#!/bin/sh # Paper installer. # Install or update: curl -fsSL https://getpaper.sh/install | sh # Uninstall: curl -fsSL https://getpaper.sh/install | sh -s -- --uninstall # # macOS: installs PaperMac. Linux (Omarchy with Hyprland): installs Paperland, with the # Paperland installer that scripts/build-site.sh inlines from paperland/ (the served # site/install is the built file; this source refuses Linux until it is built). # # The whole script is one { ... } block ending in `main "$@"; }` on the last line, so # the shell reads all of it before running any of it: a download cut short anywhere is # a syntax error and runs nothing. It never reads stdin. { set -eu # Where the release comes from. Empty: the pinned release below. Set it to # https://dl.getpaper.sh/papermac/latest.json to read the release from PaperMac's # manifest instead; then any manifest problem stops the install, never falling back to # the pin. PAPERMAC_MANIFEST_URL=https://dl.getpaper.sh/papermac/latest.json PAPERMAC_VERSION=27.0.0-alpha.3 PAPERMAC_DMG_URL=https://mjcscmicvgmdk1ey.public.blob.vercel-storage.com/papermac/PaperMac-27.0.0-alpha.3.dmg PAPERMAC_DMG_SHA256=84eb7a5ff6c0319476b6249f6060470437fd9c5b00d4fc5b6360ef342d585e03 PAPERMAC_MIN_MACOS=27 PAPERMAC_NOTARIZED=false # A manifest may only point at a disk image directly under this URL. MANIFEST_DMG_PREFIX=https://dl.getpaper.sh/papermac/ PAPERMAC_ID=dev.jsonmartin.papermac SYSTEM_APPLICATIONS=/Applications SITE=https://getpaper.sh QUIT_TIMEOUT_SECONDS=20 # PaperMac's SIGTERM handler exits within 5 seconds. TERM_TIMEOUT_SECONDS=10 UNINSTALL_WARNING="If PaperMac crashed or was force-quit, open it once and quit it before uninstalling, so it can return any hidden windows." NOT_CHANGED="The installed PaperMac was not changed." NOT_REMOVED="Nothing was removed." NL=' ' say() { printf '%s\n' "$*"; } warn() { printf 'paper: %s\n' "$*" >&2; } die() { warn "$*"; exit 1; } present() { [ -e "$1" ] || [ -L "$1" ]; } # The manifest and the disk image download with the same flags. fetch() { # URL FILE curl -fsSL --proto '=https' --tlsv1.2 -o "$2" "$1" } bad_manifest() { # FIELD die "The PaperMac release manifest at $PAPERMAC_MANIFEST_URL has a missing or invalid \"$1\". Nothing was changed." } # Reads the release from the manifest, or stops. plutil reads JSON and checks each # field's type; python3 is not used because without the Command Line Tools it is a stub # that opens an install dialog. read_manifest() { manifest=$WORK/latest.json fetch "$PAPERMAC_MANIFEST_URL" "$manifest" || die "Could not download the PaperMac release manifest from $PAPERMAC_MANIFEST_URL. Nothing was changed." version=$(plutil -extract version raw -expect string -o - "$manifest" 2>/dev/null) || bad_manifest version url=$(plutil -extract dmg_url raw -expect string -o - "$manifest" 2>/dev/null) || bad_manifest dmg_url sha=$(plutil -extract sha256 raw -expect string -o - "$manifest" 2>/dev/null) || bad_manifest sha256 min=$(plutil -extract min_macos raw -expect string -o - "$manifest" 2>/dev/null) || bad_manifest min_macos notarized=$(plutil -extract notarized raw -expect bool -o - "$manifest" 2>/dev/null) || bad_manifest notarized case $version in ''|*[!0-9a-z.-]*) bad_manifest version ;; esac printf '%s\n' "$version" | grep -Eqx '[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+\.[0-9]+)?' || bad_manifest version name=${url#"$MANIFEST_DMG_PREFIX"} [ "$name" != "$url" ] || bad_manifest dmg_url case $name in ''|.*|*[!A-Za-z0-9._-]*) bad_manifest dmg_url ;; *.dmg) ;; *) bad_manifest dmg_url ;; esac case $sha in *[!0-9a-f]*) bad_manifest sha256 ;; esac [ "${#sha}" -eq 64 ] || bad_manifest sha256 case $min in ''|*[!0-9]*|????*) bad_manifest min_macos ;; esac case $notarized in true|false) ;; *) bad_manifest notarized ;; esac PAPERMAC_VERSION=$version PAPERMAC_DMG_URL=$url PAPERMAC_DMG_SHA256=$sha PAPERMAC_MIN_MACOS=$min PAPERMAC_NOTARIZED=$notarized } check_macos_version() { version=$(sw_vers -productVersion 2>/dev/null || true) major=${version%%.*} case "$major" in ''|*[!0-9]*) die "Could not read the macOS version." ;; esac [ "$major" -ge "$PAPERMAC_MIN_MACOS" ] || die "PaperMac needs macOS $PAPERMAC_MIN_MACOS or newer; this Mac runs macOS $version. Nothing was changed." } # Only a real PaperMac bundle is ever moved or removed: never a symlink, a file, or # another app that happens to be called PaperMac.app. check_papermac() { # PATH OUTCOME if [ -L "$1" ] || [ ! -d "$1" ] || [ "$(plutil -extract CFBundleIdentifier raw -o - "$1/Contents/Info.plist" 2>/dev/null)" != "$PAPERMAC_ID" ]; then die "$1 is not PaperMac, so it was left alone. Move it out of the way, then run this again. $2" fi [ -w "${1%/*}" ] || die "PaperMac is installed in ${1%/*}, which this user cannot change. Ask an administrator, or remove it and run this again. $2" # Moving or deleting a bundle needs write access to the bundle itself. [ -w "$1" ] || die "$1 belongs to another user, so this user cannot replace or remove it. Ask that user or an administrator to remove it. $2" } # One install or uninstall at a time per Applications folder. A lock whose process is # gone is taken over under a second, short-lived lock, so two runs can never both take # it over, and a run never removes a lock it does not own. take_lock() { # APPS OUTCOME lock=$1/.PaperMac-install.lock clear_it="If none is running, remove that folder and run this again." if ! mkdir "$lock" 2>/dev/null; then present "$lock" || die "This user cannot change $1. $2" pid=$(cat "$lock/pid" 2>/dev/null) || pid= case $pid in ''|*[!0-9]*) die "Another PaperMac install or uninstall may be running: $lock exists. $clear_it $2" ;; esac if ps -p "$pid" >/dev/null 2>&1; then die "Another PaperMac install or uninstall may be running (process $pid): $lock exists. $clear_it $2" fi mkdir "$lock.takeover" 2>/dev/null || die "Another run is taking over $lock. Run this again in a moment; if this repeats, remove $lock.takeover. $2" # Holding the takeover lock, the stale lock can only have changed hands before now. if [ "$(cat "$lock/pid" 2>/dev/null)" = "$pid" ] && rm -f "$lock/pid" && rmdir "$lock" 2>/dev/null && mkdir "$lock" 2>/dev/null; then rmdir "$lock.takeover" 2>/dev/null || : say "Took over the lock left by process $pid, which is no longer running." else rmdir "$lock.takeover" 2>/dev/null || : die "Another PaperMac install or uninstall took over $lock first. Let it finish, then run this again. $2" fi fi LOCKS=$LOCKS$lock$NL printf '%s\n' "$$" > "$lock/pid" || die "Could not write $lock/pid. $2" } # Removes the locks this run took, unless another run owns one by now. release_locks() { while IFS= read -r lock; do [ -n "$lock" ] || continue owner=$(cat "$lock/pid" 2>/dev/null) || owner= if [ -z "$owner" ] || [ "$owner" = "$$" ]; then { rm -f "$lock/pid" && rmdir "$lock"; } 2>/dev/null || warn "Could not remove the lock $lock. Remove it before running this again." fi done </dev/null && pwd) || real=${app%/*} execs=$execs$app/Contents/MacOS/PaperMac$NL$real/PaperMac.app/Contents/MacOS/PaperMac$NL done </dev/null && return 0; done return 1 } # Quits the PIDS that find_papermac found. First the Apple-event quit its menu uses, # with the whole request bounded: osascript itself can hang on an unresponsive app. # SIGTERM only if that request fails or PaperMac still runs after the full wait, so a # quit in progress is never cut short. Never SIGKILL: PaperMac returns hidden windows as # it quits. quit_papermac() { # OUTCOME [ -n "$PIDS" ] || return 0 say "Quitting PaperMac..." rm -f "$WORK/quit.status" ( status=0 osascript -e "quit app id \"$PAPERMAC_ID\"" > "$WORK/quit.log" 2>&1 || status=$? echo "$status" > "$WORK/quit.status" ) 2>/dev/null & QUIT_REQUEST=$! waited=0 while papermac_running; do if [ -s "$WORK/quit.status" ] && [ "$(cat "$WORK/quit.status")" != 0 ]; then warn "PaperMac did not accept the request to quit: $(cat "$WORK/quit.log")" break fi if [ "$waited" -ge $((QUIT_TIMEOUT_SECONDS * 2)) ]; then warn "PaperMac did not quit within $QUIT_TIMEOUT_SECONDS seconds." break fi sleep 0.5 waited=$((waited + 1)) done stop_quit_request papermac_running || return 0 say "Sending PaperMac SIGTERM..." for pid in $PIDS; do kill -TERM "$pid" 2>/dev/null || :; done waited=0 while papermac_running; do [ "$waited" -lt $((TERM_TIMEOUT_SECONDS * 2)) ] || die "PaperMac is still running. Quit it from its menu bar icon, then run this again. $1" sleep 0.5 waited=$((waited + 1)) done } # Ends a quit request that is still pending, so it can never reach the PaperMac that is # about to be installed and opened: osascript first (it is the wrapper's child, and # would outlive the wrapper), then the wrapper. stop_quit_request() { [ -n "$QUIT_REQUEST" ] || return 0 pkill -TERM -P "$QUIT_REQUEST" 2>/dev/null || : kill "$QUIT_REQUEST" 2>/dev/null || : wait "$QUIT_REQUEST" 2>/dev/null || : QUIT_REQUEST='' } # Undoes whatever a failed or interrupted run left, and always gets to the detach. # Every step reports its own failure; none stops the cleanup. Returns 1 only when the # disk image stays attached. Locks are released separately, after the last step. cleanup() { result=0 stop_quit_request if [ -n "$APP_STAGE" ]; then if present "$APP_STAGE/previous.app"; then if ! present "$DEST"; then # The new copy never went in: put the old one back. if mv "$APP_STAGE/previous.app" "$DEST" 2>/dev/null; then warn "The previous PaperMac was put back at $DEST." else keep_backup fi elif present "$APP_STAGE/PaperMac.app"; then # Something that is not the new copy is at $DEST. keep_backup fi fi if [ -n "$APP_STAGE" ]; then rm -rf "$APP_STAGE" 2>/dev/null || warn "Could not fully remove $APP_STAGE. Delete that folder." APP_STAGE='' fi fi if [ -n "$MOUNTED" ]; then # A Spotlight pass over the image can make the first detach fail with "busy". if hdiutil detach -quiet "$MOUNTED" || { sleep 1; hdiutil detach -quiet "$MOUNTED"; }; then MOUNTED='' else warn "Could not detach the PaperMac disk image. Detach it with: hdiutil detach '$MOUNTED'" warn "The download is kept at $WORK" MOUNTED='' WORK='' result=1 fi fi if [ -n "$WORK" ]; then rm -rf "$WORK" 2>/dev/null || warn "Could not remove $WORK" WORK='' fi return "$result" } # The previous app could not be put back: never delete the folder that holds it. keep_backup() { rm -rf "$APP_STAGE/PaperMac.app" 2>/dev/null || : warn "The previous PaperMac is kept at $APP_STAGE/previous.app" if present "$DEST"; then warn "Something else is now at $DEST. Move it away, then put PaperMac back with:" else warn "Put it back with:" fi warn " mv '$APP_STAGE/previous.app' '$DEST'" APP_STAGE='' } install_macos() { [ -z "$PAPERMAC_MANIFEST_URL" ] || read_manifest check_macos_version # Upgrade in place; otherwise /Applications, or ~/Applications without write access. if present "$SYSTEM_APPLICATIONS/PaperMac.app"; then apps=$SYSTEM_APPLICATIONS elif present "$HOME/Applications/PaperMac.app" || [ ! -w "$SYSTEM_APPLICATIONS" ]; then apps=$HOME/Applications else apps=$SYSTEM_APPLICATIONS fi DEST=$apps/PaperMac.app mkdir -p "$apps" 2>/dev/null || : [ -w "$apps" ] || die "This user cannot change $apps, so PaperMac cannot be installed or updated there. Ask an administrator, or remove $DEST and run this again. Nothing was changed." take_lock "$apps" "Nothing was changed." if present "$DEST"; then check_papermac "$DEST" "Nothing was changed." action="Replacing the PaperMac at" else action="Installing to" fi if [ "$PAPERMAC_NOTARIZED" = true ]; then notarized=notarized; else notarized="not notarized"; fi say "Installing PaperMac $PAPERMAC_VERSION ($notarized)." say " From: $PAPERMAC_DMG_URL" say " SHA-256: $PAPERMAC_DMG_SHA256" say " $action $DEST" # Everything up to the swap happens while PaperMac keeps running, so any failure # before it leaves the user's PaperMac running and untouched. fetch "$PAPERMAC_DMG_URL" "$WORK/PaperMac.dmg" || die "Could not download PaperMac. Nothing was changed." actual=$(shasum -a 256 "$WORK/PaperMac.dmg" | awk '{print $1}') [ "$actual" = "$PAPERMAC_DMG_SHA256" ] || die "Checksum mismatch for the PaperMac download (expected $PAPERMAC_DMG_SHA256, got $actual). Nothing was changed." mkdir "$WORK/mnt" MOUNTED=$WORK/mnt # hdiutil attach prints a deprecation warning on macOS 27; its output is shown only # when it fails. if ! attach_log=$(hdiutil attach -nobrowse -readonly -mountpoint "$WORK/mnt" "$WORK/PaperMac.dmg" 2>&1); then MOUNTED='' warn "$attach_log" die "Could not open the PaperMac disk image. $NOT_CHANGED" fi [ -d "$WORK/mnt/PaperMac.app" ] || die "The PaperMac disk image does not contain PaperMac.app. $NOT_CHANGED" # Copy under a private name next to the destination and verify it, so the swap below # is two renames and there is never a partial PaperMac.app. APP_STAGE=$(mktemp -d "$apps/.PaperMac-install.XXXXXX") || die "Could not write to $apps. $NOT_CHANGED" ditto "$WORK/mnt/PaperMac.app" "$APP_STAGE/PaperMac.app" || die "Could not copy PaperMac into $apps. $NOT_CHANGED" codesign --verify --deep --strict "$APP_STAGE/PaperMac.app" || die "The downloaded PaperMac failed code signature verification. $NOT_CHANGED" # The new copy is ready: only now quit PaperMac, and only the copy being replaced. # What is at $DEST is checked again before the quit and before it is moved aside. if present "$DEST"; then check_papermac "$DEST" "$NOT_CHANGED"; fi find_papermac "$DEST" "$NOT_CHANGED" quit_papermac "$NOT_CHANGED" if present "$DEST"; then check_papermac "$DEST" "$NOT_CHANGED" # A SIGKILL or power loss between the two renames leaves the old app here. say "Moving the current PaperMac to $APP_STAGE/previous.app while the new one goes in." mv "$DEST" "$APP_STAGE/previous.app" || die "Could not move the current PaperMac aside. $NOT_CHANGED" fi # mv onto an existing directory would put the app inside it. ! present "$DEST" || die "Something else appeared at $DEST during the install." mv "$APP_STAGE/PaperMac.app" "$DEST" || die "Could not move the new PaperMac into $apps." if ! cleanup; then die "PaperMac $PAPERMAC_VERSION is installed at $DEST. Open it with: open '$DEST'" fi say "PaperMac $PAPERMAC_VERSION is installed at $DEST. Opening it; its Welcome window sets up Accessibility." open "$DEST" say "Sparkle keeps PaperMac up to date. To reinstall, run the same command again." release_locks } # Uninstall removes a bundle whole or not at all, so a checked bundle must also be # deletable throughout: every folder in it readable and writable by this user. check_deletable() { # APP locked=$(find "$1" -type d 2>/dev/null | while IFS= read -r dir; do if [ ! -r "$dir" ] || [ ! -w "$dir" ] || [ ! -x "$dir" ]; then printf '%s\n' "$dir"; break; fi done) [ -z "$locked" ] || die "This user cannot delete $locked, inside $1. Ask an administrator to remove $1. $NOT_REMOVED" } # Renames the bundle out of place first, so a failed delete never leaves a half-deleted # PaperMac.app at the real path; anything left over is reported where it is. remove_papermac() { # APP aside=$(mktemp -d "${1%/*}/.PaperMac-uninstall.XXXXXX") || die "Could not remove $1; it was left in place." if ! mv "$1" "$aside/PaperMac.app"; then rmdir "$aside" 2>/dev/null || : die "Could not remove $1; it was left in place." fi if rm -rf "$aside" 2>/dev/null; then say "Removed $1" else warn "PaperMac was removed from $1, but some of its files could not be deleted. They are in $aside; delete that folder." LEFTOVER=1 fi } uninstall_macos() { say "$UNINSTALL_WARNING" # The locks come first, in a fixed order, and cover the checks, the quit and the removal. for app in "$SYSTEM_APPLICATIONS/PaperMac.app" "$HOME/Applications/PaperMac.app"; do if present "$app"; then take_lock "${app%/*}" "$NOT_REMOVED"; fi done targets= for app in "$SYSTEM_APPLICATIONS/PaperMac.app" "$HOME/Applications/PaperMac.app"; do present "$app" || continue check_papermac "$app" "$NOT_REMOVED" check_deletable "$app" targets=$targets$app$NL done if [ -z "$targets" ]; then say "PaperMac is not installed in $SYSTEM_APPLICATIONS or ~/Applications; nothing to do." return 0 fi find_papermac "$targets" "$NOT_REMOVED" quit_papermac "$NOT_REMOVED" LEFTOVER= for app in "$SYSTEM_APPLICATIONS/PaperMac.app" "$HOME/Applications/PaperMac.app"; do case $NL$targets in *"$NL$app$NL"*) remove_papermac "$app" ;; esac done release_locks say "PaperMac's settings were kept. To remove them too, run:" say " rm -rf ~/.config/papermac" say " rm -rf ~/'Library/Application Support/PaperMac'" [ -z "$LEFTOVER" ] || exit 1 } # BEGIN PAPERLAND: generated by scripts/build-site.sh from paperland/install and # paperland/uninstall with paperland/release.env inlined; edit those, not this. # Each runs in its own subshell, so its functions, variables, set -eu, traps and # exits stay inside it, exactly as when it ran as a script of its own. # shellcheck disable=SC2030,SC2031,SC2329 # isolated on purpose: its settings stay in its subshell; traps call its handlers paperland_install() ( #!/bin/sh # Paperland installer. scripts/build-site.sh inlines it, built with release.env, into # the served /install, which runs it on Linux; see README.md in this folder. # # Linux (Omarchy): installs or upgrades Paperland as the Omarchy plugin json.paperland. # The pinned release is cloned and verified outside Omarchy's watched plugin folder, # then published into it with one rename, so omarchy-shell only ever sees verified code. # # This file is read from stdin under `curl | sh`: it never reads input from stdin, # and everything runs from main() on the last line so a truncated download runs nothing. set -eu PLUGIN_URL=https://github.com/getpapersh/paperland.git PLUGIN_SHA=f444cc52fb83c3b6f96bc9983f14a12b8379733f MIN_HYPRLAND=0.56 PLUGIN_ID=json.paperland SITE=https://getpaper.sh UNINSTALL_CMD="curl --proto '=https' --tlsv1.2 -fsSL $SITE/install | sh -s -- --uninstall" MARKER="Paperland CLI installation v1" say() { printf '%s\n' "$*"; } warn() { printf 'paper: %s\n' "$*" >&2; } die() { warn "$*"; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } # Git that ignores the caller's and the system's git setup: no inherited GIT_* variable # (redirected repositories, injected config, templates, TLS or exec-path overrides), no # global, system or attributes files, no hooks, no replace refs, no prompts, verified # TLS, and HTTPS as the only transport. cgit() ( for var in $(env | sed -n 's/^\(GIT_[A-Za-z0-9_]*\)=.*/\1/p'); do unset "$var"; done export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_ATTR_NOSYSTEM=1 GIT_NO_REPLACE_OBJECTS=1 GIT_TERMINAL_PROMPT=0 exec git -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.attributesFile=/dev/null \ -c http.sslVerify=true -c protocol.allow=never -c protocol.https.allow=always "$@" ) check_release_values() { [ -n "${PLUGIN_URL:-}" ] || die "This installer was not built: run scripts/build-site.sh and use site/install." [ "$PLUGIN_SHA" != PENDING ] || die "No Paperland release is pinned yet (PLUGIN_SHA=PENDING). Nothing was changed." case "$PLUGIN_URL" in https://*) ;; *) die "Refusing a plugin URL that is not HTTPS: $PLUGIN_URL" ;; esac printf '%s\n' "$PLUGIN_SHA" | grep -Eq '^[0-9a-f]{40}$' || die "Pinned plugin commit is not a 40-character hex SHA: $PLUGIN_SHA" } # Prints "MAJOR.MINOR" of the installed Hyprland, or nothing. hyprland_version() { if [ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]; then hv_out=$(hyprctl version 2>/dev/null || true) elif have Hyprland; then hv_out=$(Hyprland --version 2>/dev/null || true) else hv_out= fi printf '%s\n' "$hv_out" | sed -n '1s/^Hyprland \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' } # Prints PATH or the first of its parents that is a symlink, or nothing. # Paperland's setup never writes through one. first_symlink() { p=$1 while [ -n "$p" ]; do if [ -L "$p" ]; then printf '%s\n' "$p"; return 0; fi case "$p" in */*) p=${p%/*} ;; *) p= ;; esac done } owned_runtime() { [ -d "$RUNTIME" ] && [ ! -L "$RUNTIME" ] && [ "$(cat "$RUNTIME/.installed-by-paperland" 2>/dev/null)" = "$MARKER" ]; } check_linux() { have omarchy-version || die "Paperland installs as an Omarchy plugin and needs Omarchy 4 or newer (omarchy-version was not found on PATH)." ov=$(omarchy-version 2>/dev/null || true) # A development checkout reports "dev"; the checkout's own version file says which. case "$ov" in dev*) dev=$ov ov= if [ -n "${OMARCHY_PATH:-}" ]; then ov=$(head -n 1 "${OMARCHY_PATH%/}/version" 2>/dev/null || true); fi [ -n "$ov" ] || die "This system runs a development build of Omarchy ($dev) whose version could not be read from \$OMARCHY_PATH/version. Paperland needs Omarchy 4 or newer." ;; esac ov_major=$(printf '%s\n' "$ov" | sed -n '1s/^\([0-9][0-9]*\)\..*/\1/p') [ -n "$ov_major" ] && [ "$ov_major" -ge 4 ] || die "Paperland needs Omarchy 4 or newer; this system has Omarchy ${ov:-of unknown version}." for cmd in omarchy omarchy-shell omarchy-plugin-list omarchy-plugin-validate hyprctl quickshell python3 git jq; do have "$cmd" || die "Paperland needs $cmd, which was not found on PATH." done hv=$(hyprland_version) [ -n "$hv" ] || die "Could not read the Hyprland version. Paperland needs Hyprland $MIN_HYPRLAND or newer." hv_major=${hv%%.*}; hv_minor=${hv#*.} min_major=${MIN_HYPRLAND%%.*}; min_minor=${MIN_HYPRLAND#*.} if [ "$hv_major" -lt "$min_major" ] || { [ "$hv_major" -eq "$min_major" ] && [ "$hv_minor" -lt "$min_minor" ]; }; then die "Paperland needs Hyprland $MIN_HYPRLAND or newer; this system has Hyprland $hv." fi omarchy-shell shell listPlugins >/dev/null 2>&1 || die "omarchy-shell is not running. Run this from a terminal inside your Omarchy session." EXISTING= if [ -L "$PLUGIN_DIR" ]; then die "$PLUGIN_DIR is a link left by an earlier Paperland setup. First run: $UNINSTALL_CMD" elif [ -e "$PLUGIN_DIR" ]; then # Only a checkout of the pinned repo is replaced. --file reads exactly this config, # with no repository discovery and nothing executed from the checkout. [ -d "$PLUGIN_DIR/.git" ] && [ ! -L "$PLUGIN_DIR/.git" ] && [ "$(cgit config --file "$PLUGIN_DIR/.git/config" --get remote.origin.url 2>/dev/null || true)" = "$PLUGIN_URL" ] || die "Paperland is already installed at $PLUGIN_DIR from somewhere else. To reinstall, first run: $UNINSTALL_CMD" EXISTING=1 fi # Paperland's own installer refuses these; check before changing anything. if [ -e "$RUNTIME" ] || [ -L "$RUNTIME" ]; then owned_runtime || die "$RUNTIME exists and was not installed by Paperland. Move it aside, then rerun." fi if [ -e "$LAUNCHER" ] || [ -L "$LAUNCHER" ]; then [ -L "$LAUNCHER" ] && [ "$(readlink "$LAUNCHER")" = "$RUNTIME/paperland" ] || die "$LAUNCHER already exists and is not Paperland's launcher. Move it aside, then rerun." fi } # Saved choices win: fresh-install defaults only apply when there are none. setup_desc() { if [ -f "$HYPR_DIR/paperland.lua" ]; then SETUP_DESC="Your saved Paperland shortcut and autostart choices are applied again." else SETUP_DESC="SUPER + M opens the minimap, replacing any existing SUPER + M binding, and Paperland starts hidden at login." fi } run_setup() { if [ -f "$HYPR_DIR/paperland.lua" ]; then "$LAUNCHER" setup --apply --replace-bindings else "$LAUNCHER" setup --shortcut 'SUPER + M' --autostart on --apply --replace-bindings fi } print_plan() { if [ -n "$EXISTING" ]; then head_line="Paperland is already installed from $PLUGIN_URL. This will:" publish_line="Move the installed plugin to a backup folder in $OMARCHY_DIR (kept; its path is printed) and put the verified copy in its place." else head_line="This will:" publish_line="Put the verified copy in place as the Omarchy plugin $PLUGIN_ID and enable it in your Omarchy bar." fi if [ -z "$NEEDS_SETUP" ]; then setup_line="Keep your Paperland shortcut and autostart setup unchanged." elif [ -n "$MAIN_LINK" ] && [ -n "$EDIT_DOTFILES" ]; then setup_desc setup_line="Write $HYPR_DIR/paperland.lua. $HYPR_DIR/hyprland.lua is a symlink to $MAIN_LINK. With --edit-dotfiles, add Paperland's marked include to that file in place (the link stays a link, and nothing else is written there), then have Paperland check it. $SETUP_DESC" elif [ -n "$MAIN_LINK" ]; then setup_desc setup_line="Write $HYPR_DIR/paperland.lua. $HYPR_DIR/hyprland.lua is a symlink to $MAIN_LINK, which this installer does not edit by default: Paperland prints the lines to paste there, and this run stops. Paste them and run it again, or rerun with --edit-dotfiles to have them added for you. $SETUP_DESC" else setup_desc setup_line="Write $HYPR_DIR/paperland.lua and one marked include in $HYPR_DIR/hyprland.lua (Paperland backs both up first). $SETUP_DESC" fi cat </dev/null || die "No terminal to confirm on, so nothing was changed. To accept the changes above without a prompt, run: curl --proto '=https' --tlsv1.2 -fsSL $SITE/install | PAPER_YES=1 sh" printf 'Continue? [y/N] ' >/dev/tty read -r answer /dev/null || die "The pinned release $PLUGIN_SHA was not found on the plugin remote. Nothing was changed." actual=$(cgit -C "$tree" rev-parse HEAD 2>/dev/null || true) [ "$actual" = "$PLUGIN_SHA" ] || die "Integrity check failed: expected commit $PLUGIN_SHA, got ${actual:-none}. Nothing was changed." link=$(find "$tree" -path "$tree/.git" -prune -o -type l -print | head -n 1) [ -z "$link" ] || die "The release contains a symlink (${link#"$tree"/}). Nothing was changed." same_bytes_as_pin || die "The staged release does not match commit $PLUGIN_SHA exactly. Nothing was changed." id=$(jq -r '.id // empty' "$tree/manifest.json" 2>/dev/null || true) [ "$id" = "$PLUGIN_ID" ] || die "The release's manifest id is '${id:-missing}', not $PLUGIN_ID. Nothing was changed." omarchy-plugin-validate "$tree" || die "The release failed Omarchy's plugin validation. Nothing was changed." } # True when the staged files are exactly the pinned tree's blobs: the same paths, and # each file's raw bytes hash to its blob id. --no-filters hashes the bytes on disk, so no # filter, attribute or line-ending setting can make other bytes pass as the pin. same_bytes_as_pin() { # Regular blobs only; git quotes unusual paths, and those fail closed. cgit -C "$tree" ls-tree -r --full-tree "$PLUGIN_SHA" > "$STAGE_ROOT/tree" || return 1 awk -F '\t' ' $1 !~ /^100(644|755) blob [0-9a-f]+$/ || $2 ~ /^"/ { bad = 1 } { split($1, f, " "); print f[3] " " $2 } END { exit bad }' "$STAGE_ROOT/tree" > "$STAGE_ROOT/listed" || return 1 sort "$STAGE_ROOT/listed" > "$STAGE_ROOT/expected" (cd "$tree" && find . -path ./.git -prune -o -type f -print) | sed 's|^\./||' > "$STAGE_ROOT/paths" cgit -C "$tree" hash-object --no-filters --stdin-paths < "$STAGE_ROOT/paths" > "$STAGE_ROOT/hashes" || return 1 awk 'NR == FNR { oid[FNR] = $0; next } { print oid[FNR] " " $0 }' "$STAGE_ROOT/hashes" "$STAGE_ROOT/paths" | sort > "$STAGE_ROOT/actual" [ -s "$STAGE_ROOT/expected" ] && [ "$(cat "$STAGE_ROOT/expected")" = "$(cat "$STAGE_ROOT/actual")" ] } # Renames only into empty destinations. on_exit works out what happened from the # paths, so a signal between a rename and the next line cannot mislead it. publish() { if [ -n "$EXISTING" ]; then OLD_HEAD=$(cgit -C "$PLUGIN_DIR" rev-parse HEAD 2>/dev/null || true) BACKUP=$(mktemp -d "$OMARCHY_DIR/.paperland-previous-$(date +%Y%m%d-%H%M%S).XXXXXX") mv "$PLUGIN_DIR" "$BACKUP/$PLUGIN_ID" fi absent "$PLUGIN_DIR" || die "Something else created $PLUGIN_DIR during the install." mv "$tree" "$PLUGIN_DIR" # A folder created between the check and the rename gets the tree nested inside it; # take the tree back out so on_exit never mistakes that folder for this release. if [ ! -f "$PLUGIN_DIR/manifest.json" ] || ! absent "$PLUGIN_DIR/$PLUGIN_ID"; then if absent "$tree"; then mv "$PLUGIN_DIR/$PLUGIN_ID" "$tree" || true; fi die "Something else created $PLUGIN_DIR during the install." fi } absent() { [ ! -e "$1" ] && [ ! -L "$1" ]; } # Reports and undoes a failure. Before the runtime refresh starts, a moved-aside plugin # is put back; after it, the new plugin stays with the new runtime and a rerun finishes. on_exit() { status=$? # A second signal must not cut the rollback short. trap '' INT TERM HUP prev=${BACKUP:+$BACKUP/$PLUGIN_ID} if [ "$status" -eq 0 ]; then : elif [ -n "$RUNTIME_STARTED" ]; then if [ -z "$RUNTIME_DONE" ]; then warn "The Paperland runtime may not have finished installing. Run the same command again to finish, or remove Paperland with: $UNINSTALL_CMD" elif [ -n "$MAIN_LINK" ] && [ -n "$SETUP_STARTED" ] && [ -z "$INCLUDE_ADDED" ] && [ -n "$EDIT_DOTFILES" ]; then warn "Fix what Paperland reported above, then run the same command again with --edit-dotfiles." elif [ -n "$MAIN_LINK" ] && [ -n "$SETUP_STARTED" ] && [ -z "$INCLUDE_ADDED" ]; then warn "If Paperland printed lines to paste above, paste them into $MAIN_LINK. Then run the same command again to finish." else warn "Paperland's plugin and runtime were installed but setup did not finish. Run the same command again to finish, or remove Paperland with: $UNINSTALL_CMD" fi if [ -n "$prev" ] && [ -e "$prev" ]; then warn "The replaced plugin is kept at $prev" warn "Delete it when you no longer need it: rm -rf '$BACKUP'" fi elif [ -n "$prev" ] && [ -e "$prev" ]; then # Never mv into an occupied path: that nests one plugin inside the other. Only the # copy this run published (its staged tree is gone) is taken out again. if ! absent "$PLUGIN_DIR" && absent "$tree"; then mv "$PLUGIN_DIR" "$STAGE_ROOT/failed-$PLUGIN_ID" || true; fi if absent "$PLUGIN_DIR" && mv "$prev" "$PLUGIN_DIR"; then warn "The previous plugin was restored to $PLUGIN_DIR." else if absent "$PLUGIN_DIR"; then warn "Could not restore the previous plugin to $PLUGIN_DIR. Restore it with: mv '$prev' '$PLUGIN_DIR'" else warn "Could not restore the previous plugin to $PLUGIN_DIR, which is occupied; it is at $prev. Once $PLUGIN_DIR is moved away, restore it with: mv '$prev' '$PLUGIN_DIR'" fi fi elif [ -z "$EXISTING" ] && [ -n "$tree" ] && absent "$tree" && [ -d "$PLUGIN_DIR" ]; then warn "Paperland's plugin was installed but setup did not finish. Run the same command again to finish, or remove Paperland with: $UNINSTALL_CMD" fi if [ -n "$STAGE_ROOT" ]; then rm -rf "$STAGE_ROOT"; fi if [ -n "$BACKUP" ]; then rmdir "$BACKUP" 2>/dev/null || true; fi } plugin_listed() { # JQ_FILTER: true when omarchy-plugin-list --json matches it omarchy-plugin-list --json 2>/dev/null | jq -e --arg id "$PLUGIN_ID" "$1" >/dev/null 2>&1 } # Enables the widget on a first install only. On a rerun a disabled widget may be the # user's choice, so it stays off and the command to turn it on is printed. # shellcheck disable=SC2016 # $id is a jq variable enable_plugin() { omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true i=0 until plugin_listed 'any(.[]; .id == $id)'; do i=$((i + 1)) [ "$i" -lt 40 ] || die "Omarchy did not discover $PLUGIN_ID after it was installed." sleep 0.05 done if [ -z "$EXISTING" ] && ! plugin_listed 'any(.[]; .id == $id and .enabled)'; then omarchy plugin enable "$PLUGIN_ID" fi } # The widget runs this launcher instead of whatever `paperland` omarchy-shell's PATH finds. # shellcheck disable=SC2016 # $id is a jq variable set_widget_launcher() { if ! plugin_listed 'any(.[]; .id == $id and .enabled)'; then WIDGET_OFF=1 elif ! omarchy bar set "$PLUGIN_ID" executable "$RUNTIME/paperland" >/dev/null; then warn "Could not point the bar widget at $RUNTIME/paperland, so it runs 'paperland' from PATH. To set it: omarchy bar set $PLUGIN_ID executable $RUNTIME/paperland" fi } # The replaced plugin is always kept: git cannot show all of a checkout's work # (assume-unchanged files, a moved worktree, unpushed commits). report_previous() { [ -n "$BACKUP" ] || return 0 old=$BACKUP/$PLUGIN_ID say "The replaced plugin is kept at $old" say "Delete it when you no longer need it: rm -rf '$BACKUP'" # Git in the user's old checkout only reads (rev-parse in publish, merge-base here), # and the -c settings override its local hooks and fsmonitor. if [ -n "$OLD_HEAD" ] && [ "$OLD_HEAD" != "$PLUGIN_SHA" ] && cgit -C "$old" merge-base --is-ancestor "$PLUGIN_SHA" "$OLD_HEAD" 2>/dev/null; then say "Note: the replaced plugin was at $OLD_HEAD, newer than this release; it now runs the pinned release." fi } # Paperland's preview checks an include that was added by hand or by --edit-dotfiles, # without writing anything. check_symlinked_include() { "$LAUNCHER" setup --dry-run >/dev/null || die "Paperland's setup check failed; see its message above. If it names the include, fix it in $MAIN_LINK." } # --edit-dotfiles: appends the block Paperland's setup printed to the file hyprland.lua # links to. Only for a plain "add these lines" request: anything else (a block to # replace, lines to remove, another error) stops with the file untouched. add_include_to_dotfiles() { out=$STAGE_ROOT/setup.out main=$HYPR_DIR/hyprland.lua if ! grep -qF 'Symlinked main config needs a manual include.' "$out" || ! grep -qF 'Add these exact lines to its tracked target:' "$out" || grep -q '^Remove ' "$out" || grep -qF -- '-- BEGIN Paperland setup' "$main"; then die "Paperland's setup stopped for another reason (above), so $MAIN_LINK was not edited." fi block=$(sed -n '/^-- BEGIN Paperland setup$/,/^-- END Paperland setup$/p' "$out") [ "$(printf '%s\n' "$block" | grep -c '^-- BEGIN Paperland setup$')" = 1 ] && [ "$(printf '%s\n' "$block" | tail -n 1)" = '-- END Paperland setup' ] || die "Paperland's setup did not print one complete include, so $MAIN_LINK was not edited." [ -f "$main" ] && [ -w "$main" ] || die "$MAIN_LINK is not a writable file, so it was not edited." # Appending through the link edits the real file in place: same file, mode and owner, # and the link stays a link. The layout matches what Paperland's setup appends. sep= if [ -s "$main" ] && [ -n "$(tail -c 1 "$main")" ]; then sep=' '; fi printf '%s\n%s\n' "$sep" "$block" >> "$main" INCLUDE_ADDED=1 say "Added Paperland's include to $MAIN_LINK." } install_linux() { check_release_values OMARCHY_DIR=$HOME/.config/omarchy PLUGIN_DIR=$OMARCHY_DIR/plugins/$PLUGIN_ID RUNTIME=${XDG_DATA_HOME:-$HOME/.local/share}/paperland LAUNCHER=$HOME/.local/bin/paperland HYPR_DIR=${XDG_CONFIG_HOME:-$HOME/.config}/hypr STAGE_ROOT='' tree='' BACKUP='' OLD_HEAD='' RUNTIME_STARTED='' RUNTIME_DONE='' SETUP_STARTED='' INCLUDE_ADDED='' WIDGET_OFF='' MAIN_LINK='' check_linux # Checked before the runtime refresh, which always recreates the launcher. NEEDS_SETUP= if [ -z "$EXISTING" ] || [ ! -L "$LAUNCHER" ] || [ ! -f "$HYPR_DIR/paperland.lua" ] || ! grep -qF -- '-- BEGIN Paperland setup' "$HYPR_DIR/hyprland.lua" 2>/dev/null; then NEEDS_SETUP=1 fi if [ -n "$NEEDS_SETUP" ]; then # Paperland's setup refuses a symlinked folder anywhere above paperland.lua, and # for a symlinked hyprland.lua only prints an include to paste. folder_link=$(first_symlink "$HYPR_DIR") if [ -n "$folder_link" ]; then refusal="Paperland's setup cannot manage $HYPR_DIR: $folder_link is a symlink to $(readlink "$folder_link"). Paperland's setup writes its generated files into $HYPR_DIR and never writes through a symlink. This is lifted once Paperland keeps its generated files outside $HYPR_DIR." if [ -n "$EDIT_DOTFILES" ]; then refusal="$refusal --edit-dotfiles does not lift this yet."; fi die "$refusal Nothing was changed." fi fi if [ -L "$HYPR_DIR/hyprland.lua" ]; then # The file at the end of the chain, which is what an append through the link edits. MAIN_LINK=$(readlink -f "$HYPR_DIR/hyprland.lua" 2>/dev/null || readlink "$HYPR_DIR/hyprland.lua") case "$MAIN_LINK" in /*) ;; *) MAIN_LINK=$HYPR_DIR/$MAIN_LINK ;; esac elif [ -n "$EDIT_DOTFILES" ]; then die "--edit-dotfiles applies only when $HYPR_DIR/hyprland.lua is a symlink, and here it is not; Paperland's setup edits it directly. Rerun without --edit-dotfiles. Nothing was changed." fi print_plan confirm trap on_exit EXIT trap 'exit 130' INT TERM HUP stage_and_verify publish enable_plugin # setup only accepts the marker-owned runtime that `install --no-setup` publishes # from the verified plugin; bindings then point at a launcher that exists. # Set first: after a signal during the refresh the runtime may be new, so on_exit # keeps the new plugin. A failed refresh has rolled itself back. RUNTIME_STARTED=1 "$PLUGIN_DIR/paperland/paperland" install --no-setup || { RUNTIME_STARTED=; exit 1; } RUNTIME_DONE=1 set_widget_launcher if [ -n "$NEEDS_SETUP" ]; then SETUP_STARTED=1 if [ -n "$EDIT_DOTFILES" ] && [ -n "$MAIN_LINK" ]; then setup_status=0 run_setup > "$STAGE_ROOT/setup.out" 2>&1 || setup_status=$? cat "$STAGE_ROOT/setup.out" if [ "$setup_status" -ne 0 ]; then add_include_to_dotfiles; fi check_symlinked_include else run_setup fi elif [ -n "$MAIN_LINK" ]; then check_symlinked_include fi report_previous say "" if [ -n "$EXISTING" ]; then say "Paperland is up to date at $PLUGIN_SHA." # Paperland's install never restarts a running Paperland. say "Restart Paperland to finish: paperland stop && paperland start-hidden" else say "Paperland is installed at $PLUGIN_SHA. Open the minimap with your Paperland shortcut (SUPER + M by default)." fi if [ -n "$INCLUDE_ADDED" ]; then say "Hyprland loads the new include on its next config reload (hyprctl reload)." fi if [ -n "$WIDGET_OFF" ]; then say "The Paperland bar widget is off. To add it to your bar: omarchy plugin enable $PLUGIN_ID && omarchy bar set $PLUGIN_ID executable $RUNTIME/paperland" fi case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) warn "$HOME/.local/bin is not on PATH; add it to run 'paperland' in a terminal." ;; esac say "To update, run the same command again. Uninstall: $UNINSTALL_CMD" } usage() { cat <&2; die "Unknown option: $arg" ;; esac done [ "$(id -u)" -ne 0 ] || die "Do not run this installer as root or with sudo; run it as your normal user." case "$(uname -s)" in Linux) install_linux ;; Darwin) [ -z "$EDIT_DOTFILES" ] || die "--edit-dotfiles applies only to Paperland on Linux; PaperMac has no Hyprland config." die "This Paperland installer does not handle macOS; the served /install installs PaperMac there." ;; *) die "Unsupported system: $(uname -s). Paperland needs Linux with Omarchy; PaperMac needs macOS." ;; esac } main "$@" ) # shellcheck disable=SC2030,SC2031,SC2120,SC2329 # isolated on purpose, as above; it takes no options paperland_uninstall() ( #!/bin/sh # Paperland uninstaller. scripts/build-site.sh inlines it, built with release.env, into # the served /install, which runs it on Linux for --uninstall; see README.md in this folder. # # Linux: removes Paperland's shortcuts and autostart with Paperland's own setup, # detaches its include, then removes the Omarchy plugin. It never edits or deletes # user configuration directly, runs Paperland only through its own marker-checked # launcher, and reports anything it had to leave in place. # Read from stdin under `curl | sh`: never reads input, and runs only from main(). set -eu PLUGIN_URL=https://github.com/getpapersh/paperland.git PLUGIN_ID=json.paperland MARKER="Paperland CLI installation v1" say() { printf '%s\n' "$*"; } die() { printf 'paper: %s\n' "$*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } # Same isolation as the installer: nothing from the caller's or system git setup. cgit() ( for var in $(env | sed -n 's/^\(GIT_[A-Za-z0-9_]*\)=.*/\1/p'); do unset "$var"; done export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_ATTR_NOSYSTEM=1 GIT_NO_REPLACE_OBJECTS=1 GIT_TERMINAL_PROMPT=0 exec git -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.attributesFile=/dev/null \ -c http.sslVerify=true -c protocol.allow=never -c protocol.https.allow=always "$@" ) # Prints PATH or the first of its parents that is a symlink, or nothing. first_symlink() { p=$1 while [ -n "$p" ]; do if [ -L "$p" ]; then printf '%s\n' "$p"; return 0; fi case "$p" in */*) p=${p%/*} ;; *) p= ;; esac done } # Paperland's setup and uninstall never write through a symlink, so a symlinked # hyprland.lua or Hyprland folder is undone by hand before this uninstaller can finish. manual_recipe() { # LINK FOLDER_TOO main=$hypr/hyprland.lua if [ -L "$main" ]; then main=$(readlink -f "$main" 2>/dev/null || readlink "$main") case "$main" in /*) ;; *) main=$hypr/$main ;; esac fi printf '%s\n' "paper: $1 is a symlink, and Paperland never edits through one. Nothing was removed." \ "Remove Paperland's setup by hand:" \ " 1. Delete the lines from '-- BEGIN Paperland setup' to '-- END Paperland setup' in $main, if present." \ " 2. rm '$hypr/paperland.lua'" >&2 if [ -n "$2" ]; then printf '%s\n' " 3. rm '$launcher' && rm -rf '$runtime'" >&2; fi printf '%s\n' "Then run this uninstaller again." >&2 exit 1 } # Paperland's uninstall refuses while shell.json names this plugin with its launcher, # which the widget's executable setting does. bar_names_launcher() { grep -qF "$PLUGIN_ID" "$omarchy_dir/shell.json" 2>/dev/null && { grep -qF "$runtime/paperland" "$omarchy_dir/shell.json" || grep -qF "$launcher" "$omarchy_dir/shell.json"; } } remove_plugin() { have omarchy || { LEFT="$LEFT $plugin_dir (the omarchy command was not found)"; return 0; } omarchy plugin remove "$PLUGIN_ID" --yes } uninstall_linux() { [ -n "${PLUGIN_URL:-}" ] || die "This uninstaller was not built: run scripts/build-site.sh and use site/install." omarchy_dir=$HOME/.config/omarchy plugin_dir=$omarchy_dir/plugins/$PLUGIN_ID runtime=${XDG_DATA_HOME:-$HOME/.local/share}/paperland launcher=$HOME/.local/bin/paperland hypr=${XDG_CONFIG_HOME:-$HOME/.config}/hypr LEFT= found= REPAIR="To repair, install again and then rerun this uninstaller, or remove these by hand." # Omarchy's plugin removal needs omarchy-shell; check before anything is removed. if [ -e "$plugin_dir" ] || [ -L "$plugin_dir" ]; then omarchy-shell shell listPlugins >/dev/null 2>&1 || die "omarchy-shell is not running, and Omarchy needs it to remove the plugin. Run this from a terminal inside your Omarchy session. Nothing was removed." fi ours= if [ -L "$launcher" ] && [ "$(readlink "$launcher")" = "$runtime/paperland" ] && [ ! -L "$runtime" ] && [ "$(cat "$runtime/.installed-by-paperland" 2>/dev/null)" = "$MARKER" ] && [ -x "$runtime/paperland" ]; then ours=1 folder_link=$(first_symlink "$hypr") if [ -n "$folder_link" ]; then manual_recipe "$folder_link" folder elif [ -L "$hypr/hyprland.lua" ] && { [ -e "$hypr/paperland.lua" ] || grep -qF -- '-- BEGIN Paperland setup' "$hypr/hyprland.lua" 2>/dev/null; }; then manual_recipe "$hypr/hyprland.lua" '' fi fi # An earlier `paperland setup --omarchy-bar enable` linked the plugin into the # runtime; Paperland's uninstall refuses while that link exists, so it goes first. if [ -L "$plugin_dir" ] && [ "$(readlink "$plugin_dir")" = "$runtime/plugin" ]; then found=1 remove_plugin fi if [ -n "$ours" ]; then found=1 say "Removing Paperland shortcuts and autostart..." "$launcher" setup --shortcut none --resize system --autostart off --strip-chord none --apply || die "Paperland setup could not remove its shortcuts; nothing else was removed. Fix the reported problem and rerun." elif [ -e "$launcher" ] || [ -L "$launcher" ]; then LEFT="$LEFT $launcher (not Paperland's launcher, or its runtime is missing; not run)" fi # The plugin goes before Paperland's uninstall: removing it drops the widget's bar item # from shell.json, and with it the launcher path that Paperland refuses to uninstall under. if [ -L "$plugin_dir" ]; then LEFT="$LEFT $plugin_dir (a link this uninstaller did not create)" elif [ -d "$plugin_dir" ]; then found=1 origin= if [ -d "$plugin_dir/.git" ]; then origin=$(cgit config --file "$plugin_dir/.git/config" --get remote.origin.url 2>/dev/null || true) fi if [ -d "$plugin_dir/.git" ] && [ "$origin" != "$PLUGIN_URL" ]; then LEFT="$LEFT $plugin_dir (a checkout of ${origin:-an unknown repository}, not $PLUGIN_URL)" else # Omarchy deletes what it takes for a git checkout, and git cannot show all of a # checkout's work (assume-unchanged files, a moved worktree, unpushed commits): # always keep a copy. kept=$(mktemp -d "$omarchy_dir/.paperland-removed-$(date +%Y%m%d-%H%M%S).XXXXXX") cp -RP "$plugin_dir" "$kept/" || die "Could not copy $plugin_dir to $kept, so the plugin was not removed." say "A copy of the plugin is kept at $kept/$PLUGIN_ID" say "Delete it when you no longer need it: rm -rf '$kept'" remove_plugin fi elif [ -e "$plugin_dir" ]; then found=1 remove_plugin fi if [ -n "$ours" ]; then # omarchy-shell may finish writing shell.json after `omarchy plugin remove` returns. i=0 while bar_names_launcher && [ "$i" -lt 40 ]; do i=$((i + 1)); sleep 0.05; done if bar_names_launcher; then # Paperland's uninstall would refuse; neither a rerun nor a reinstall clears the item. LEFT="$LEFT $launcher (not run: $omarchy_dir/shell.json still names $PLUGIN_ID with this launcher)" REPAIR="Run 'omarchy plugin disable $PLUGIN_ID' (or remove its item from $omarchy_dir/shell.json), then rerun this uninstaller." elif ! "$launcher" uninstall; then LEFT="$LEFT $launcher (Paperland's uninstall stopped; its reason is above)" fi fi if [ -e "$runtime" ] || [ -L "$runtime" ]; then LEFT="$LEFT $runtime (Paperland runtime)"; fi if [ -f "$hypr/paperland.lua" ]; then LEFT="$LEFT $hypr/paperland.lua (generated Paperland config)"; fi if grep -qF -- '-- BEGIN Paperland setup' "$hypr/hyprland.lua" 2>/dev/null; then LEFT="$LEFT the marked Paperland include in $hypr/hyprland.lua"; fi # The installer never prunes the plugins it replaced; list them, never delete them. for previous in "$omarchy_dir"/.paperland-previous-*; do if [ -d "$previous" ]; then say "Kept by earlier installs: $previous (delete with: rm -rf '$previous')"; fi done if [ -n "$LEFT" ]; then printf 'paper: Paperland was not fully uninstalled. Left in place:%s\n' "$LEFT" >&2 die "$REPAIR" elif [ -z "$found" ]; then say "Paperland is not installed; nothing to do." else say "Paperland is uninstalled. Your settings and config backups were kept." fi } main() { for arg do case "$arg" in --edit-dotfiles) die "--edit-dotfiles applies only to the installer. For a symlinked Hyprland config, this uninstaller prints the lines to remove by hand." ;; *) die "Unknown option: $arg. The uninstaller takes no options." ;; esac done [ "$(id -u)" -ne 0 ] || die "Do not run this uninstaller as root or with sudo; run it as your normal user." case "$(uname -s)" in Linux) uninstall_linux ;; Darwin) die "This Paperland uninstaller does not handle macOS." ;; *) die "Unsupported system: $(uname -s)." ;; esac } main "$@" ) # END PAPERLAND # shellcheck disable=SC2031 # the Paperland subshells set SITE too, to the same value usage() { cat <