#!/usr/bin/env bash # # Bwosir installer # Usage: curl -fsSL https://bwo.frgmt.xyz/sir | sh # # ============================================================================= # WHY THIS FILE STARTS THE WAY IT DOES (read this before editing anything above # `set -euo pipefail` below) # # The documented invocation pipes this file into `sh`, not `bash`. When a # script is piped into a shell like that, the `#!/usr/bin/env bash` shebang # above is NEVER consulted — shebangs only matter when the kernel execs a file # directly. Whatever binary `/bin/sh` happens to be on the machine reads and # interprets this text itself. # # On macOS, /bin/sh IS bash (compiled to behave more POSIX-y when its argv[0] # is "sh"), so $BASH_VERSION is still set and every bashism below ([[, arrays, # local, ${var:offset:len}, functions, etc.) works fine even though we were # invoked as `sh`. The guard below exists for the rarer case where /bin/sh is # a genuinely different, non-bash shell (dash/ash/etc — not the case on a # stock Mac, but we don't want to silently misbehave if someone runs this # under one). The guard itself is written in strict POSIX syntax only (plain # `[ ]`, no arrays, no `local`, no `[[`) so it is safe to execute under ANY # `/bin/sh`, bash or not, before we know which shell we're in. # # Two cases: # 1. The script was saved to disk and run as `sh install.sh` (or similar) # under a non-bash sh. Here $0 is a real, readable file path, so we can # just re-exec that same file under a real bash. # 2. The script was piped straight into a non-bash sh, e.g. # `curl ... | sh`. Here $0 is just the literal string the interpreter # was invoked with (typically "sh") — it is NOT a path to a file we can # re-exec, because there never was a file; the script body only ever # existed as the pipe's stdin. In that case we re-fetch the script over # the network and hand THAT straight to bash instead. # ============================================================================= BWOSIR_INSTALL_URL="https://bwo.frgmt.xyz/sir" if [ -z "${BASH_VERSION:-}" ]; then if [ -f "$0" ]; then exec bash "$0" "$@" else # No on-disk $0 to re-exec (we were fed via a pipe). Re-fetch and run # under bash directly. This only triggers on a genuinely non-bash sh, # which is not what ships on macOS. curl -fsSL "$BWOSIR_INSTALL_URL" | bash exit $? fi fi # From here on we are guaranteed to be running under bash, so it's safe to # turn on strict-mode and use bash-only features. set -euo pipefail # --- bash version note ------------------------------------------------------ # macOS ships bash 3.2 as /bin/bash (and that's what /usr/bin/env bash finds # unless the user has installed a newer bash via Homebrew). Everything in # this script intentionally avoids bash-4+-only syntax (no ${var,,}, no # associative arrays, no `mapfile`) so it runs correctly on a stock Mac. # ----------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Constants (the shared "release contract" — see dist/README.md) # --------------------------------------------------------------------------- REPO="frgmt0/bwosir" BUNDLE_ID="com.bwosir.app" APP_BASENAME="Bwosir.app" WORKER_LATEST_URL="https://bwo.frgmt.xyz/latest.json" GITHUB_API_LATEST="https://api.github.com/repos/${REPO}/releases/latest" UNINSTALL_ONE_LINER="curl -fsSL https://bwo.frgmt.xyz/uninstall | sh" TOTAL_STEPS=6 # --------------------------------------------------------------------------- # Colors / TTY detection # # We only emit ANSI escapes when stdout looks like an interactive terminal # AND terminfo reports color support. When this script is piped from # `curl | sh`, stdin is the pipe carrying the script's own source — but # stdout is normally still the user's terminal, so this check is on stdout, # not stdin. # --------------------------------------------------------------------------- NCOLORS=0 if [[ -t 1 ]]; then NCOLORS=$(tput colors 2>/dev/null || echo 0) fi if [[ "$NCOLORS" -ge 8 ]]; then BOLD=$(tput bold) DIM=$(tput dim) RESET=$(tput sgr0) RED=$(tput setaf 1) GREEN=$(tput setaf 2) YELLOW=$(tput setaf 3) BLUE=$(tput setaf 4) CYAN=$(tput setaf 6) else BOLD=""; DIM=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""; CYAN="" fi # --------------------------------------------------------------------------- # Logging helpers # --------------------------------------------------------------------------- step() { printf '\n%s[%s/%s]%s %s\n' "${BOLD}${BLUE}" "$1" "$TOTAL_STEPS" "${RESET}" "$2" } ok() { printf ' %s✓%s %s\n' "${GREEN}" "${RESET}" "$1" } warn() { printf ' %s!%s %s\n' "${YELLOW}" "${RESET}" "$1" >&2 } err() { printf '%s✗ %s%s\n' "${RED}${BOLD}" "$1" "${RESET}" >&2 } print_banner() { printf '%s' "${CYAN}${BOLD}" cat <<'EOF' ____ _ | __ ) __ _____ ___ (_)_ __ | _ \ \ \ /\ / / _ \/ __|| | '__| | |_) | \ V V / (_) \__ \| | | |____/ \_/\_/ \___/|___/|_|_| EOF printf '%s\n' "${RESET}" printf '%s🧭 Bwosir — the tiny WebKit browser for macOS%s\n\n' "${DIM}" "${RESET}" } print_help() { cat <&2 SPINNER_PID="" return 0 fi ( chars='/-\|' i=0 while :; do i=$(( (i + 1) % 4 )) printf '\r %s %s ' "$msg" "${chars:$i:1}" >&2 sleep 0.1 done ) & SPINNER_PID=$! } stop_spinner() { if [[ -n "$SPINNER_PID" ]]; then kill "$SPINNER_PID" 2>/dev/null || true wait "$SPINNER_PID" 2>/dev/null || true SPINNER_PID="" printf '\r%*s\r' 60 "" >&2 fi } # --------------------------------------------------------------------------- # Interactive confirm — always reads from /dev/tty explicitly. # # This script's own stdin is the pipe carrying its source code (`curl | sh`), # so a plain `read` here would try to read from that pipe (which is usually # already exhausted, and definitely isn't the user typing an answer). Every # prompt in this script reads from /dev/tty instead. # --------------------------------------------------------------------------- confirm() { local prompt="$1" default="$2" suffix reply if [[ "$default" == "y" ]]; then suffix="[Y/n]"; else suffix="[y/N]"; fi printf ' %s %s %s ' "${YELLOW}?${RESET}" "$prompt" "$suffix" >&2 if ! read -r reply 2>/dev/null /dev/null); then err "Could not determine your macOS version — this installer only supports macOS." exit 1 fi os_major="${os_version%%.*}" if ! [[ "$os_major" =~ ^[0-9]+$ ]]; then err "Could not parse your macOS version (got \"$os_version\")." exit 1 fi if (( os_major < 26 )); then err "Bwosir requires macOS 26 (Tahoe) or later — you're running macOS ${os_version}." printf ' Please update macOS and try again: %sSystem Settings > General > Software Update%s\n' "${DIM}" "${RESET}" >&2 exit 1 fi ok "macOS ${os_version} — supported" arch=$(uname -m) case "$arch" in arm64) arch_label="Apple Silicon (arm64)" ;; x86_64) arch_label="Intel (x86_64)" ;; *) err "Unsupported architecture: $arch" exit 1 ;; esac ok "Architecture: ${arch_label}" if [[ -w "/Applications" ]]; then INSTALL_PARENT="/Applications" else INSTALL_PARENT="$HOME/Applications" warn "/Applications isn't writable — will install to ${INSTALL_PARENT} instead" mkdir -p "$INSTALL_PARENT" fi TARGET_APP="${INSTALL_PARENT}/${APP_BASENAME}" ok "Install location: ${TARGET_APP}" if [[ -e "$TARGET_APP" ]]; then warn "${TARGET_APP} already exists." if ! confirm "Replace the existing installation?" n; then printf '\nInstallation cancelled. Your existing Bwosir installation was left untouched.\n' exit 0 fi fi } # --------------------------------------------------------------------------- # Step 2: fetch latest release info # --------------------------------------------------------------------------- RELEASE_VERSION="" RELEASE_ZIP_URL="" RELEASE_SHA256="" fetch_from_worker() { local json if ! json=$(curl -fsSL --max-time 10 "$WORKER_LATEST_URL" 2>/dev/null); then return 1 fi RELEASE_VERSION=$(extract_json_field "version" "$json") RELEASE_ZIP_URL=$(extract_json_field "url" "$json") RELEASE_SHA256=$(extract_json_field "sha256" "$json") if [[ -z "$RELEASE_VERSION" || -z "$RELEASE_ZIP_URL" || -z "$RELEASE_SHA256" ]]; then return 1 fi return 0 } fetch_from_github_api() { local api_json checksums_file zip_name if ! api_json=$(curl -fsSL --max-time 15 -H "User-Agent: bwosir-installer" "$GITHUB_API_LATEST" 2>/dev/null); then err "Could not reach GitHub to look up the latest Bwosir release." printf ' Check your internet connection and try again.\n' >&2 exit 1 fi RELEASE_ZIP_URL=$(printf '%s' "$api_json" \ | grep -o "\"browser_download_url\"[[:space:]]*:[[:space:]]*\"[^\"]*Bwosir-[^\"]*\\.zip\"" \ | head -n1 \ | sed -E 's/.*"(https:[^"]*)"$/\1/') if [[ -z "$RELEASE_ZIP_URL" ]]; then err "Could not find a Bwosir-*.zip asset in the latest GitHub release." exit 1 fi zip_name="${RELEASE_ZIP_URL##*/}" RELEASE_VERSION=$(printf '%s' "$zip_name" | sed -E 's/^Bwosir-(.*)\.zip$/\1/') local checksums_url checksums_url=$(printf '%s' "$api_json" \ | grep -o "\"browser_download_url\"[[:space:]]*:[[:space:]]*\"[^\"]*checksums\\.txt\"" \ | head -n1 \ | sed -E 's/.*"(https:[^"]*)"$/\1/') if [[ -n "$checksums_url" ]]; then checksums_file="${TMP_DIR}/checksums.txt" if curl -fsSL --max-time 15 -H "User-Agent: bwosir-installer" -o "$checksums_file" "$checksums_url" 2>/dev/null; then RELEASE_SHA256=$(grep -F "$zip_name" "$checksums_file" | awk '{print $1}' | head -n1) fi fi if [[ -z "$RELEASE_SHA256" ]]; then err "Could not determine the published checksum for ${zip_name}. Aborting for your safety." exit 1 fi } fetch_release_info() { step 2 "Fetching latest release..." if fetch_from_worker; then ok "Latest version: ${RELEASE_VERSION} (via bwo.frgmt.xyz)" else warn "Couldn't reach bwo.frgmt.xyz — falling back to the GitHub API" fetch_from_github_api ok "Latest version: ${RELEASE_VERSION} (via GitHub API)" fi } # --------------------------------------------------------------------------- # Step 3: download # --------------------------------------------------------------------------- ZIP_PATH="" download_release() { step 3 "Downloading Bwosir ${RELEASE_VERSION}..." ZIP_PATH="${TMP_DIR}/Bwosir-${RELEASE_VERSION}.zip" start_spinner "Downloading Bwosir-${RELEASE_VERSION}.zip" if ! curl -fsSL --max-time 300 -o "$ZIP_PATH" "$RELEASE_ZIP_URL"; then stop_spinner err "Download failed. Check your internet connection and try again." exit 1 fi stop_spinner if [[ ! -s "$ZIP_PATH" ]]; then err "Downloaded file is empty — something went wrong on the server side." exit 1 fi ok "Downloaded $(du -h "$ZIP_PATH" | awk '{print $1}')" } # --------------------------------------------------------------------------- # Step 4: verify checksum # --------------------------------------------------------------------------- verify_checksum() { step 4 "Verifying checksum..." local actual actual_lc expected_lc actual=$(shasum -a 256 "$ZIP_PATH" | awk '{print $1}') actual_lc=$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]') expected_lc=$(printf '%s' "$RELEASE_SHA256" | tr '[:upper:]' '[:lower:]') if [[ "$actual_lc" != "$expected_lc" ]]; then err "Checksum verification failed — the downloaded file does not match what Bwosir published." err "This could mean a corrupted download or a tampered file. Aborting for your safety." printf ' expected: %s\n got: %s\n' "$expected_lc" "$actual_lc" >&2 exit 1 fi ok "sha256 verified (${actual_lc:0:12}...)" } # --------------------------------------------------------------------------- # Step 5: install # --------------------------------------------------------------------------- install_app() { step 5 "Installing Bwosir..." local extract_dir src_app extract_dir="${TMP_DIR}/extracted" mkdir -p "$extract_dir" # These zips are produced by `ditto -c -k` in the release pipeline, so we # extract with `ditto` too — it round-trips extended attributes and code # signatures more faithfully than `unzip`. if ! ditto -x -k "$ZIP_PATH" "$extract_dir" 2>/dev/null; then err "Could not unpack ${ZIP_PATH##*/}. The download may be corrupted." exit 1 fi src_app="${extract_dir}/${APP_BASENAME}" if [[ ! -d "$src_app" ]]; then err "${APP_BASENAME} was not found inside the downloaded archive." exit 1 fi ok "Unpacked ${APP_BASENAME}" if [[ -e "$TARGET_APP" ]]; then rm -rf "$TARGET_APP" fi if ! ditto "$src_app" "$TARGET_APP"; then err "Failed to copy ${APP_BASENAME} into ${INSTALL_PARENT}." exit 1 fi ok "Installed to ${TARGET_APP}" } # --------------------------------------------------------------------------- # Step 6: sign, clear quarantine, launch # --------------------------------------------------------------------------- finalize() { step 6 "Finalizing..." # --- Signing ----------------------------------------------------------- # Bwosir.app ships already ad-hoc signed by the release pipeline (a # ditto-zipped, ad-hoc-signed app, per the release contract). The # `codesign --verify` below is just a sanity check. If it fails, we # re-apply an ad-hoc signature (`--sign -`) as a DEFENSIVE FALLBACK for a # signature that got corrupted or stripped in transit (e.g. by some # intermediate tool re-packing the zip) — it is not the primary signing # path, and ad-hoc signing with `--deep` here does not grant any # Gatekeeper trust or Developer ID identity; it only ensures the bundle # has *a* valid signature so macOS doesn't refuse to run it as "damaged". if codesign --verify --deep --strict "$TARGET_APP" >/dev/null 2>&1; then ok "Code signature OK" else warn "Signature missing or invalid — re-applying an ad-hoc signature (defensive fallback, see comment in this script)" if codesign --force --deep --sign - "$TARGET_APP" >/dev/null 2>&1; then ok "Ad-hoc signature applied" else warn "Ad-hoc re-signing failed — Bwosir may refuse to launch. You can retry with a fresh install." fi fi # --- Quarantine ---------------------------------------------------------- # HONESTY NOTE — this is NOT a Gatekeeper bypass: # Files downloaded by `curl` never receive the com.apple.quarantine # extended attribute in the first place. Quarantine-flagging is opt-in # behavior (LSFileQuarantineEnabled) used by apps like Safari, Mail, and # Chrome — curl does not set it. So this script's own download has # nothing to "bypass": there was never a Gatekeeper prompt to defeat. # # The line below is a defensive no-op for a DIFFERENT scenario: if the zip # was originally obtained some other way (e.g. downloaded in a browser) # and still carries the flag, this clears it. It does not bypass anything # this script's own download path would have triggered, and the app must # still carry a valid signature (handled above) to actually launch. xattr -dr com.apple.quarantine "$TARGET_APP" 2>/dev/null || true # --- Launch -------------------------------------------------------------- if open "$TARGET_APP" 2>/dev/null; then ok "Launched Bwosir" else warn "Installed, but couldn't launch it automatically. Open it from ${INSTALL_PARENT}." fi } print_success() { printf '\n%s✓ Bwosir %s is installed!%s\n' "${GREEN}${BOLD}" "$RELEASE_VERSION" "${RESET}" printf '\n App: %s\n' "$TARGET_APP" printf ' Bundle: %s\n' "$BUNDLE_ID" printf '\n To uninstall later:\n %s%s%s\n\n' "${DIM}" "$UNINSTALL_ONE_LINER" "${RESET}" } # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- main() { for arg in "$@"; do case "$arg" in -h|--help) print_help exit 0 ;; esac done print_banner TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/bwosir-install.XXXXXX") detect_system fetch_release_info download_release verify_checksum install_app finalize print_success } main "$@"