#!/usr/bin/env bash set -euo pipefail umask 022 version=0.1.1 base=https://outerloop.sh/download/linux usage() { echo 'Usage: bash install.sh [--check | --from-directory DIRECTORY]'; } mode=install local_directory= case "${1:-}" in '') ;; --check) mode=check ;; --from-directory) local_directory=$(realpath "${2:?Missing download directory}") ;; --help|-h) usage; exit 0 ;; *) usage >&2; exit 1 ;; esac [[ $(id -u) != 0 ]] || { echo 'Run this installer as your desktop user, without sudo.' >&2; exit 1; } [[ $(uname -s) == Linux ]] || { echo 'This installer requires Linux.' >&2; exit 1; } source /etc/os-release case "$(uname -m)" in aarch64|arm64) architecture=arm64 ;; x86_64|amd64) architecture=x86_64 ;; *) echo 'Outer Loop requires an x86-64 or ARM64 computer.' >&2; exit 1 ;; esac case "$ID:${VERSION_ID:-}:$architecture" in ubuntu:24.04:arm64|ubuntu:24.04:x86_64) target=ubuntu-24.04-$architecture dependencies='sudo apt-get update && sudo apt-get install libwebkitgtk-6.0-4 libqrencode4 libssl3t64 bubblewrap dbus-daemon xdg-dbus-proxy openssh-client curl python3 ca-certificates libatomic1 pulseaudio-utils gstreamer1.0-pulseaudio libltdl7 libwrap0 libspeexdsp1 libsoxr0 libtdb1 libwebrtc-audio-processing1 apparmor desktop-file-utils' ;; fedora:44:x86_64|fedora:44:arm64) target=fedora-44-$architecture dependencies='sudo dnf install webkitgtk6.0 qrencode-libs openssl-libs libdispatch bubblewrap dbus-daemon xdg-dbus-proxy openssh-clients curl python3 ca-certificates pulseaudio-utils gstreamer1-plugins-good libtool-ltdl fftw-libs-single webrtc-audio-processing desktop-file-utils' ;; omarchy:*:*|arch:*:*|archarm:*:*) target=omarchy-$architecture dependencies='sudo pacman -S --needed webkitgtk-6.0 qrencode openssl libdispatch bubblewrap dbus xdg-dbus-proxy openssh curl python ca-certificates libpulse gst-plugins-good libtool speexdsp libsoxr fftw tdb webrtc-audio-processing-1 desktop-file-utils' ;; *) printf 'No download is available for %s %s (%s). See https://outerloop.sh/linux/\n' "$ID" "${VERSION_ID:-}" "$architecture" >&2; exit 1 ;; esac printf 'Outer Loop %s ยท %s\n' "$version" "$target" printf '\nSystem dependencies (run once):\n%s\n\n' "$dependencies" [[ $mode != check ]] || exit 0 for command in curl python3 sha256sum tar ldd flock bwrap dbus-daemon xdg-dbus-proxy ssh-keygen pacat; do command -v "$command" >/dev/null || { echo "Missing $command. Install the dependencies above, then retry." >&2; exit 1; } done if [[ $ID == ubuntu ]] && [[ $(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null || true) == 1 ]] && [[ ! -f /etc/apparmor.d/outer-loop-release ]]; then printf 'Ubuntu needs the one-time sandbox setup first:\ncurl -fSLo setup-ubuntu.sh %s/%s/setup-ubuntu.sh\nsudo bash setup-ubuntu.sh\n' "$base" "$version" >&2 exit 1 fi workspace=$(mktemp -d) trap 'rm -rf "$workspace"' EXIT fetch() { if [[ -n $local_directory ]]; then cp "$local_directory/$1" "$2"; else curl --fail --show-error --location --proto '=https' --proto-redir '=https' "$base/$version/$1" -o "$2"; fi } fetch manifest.json "$workspace/manifest.json" read -r archive digest < <(python3 - "$workspace/manifest.json" "$version" "$target" <<'PY' import json,re,sys manifest=json.load(open(sys.argv[1])) assert manifest['version']==sys.argv[2], 'Release version mismatch' entry=manifest['packages'][sys.argv[3]] assert entry['file']=='outer-loop-'+sys.argv[2]+'-'+sys.argv[3]+'.tar.gz' assert re.fullmatch('[0-9a-f]{64}',entry['sha256']) print(entry['file'],entry['sha256']) PY ) fetch "$archive" "$workspace/$archive" printf '%s %s\n' "$digest" "$workspace/$archive" | sha256sum --check --status || { echo 'Download checksum mismatch. Nothing was installed.' >&2; exit 1; } python3 - "$workspace/$archive" "$workspace/unpacked" <<'PY' import pathlib,sys,tarfile root=pathlib.Path(sys.argv[2]); root.mkdir() with tarfile.open(sys.argv[1]) as archive: members=archive.getmembers() for member in members: path=pathlib.PurePosixPath(member.name) if path.is_absolute() or '..' in path.parts or not path.parts or path.parts[0]!='outer-loop': raise ValueError('Unsafe archive path') if not (member.isdir() or member.isfile() or member.issym()): raise ValueError('Unsupported archive entry') if member.issym(): destination=(root/path.parent/member.linkname).resolve() if not destination.is_relative_to(root.resolve()): raise ValueError('Unsafe archive link') archive.extractall(root, members=members, filter='data') PY payload="$workspace/unpacked/outer-loop" [[ $(cat "$payload/VERSION") == "$version" && $(cat "$payload/TARGET") == "$target" ]] || { echo 'Package metadata mismatch.' >&2; exit 1; } for executable in "$payload"/bin/*; do missing=$(ldd "$executable" | grep 'not found' || true) if [[ -n $missing ]]; then printf '%s\nInstall the dependencies above, then retry.\n' "$missing" >&2; exit 1; fi done LD_LIBRARY_PATH="$payload/libexec/pulse" "$payload/libexec/pulse/pulseaudio" --version prefix="$HOME/.local/opt/outer-loop" release="$version-$target-$digest" mkdir -p "$prefix/releases" "$HOME/.local/bin" "$HOME/.local/share/applications" "$HOME/.local/share/icons/hicolor/256x256/apps" exec 9>"$prefix/.install.lock" flock -n 9 || { echo 'Another Outer Loop installation is running.' >&2; exit 1; } if [[ -d "$prefix/releases/$release" ]]; then [[ -f "$prefix/releases/$release/.archive-sha256" && $(cat "$prefix/releases/$release/.archive-sha256") == "$digest" ]] || { echo 'Existing release differs from the download. Refusing to overwrite it.' >&2; exit 1; } else stage=$(mktemp -d "$prefix/releases/.install-XXXXXX") cp -a "$payload/." "$stage/" printf '%s\n' "$digest" > "$stage/.archive-sha256" mv "$stage" "$prefix/releases/$release" fi if [[ -L "$prefix/current" && $(readlink "$prefix/current") != "releases/$release" ]]; then ln -sfn "$(readlink "$prefix/current")" "$prefix/previous" fi ln -s "releases/$release" "$prefix/.current-$$" mv -Tf "$prefix/.current-$$" "$prefix/current" launcher=$(mktemp "$HOME/.local/bin/.outer-loop-XXXXXX") cat > "$launcher" <<'LAUNCHER' #!/bin/sh if [ -z "${OUTER_LOOP_SOFTWARE_RENDERING+x}" ] && [ "$(uname -m)" = aarch64 ] && grep -qi parallels /sys/class/dmi/id/sys_vendor 2>/dev/null; then export OUTER_LOOP_SOFTWARE_RENDERING=1 fi exec "$HOME/.local/opt/outer-loop/current/bin/outer-loop" "$@" LAUNCHER chmod 755 "$launcher" mv -Tf "$launcher" "$HOME/.local/bin/outer-loop" python3 - <<'PY' from pathlib import Path home=Path.home() launcher=str(home/'.local/bin/outer-loop') if '\n' in launcher or '\r' in launcher: raise ValueError('Unsupported home directory') quoted='"'+launcher.replace('\\','\\\\\\\\').replace('"','\\\\"').replace('`','\\\\`').replace('$','\\\\$').replace('%','%%')+'"' (home/'.local/share/applications/dev.outergroup.OuterLoop.Linux.desktop').write_text('[Desktop Entry]\nType=Application\nName=Outer Loop\nComment=Browse Outer Shell and remote web apps through SSH\nExec='+quoted+'\nIcon=dev.outergroup.OuterLoop.Linux\nTerminal=false\nCategories=Network;WebBrowser;\nStartupNotify=true\n') PY install -m644 "$prefix/current/share/icons/hicolor/256x256/apps/dev.outergroup.OuterLoop.Linux.png" "$HOME/.local/share/icons/hicolor/256x256/apps/" if command -v update-desktop-database >/dev/null; then update-desktop-database "$HOME/.local/share/applications"; fi printf '\nInstalled Outer Loop %s. Open Outer Loop from your app launcher, or run:\n%s/.local/bin/outer-loop\n\nClose and reopen existing Outer Loop windows to use this release.\n' "$version" "$HOME"