#!/usr/bin/env bash # unoblox works beta installer. Read this at https://unoblox.ai/install.sh # Re-run to update. User data is never removed. # Overrides: UNOBLOX_WORKS_MANIFEST, UNOBLOX_WORKS_VERSION, # UNOBLOX_WORKS_NO_LAUNCH=1, UNOBLOX_WORKS_APPIMAGE=1 (Linux). unoblox_works_install() ( set -euo pipefail fail() { printf '❌ unoblox works: %s\n' "$*" >&2; exit 1; } say() { printf 'unoblox works: %s\n' "$*" >&2; } heartbeat='' stop_heartbeat() { if [ -n "$heartbeat" ]; then kill "$heartbeat" 2>/dev/null || true wait "$heartbeat" 2>/dev/null || true heartbeat='' fi } # Keep the operation in the foreground so password prompts still work. status_loop() { trap - EXIT INT TERM sleeper='' trap '[ -z "$sleeper" ] || kill "$sleeper" 2>/dev/null || true' EXIT trap 'exit 0' INT TERM label="$1"; started="$SECONDS" : > "$2" while true; do sleep 5 & sleeper=$! wait "$sleeper" || exit 0 sleeper='' say "⏳ $label — still working ($((SECONDS - started))s elapsed)." done } step() { local label="$1" started="$SECONDS" result=0 shift say "🔄 ${label}…" rm -f "$work/status-ready" status_loop "$label" "$work/status-ready" > /dev/null & heartbeat=$! while [ ! -e "$work/status-ready" ]; do sleep 0.05; done "$@" || result=$? stop_heartbeat if [ "$result" != 0 ]; then say "❌ $label failed (exit $result)." return "$result" fi say "✅ $label complete ($((SECONDS - started))s)." } command -v curl >/dev/null || fail 'curl is required.' manifest="${UNOBLOX_WORKS_MANIFEST:-https://unoblox.ai/works/latest.json}" if [ -n "${UNOBLOX_WORKS_VERSION:-}" ]; then [[ "$UNOBLOX_WORKS_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail 'Invalid pinned version.' [ -z "${UNOBLOX_WORKS_MANIFEST:-}" ] || fail 'Use either a version or a manifest override, not both.' manifest="https://unoblox.ai/works/releases/$UNOBLOX_WORKS_VERSION/latest.json" fi [[ "$manifest" == https://* ]] || fail 'The manifest URL must use HTTPS.' system="$(uname -s)" case "$system/$(uname -m)" in Darwin/arm64) [ "$(sw_vers -productVersion | cut -d. -f1)" -ge 12 ] || fail 'macOS 12 or later is required.' key=macos-arm64 ;; Darwin/*) fail 'The beta supports Apple Silicon Macs (M1 or later) only.' ;; Linux/x86_64) command -v python3 >/dev/null || fail 'Python 3 is required to read the release manifest.' key=linux-x64-appimage if [ "${UNOBLOX_WORKS_APPIMAGE:-0}" != 1 ] && command -v apt-get >/dev/null; then if [ "$(id -u)" = 0 ]; then key=linux-x64-deb elif command -v sudo >/dev/null; then say 'The .deb install needs your sudo password. If you cannot use sudo, the AppImage will be used.' if sudo -v; then key=linux-x64-deb; fi fi fi ;; *) fail 'Supported systems: Apple Silicon macOS and Linux x64. On Windows use install.ps1.' ;; esac work="$(mktemp -d)" mount='' staged='' target='' # shellcheck disable=SC2329 # Invoked by the EXIT trap. cleanup() { stop_heartbeat if [ -n "$mount" ]; then hdiutil detach "$mount" -quiet 2>/dev/null || true; fi if [ -n "$staged" ]; then if [ -d "$staged/previous.app" ] && [ ! -e "$target" ]; then if ! mv "$staged/previous.app" "$target"; then say "Your previous app is saved in $staged/previous.app. Restore it before trying again." staged='' fi fi [ -z "$staged" ] || rm -rf "$staged" fi rm -rf "$work" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM say '📋 Reading the release manifest…' curl --proto '=https' --proto-redir '=https' -fsSL --retry 3 --connect-timeout 20 --max-time 60 "$manifest" -o "$work/latest.json" || fail 'Could not download the release manifest.' # JSON is parsed with native macOS JavaScript or Python on Linux, never eval. if [ "$system" = Darwin ]; then osascript -l JavaScript - "$work/latest.json" "$key" > "$work/asset" <<'JS' || fail 'The release manifest is invalid.' ObjC.import('Foundation'); function run(args) { const data = $.NSString.stringWithContentsOfFileEncodingError(args[0], $.NSUTF8StringEncoding, null).js; const r = JSON.parse(data), f = r.files[args[1]]; if (r.name !== 'unoblox works' || r.channel !== 'beta' || !/^\d+\.\d+\.\d+$/.test(r.version) || !f || !/^https:\/\/[^\s@]+$/.test(f.url) || !/^[a-f\d]{64}$/i.test(f.sha256) || !Number.isSafeInteger(f.size) || f.size <= 0) throw Error('Invalid manifest'); return [r.version, f.url, f.sha256, f.size].join('\n'); } JS else python3 - "$work/latest.json" "$key" > "$work/asset" <<'PY' || fail 'The release manifest is invalid.' import json, re, sys from urllib.parse import urlsplit r = json.load(open(sys.argv[1], encoding='utf-8')) f = r['files'][sys.argv[2]] u = urlsplit(f['url']) assert r['name'] == 'unoblox works' and r['channel'] == 'beta' and re.fullmatch(r'\d+\.\d+\.\d+', r['version']) assert u.scheme == 'https' and u.hostname and not u.username and not u.password and not any(c.isspace() for c in f['url']) assert re.fullmatch(r'[a-fA-F0-9]{64}', f['sha256']) and type(f['size']) is int and f['size'] > 0 print(r['version'], f['url'], f['sha256'], f['size'], sep='\n') PY fi { IFS= read -r version; IFS= read -r url; IFS= read -r expected; IFS= read -r expected_size; } < "$work/asset" say "📥 Downloading version ${version} ($((expected_size / 1024 / 1024)) MB)…" file="$work/installer" curl --proto '=https' --proto-redir '=https' -fL --retry 3 --connect-timeout 20 "$url" -o "$file" || fail 'Installer download failed.' [ "$(wc -c < "$file" | tr -d ' ')" = "$expected_size" ] || fail 'The installer size is wrong. Nothing was installed.' say '🔐 Checking installer size and SHA-256 checksum…' if [ "$system" = Darwin ]; then actual="$(step 'Calculating SHA-256 checksum' shasum -a 256 "$file" | cut -d' ' -f1)" else actual="$(step 'Calculating SHA-256 checksum' sha256sum "$file" | cut -d' ' -f1)"; fi [ "$(printf '%s' "$actual" | tr 'A-F' 'a-f')" = "$(printf '%s' "$expected" | tr 'A-F' 'a-f')" ] || fail 'SHA-256 checksum mismatch. Nothing was installed.' say '✅ Checksum verified. Starting installation…' if [ "$system" = Linux ] && command -v pgrep >/dev/null && pgrep -u "$(id -u)" -x 'unoblox works' >/dev/null; then say '⏳ Closing unoblox works (waiting up to 30 seconds)…' pkill -TERM -u "$(id -u)" -x 'unoblox works' || fail 'Quit unoblox works, then try again.' for _ in {1..30}; do pgrep -u "$(id -u)" -x 'unoblox works' >/dev/null || break; sleep 1; done if pgrep -u "$(id -u)" -x 'unoblox works' >/dev/null; then fail 'The app is still running. Quit it, then try again.'; fi fi if [ "$system" = Darwin ]; then mount="$work/mnt"; mkdir "$mount" step 'Mounting the disk image' hdiutil attach "$file" -nobrowse -readonly -mountpoint "$mount" -quiet || fail 'Could not mount the disk image.' source_app="$mount/unoblox works.app" [ -d "$source_app" ] || fail 'The disk image has no unoblox works.app.' step 'Checking the app signature' codesign --verify --deep --strict "$source_app" || fail 'The app signature is damaged. Nothing was installed.' if pgrep -x 'unoblox works' >/dev/null; then say '⏳ Closing unoblox works (waiting up to 30 seconds)…' osascript -e 'quit app "unoblox works"' || fail 'Quit unoblox works, then run the installer again.' for _ in {1..30}; do pgrep -x 'unoblox works' >/dev/null || break; sleep 1; done if pgrep -x 'unoblox works' >/dev/null; then fail 'The app is still running. Quit it, then try again.'; fi fi applications=/Applications if [ ! -w "$applications" ]; then applications="$HOME/Applications"; mkdir -p "$applications" say "Installing in $applications because /Applications is not writable." fi target="$applications/unoblox works.app" [ ! -e "$target" ] || [ -w "$target" ] || fail 'The existing app is not writable by your user.' staged="$(mktemp -d "$applications/.unoblox-works.XXXXXX")" step 'Copying the app to Applications' ditto "$source_app" "$staged/unoblox works.app" || fail 'Could not copy the app.' step 'Preparing the downloaded app' xattr -dr com.apple.quarantine "$staged/unoblox works.app" || fail 'Could not clear the download quarantine attribute.' step 'Verifying the copied app signature' codesign --verify --deep --strict "$staged/unoblox works.app" || fail 'The copied app signature is damaged.' say '📦 Saving the app in Applications…' if [ -e "$target" ]; then mv "$target" "$staged/previous.app"; fi if ! mv "$staged/unoblox works.app" "$target"; then [ ! -e "$staged/previous.app" ] || mv "$staged/previous.app" "$target" fail 'Could not replace the app. Your previous installation was restored.' fi step 'Unmounting the disk image' hdiutil detach "$mount" -quiet; mount='' say "✅ Installed $target. Your settings and conversations were kept." if [ -d /Applications/Unoblox.app ]; then say 'The earlier Unoblox.app can be deleted separately; your data is kept.'; fi if [ "${UNOBLOX_WORKS_NO_LAUNCH:-0}" != 1 ]; then step 'Opening unoblox works 🚀' open "$target" || fail 'Installed successfully, but could not open the app. Open it from Applications.' fi elif [ "$key" = linux-x64-deb ]; then mv "$file" "$work/unoblox-works.deb"; file="$work/unoblox-works.deb" chmod 755 "$work"; chmod 644 "$file" if [ "$(id -u)" = 0 ]; then step 'Installing the .deb and system dependencies' apt-get install -y "$file" else step 'Installing the .deb and system dependencies' sudo apt-get install -y "$file"; fi command -v unoblox-works >/dev/null || fail 'The package did not install the unoblox-works command.' say '✅ Installed. Open unoblox works from the applications menu.' if [ "${UNOBLOX_WORKS_NO_LAUNCH:-0}" != 1 ] && [ "$(id -u)" != 0 ]; then nohup unoblox-works >/dev/null 2>&1 < /dev/null & fi else [ "$(id -u)" != 0 ] || fail 'Run the AppImage install as your normal desktop user.' destination="$HOME/.local/opt/unoblox-works" mkdir -p "$destination" "$HOME/.local/bin" "$HOME/.local/share/applications" step 'Installing the AppImage' install -m 755 "$file" "$destination/unoblox-works.AppImage.new" mv "$destination/unoblox-works.AppImage.new" "$destination/unoblox-works.AppImage" ln -sfn "$destination/unoblox-works.AppImage" "$HOME/.local/bin/unoblox-works" # Escape a desktop-entry path, including homes with spaces or quotes. # shellcheck disable=SC2016 # This is Python code; dollar signs are escaped literally. desktop_exec="$(python3 -c 'import sys; p=sys.argv[1]; print("\""+p.replace("\\", "\\\\").replace("\"", "\\\"").replace("`", "\\`").replace("$", "\\$").replace("%", "%%")+"\"")' "$destination/unoblox-works.AppImage")" cat > "$HOME/.local/share/applications/unoblox-works.desktop" </dev/null || /sbin/ldconfig -p 2>/dev/null || true; } | grep -q 'libfuse.so.2'; then can_launch=0 say '⚠️ FUSE 2 is required: libfuse2t64 on Ubuntu 24.04+, libfuse2 on Ubuntu 22.04, or fuse-libs on Fedora.' fi if [ -r /etc/os-release ]; then # Read distro identifiers without sourcing the file. distro="$(sed -n 's/^ID=//p' /etc/os-release | tr -d '\"')" distro_version="$(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '\"')" if [ "$distro" = ubuntu ] && [ "${distro_version%%.*}" -ge 24 ]; then can_launch=0 say '⚠️ On Ubuntu 24.04+, use the .deb installer for Chromium sandbox support. No sandbox protections were disabled.' fi fi say "✅ Installed $destination/unoblox-works.AppImage. Your settings and conversations were kept." if [ "${UNOBLOX_WORKS_NO_LAUNCH:-0}" != 1 ] && [ "$can_launch" = 1 ]; then nohup "$destination/unoblox-works.AppImage" >/dev/null 2>&1 < /dev/null & fi fi say '🎉 Installation finished.' ) unoblox_works_install