#!/usr/bin/env bash # # Fluxer macOS installer — download + verify + instance URL + launcher app. # # Usage: # EDITION=fluxer-canary INSTANCE=https://fluxer.example.com ./install-fluxer-macos.sh # # Env: # EDITION fluxer | fluxer-canary (default: fluxer-canary) # maps to the release channel: fluxer -> stable, fluxer-canary -> canary # INSTANCE your instance base URL (prompted if unset) # LAUNCHER_ONLY=1 skip download/install, only (re)write the launcher app # (requires the .app to already be installed) # ASSUME_YES=1 never prompt # ARCH force arch: arm64 | x64 (default: detected from `uname -m`) # INSTALL_DIR where to put the .app (default: /Applications, falls back # to ~/Applications if /Applications isn't writable) # ALLOW_HTTP=1 allow a plain http:// instance URL (loopback is always allowed) # VERIFY=1 run the launch-based flag check even if the static probe # of the app bundle can't confirm the flag exists # EXTRA_FLAGS extra flags, space-separated, restricted charset. # --enable-features= / --disable-features= are MERGED into # ours (Chromium keeps only the last occurrence of each). # # Unlike the Linux/Chromium build, macOS Chromium decodes (and usually encodes) # H.264/HEVC through VideoToolbox automatically — there's no VA-API-style flag # to pass, so this script carries no GPU/session detection. # # NOTE: --fluxer-app-url is not documented publicly (checked Sep 2026). The # script probes the installed app bundle for it and warns if it isn't found. # Only point the desktop app at an instance you control. # set -euo pipefail EDITION="${EDITION:-fluxer-canary}" INSTANCE="${INSTANCE:-https://fluxer.systux.xyz}" ORIGIN='https://pkgs.fluxer.com' TMPFILES=() MOUNTPOINTS=() cleanup() { local mp for mp in "${MOUNTPOINTS[@]:-}"; do [[ -n "$mp" ]] && hdiutil detach "$mp" -quiet 2>/dev/null || true done rm -f "${TMPFILES[@]:-}" } trap cleanup EXIT info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } ok() { printf '\033[1;32m ✓\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; } die() { printf '\033[1;31m ✗\033[0m %s\n' "$*" >&2; exit 1; } confirm() { # confirm "question" -> default yes; non-interactive => yes only with ASSUME_YES [[ "${ASSUME_YES:-0}" == 1 ]] && return 0 [[ -t 0 ]] || return 1 local ans; read -rp "$1 [Y/n] " ans [[ ! "$ans" =~ ^[Nn] ]] } join_by() { local IFS="$1"; shift; printf '%s' "$*"; } # run_with_timeout SECONDS CMD... : macOS ships no `timeout` binary by default. run_with_timeout() { local secs="$1"; shift local out; out="$(mktemp)"; TMPFILES+=("$out") ("$@" >"$out" 2>&1) & local pid=$! ( sleep "$secs"; kill "$pid" 2>/dev/null ) & local watcher=$! wait "$pid" 2>/dev/null || true kill "$watcher" 2>/dev/null || true cat "$out" } [[ "$(uname -s)" == Darwin ]] || die "this script is for macOS — use install-fluxer-arch.sh on Linux" # ------------------------------------------------------------ preflight [[ "$EDITION" =~ ^fluxer(-canary)?$ ]] \ || die "EDITION must be 'fluxer' or 'fluxer-canary' (got '$EDITION')" CHANNEL=stable [[ "$EDITION" == fluxer-canary ]] && CHANNEL=canary if [[ -z "$INSTANCE" ]]; then [[ -t 0 ]] || die "INSTANCE not set and no terminal to prompt on" read -rp "Instance URL (e.g. https://fluxer.example.com): " INSTANCE fi INSTANCE="${INSTANCE%/}" # Restricted charset: keeps the launcher's shell-quoted Exec-equivalent free # of characters needing extra escaping. No userinfo (@), no query/fragment, # no IPv6 literals. URL_RE='^(https?)://([A-Za-z0-9._-]+)(:[0-9]+)?(/[A-Za-z0-9._/-]*)?$' [[ "$INSTANCE" =~ $URL_RE ]] || die "invalid instance URL: '$INSTANCE'" URL_SCHEME="${BASH_REMATCH[1]}" URL_HOST="${BASH_REMATCH[2]}" if [[ "$URL_SCHEME" == http && "${ALLOW_HTTP:-0}" != 1 ]]; then [[ "$URL_HOST" =~ ^(localhost|127\.[0-9]+\.[0-9]+\.[0-9]+)$ ]] \ || die "plain http:// is only accepted for loopback; use https:// or set ALLOW_HTTP=1" fi info "Checking $INSTANCE …" if curl -fsS -o /dev/null --max-time 10 "$INSTANCE/"; then ok "$INSTANCE reachable" else warn "$INSTANCE did not answer — continuing anyway (server may be down)" fi APP_NAME="Fluxer"; APP_COMMENT="Fluxer desktop" [[ "$EDITION" == fluxer-canary ]] && { APP_NAME="Fluxer Canary"; APP_COMMENT="Canary build of Fluxer"; } INSTALL_DIR="${INSTALL_DIR:-/Applications}" APP_PATH="" # ---------------------------------------------------------------- install if [[ "${LAUNCHER_ONLY:-0}" != 1 ]]; then if [[ -n "${ARCH:-}" ]]; then [[ "$ARCH" =~ ^(arm64|x64)$ ]] || die "ARCH must be arm64 | x64 (got '$ARCH')" else case "$(uname -m)" in arm64) ARCH=arm64 ;; x86_64) ARCH=x64 ;; *) die "unrecognized architecture: $(uname -m) — set ARCH=arm64|x64" ;; esac fi info "Channel: $CHANNEL Arch: $ARCH" DMG_URL="$ORIGIN/desktop/$CHANNEL/darwin/$ARCH/latest/dmg" SHA_URL="$DMG_URL.sha256" # Best-effort version string for the log line; never fatal. ver_json="$(curl -fsS --max-time 10 "$ORIGIN/desktop/$CHANNEL/darwin/$ARCH/latest" 2>/dev/null || true)" ver="$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' <<<"$ver_json" | head -1 | sed -E 's/.*:[[:space:]]*"([^"]+)"/\1/' || true)" [[ -n "$ver" ]] && info "Latest $CHANNEL version: $ver" || true # Braces are required here on macOS's Bash 3.2: in a UTF-8 locale it can # treat the adjacent ellipsis as part of the parameter name (ARCH…), and # `set -u` then reports it as an unbound variable. info "Downloading $EDITION for darwin/${ARCH}…" dmg_file="$(mktemp -t fluxer).dmg"; TMPFILES+=("$dmg_file") curl -fSL --max-time 300 -o "$dmg_file" "$DMG_URL" || die "download failed: $DMG_URL" ok "downloaded $(du -h "$dmg_file" | cut -f1)" info "Verifying checksum…" sha_expected="$(curl -fsS --max-time 15 "$SHA_URL" | grep -oE '[0-9a-fA-F]{64}' | head -1 || true)" if [[ -z "$sha_expected" ]]; then warn "couldn't fetch/parse $SHA_URL — skipping checksum verification" else sha_actual="$(shasum -a 256 "$dmg_file" | awk '{print $1}')" # macOS ships Bash 3.2, which does not support Bash 4's ${var,,} # lowercase expansion. The checksum parser accepts uppercase hex, so # normalize both sides with POSIX tr before comparing them. sha_actual_lc="$(printf '%s' "$sha_actual" | tr '[:upper:]' '[:lower:]')" sha_expected_lc="$(printf '%s' "$sha_expected" | tr '[:upper:]' '[:lower:]')" [[ "$sha_actual_lc" == "$sha_expected_lc" ]] \ || die "checksum mismatch: expected $sha_expected, got $sha_actual" ok "checksum verified" fi info "Mounting disk image…" attach_out="$(hdiutil attach -nobrowse -readonly "$dmg_file")" || die "could not attach disk image" mount_point="$(grep '/Volumes/' <<<"$attach_out" | awk -F'\t' '{print $NF}' | tail -1)" [[ -n "$mount_point" && -d "$mount_point" ]] || die "could not determine mount point from hdiutil output" MOUNTPOINTS+=("$mount_point") src_app="$(find "$mount_point" -maxdepth 1 -iname '*.app' -print -quit)" [[ -n "$src_app" ]] || die "no .app bundle found inside the disk image" ok "found $(basename "$src_app")" if [[ ! -w "$INSTALL_DIR" ]]; then warn "$INSTALL_DIR is not writable" if confirm "Use sudo to install into $INSTALL_DIR instead of ~/Applications?"; then SUDO=sudo else INSTALL_DIR="$HOME/Applications" mkdir -p "$INSTALL_DIR" SUDO="" fi else SUDO="" fi dest_app="$INSTALL_DIR/$(basename "$src_app")" if [[ -e "$dest_app" ]]; then if confirm "$dest_app already exists — overwrite?"; then ${SUDO:-} rm -rf "$dest_app" else die "not overwriting $dest_app" fi fi info "Installing to ${dest_app}…" # ditto (not cp -R) preserves resource forks, xattrs (incl. the quarantine # flag — we deliberately do NOT strip it, so Gatekeeper still checks it on # first launch) and code signature layout. ${SUDO:-} ditto "$src_app" "$dest_app" || die "copy failed" ok "installed" hdiutil detach "$mount_point" -quiet 2>/dev/null || true MOUNTPOINTS=("${MOUNTPOINTS[@]/$mount_point/}") APP_PATH="$dest_app" else for d in "/Applications" "$HOME/Applications"; do for cand in "$d/Fluxer.app" "$d/Fluxer Canary.app"; do if [[ -e "$cand" ]]; then base="$(basename "$cand" .app)" if [[ "$EDITION" == fluxer-canary && "$base" == *Canary* ]] || \ [[ "$EDITION" == fluxer && "$base" != *Canary* ]]; then APP_PATH="$cand"; break 2 fi fi done done [[ -n "$APP_PATH" ]] || die "LAUNCHER_ONLY=1 but no installed $APP_NAME.app found under /Applications or ~/Applications" fi info "App bundle: $APP_PATH" # --------------------------------------------------------- Gatekeeper check if command -v spctl >/dev/null; then if spctl -a -t exec -vv "$APP_PATH" &>/dev/null; then ok "Gatekeeper accepts the app (signed/notarized)" else warn "Gatekeeper assessment failed — macOS may refuse to open this app without extra confirmation" fi fi # ------------------------------------------------------------ resolve binary plist="$APP_PATH/Contents/Info.plist" exe_name="$(defaults read "${plist%.plist}" CFBundleExecutable 2>/dev/null || true)" [[ -n "$exe_name" ]] || die "couldn't read CFBundleExecutable from $plist" BIN="$APP_PATH/Contents/MacOS/$exe_name" [[ -x "$BIN" ]] || die "expected executable not found: $BIN" info "Binary: $BIN" # ------------------------------------------------------------- build flags FLAGS=("--fluxer-app-url=${INSTANCE}") ENABLE=() DISABLE=() if [[ -n "${EXTRA_FLAGS:-}" ]]; then read -ra extra <<<"$EXTRA_FLAGS" for tok in "${extra[@]}"; do [[ "$tok" =~ ^[A-Za-z0-9._:/=,+-]+$ ]] \ || die "EXTRA_FLAGS token has characters that can't be safely shell-quoted in the launcher: '$tok'" case "$tok" in --enable-features=*) IFS=, read -ra t <<<"${tok#*=}"; ENABLE+=("${t[@]}") ;; --disable-features=*) IFS=, read -ra t <<<"${tok#*=}"; DISABLE+=("${t[@]}") ;; *) FLAGS+=("$tok") ;; esac done fi if (( ${#ENABLE[@]} )); then FLAGS+=("--enable-features=$(join_by , "${ENABLE[@]}")"); fi if (( ${#DISABLE[@]} )); then FLAGS+=("--disable-features=$(join_by , "${DISABLE[@]}")"); fi LAUNCH_DISPLAY="$BIN ${FLAGS[*]}" info "Launch command: $LAUNCH_DISPLAY" # ------------------------------------------------- write launcher app # Finder double-clicks can't carry CLI flags the way editing a .desktop # Exec= line does, and rewriting Info.plist/CFBundleExecutable in a signed # .app would break its code signature. Instead: a tiny compiled AppleScript # app that shells out to `open -na --args `, which is the # macOS-native way to hand an existing app bundle fresh argv. `-n` forces a # new process; if the app is already running and implements Electron's # second-instance handling it may just forward the URL to the running # window, otherwise it focuses the existing window without the flag (same # caveat as the Linux single-instance case). LAUNCHER_DIR="${INSTALL_DIR:-$HOME/Applications}" [[ -w "$LAUNCHER_DIR" ]] || LAUNCHER_DIR="$HOME/Applications" mkdir -p "$LAUNCHER_DIR" LAUNCHER_APP="$LAUNCHER_DIR/$APP_NAME ($URL_HOST).app" args_quoted="" for f in "${FLAGS[@]}"; do args_quoted+=" $(printf '%q' "$f")" done as_src="$(mktemp -t fluxer-launcher).applescript"; TMPFILES+=("$as_src") cat >"$as_src" </tmp/osacompile.err; then ok "wrote launcher: $LAUNCHER_APP" # Best-effort: give the launcher the real app's icon so it doesn't show the # generic AppleScript icon in the Dock/Finder. src_icns="$APP_PATH/Contents/Resources/$(defaults read "${plist%.plist}" CFBundleIconFile 2>/dev/null || true)" [[ "$src_icns" != *.icns ]] && src_icns="${src_icns}.icns" if [[ -f "$src_icns" ]]; then cp "$src_icns" "$LAUNCHER_APP/Contents/Resources/applet.icns" 2>/dev/null || true fi else warn "osacompile failed — no launcher app written; see /tmp/osacompile.err" warn "you can still launch manually: open -na \"$APP_PATH\" --args${args_quoted}" fi # ------------------------------------------------- verify flags exist info "Checking that the app knows the flags we pass…" ASAR="$APP_PATH/Contents/Resources/app.asar" [[ -e "$ASAR" ]] || ASAR="$APP_PATH/Contents/Resources/app" bundle_has() { # 0 = found, 1 = not found, 2 = can't tell [[ -e "$ASAR" ]] || return 2 local rc=0 grep -raqF -- "$1" "$ASAR" 2>/dev/null || rc=$? case "$rc" in 0) return 0 ;; 1) return 1 ;; *) return 2 ;; esac } rc=0; bundle_has 'fluxer-app-url' || rc=$? case "$rc" in 0) ok "--fluxer-app-url found in the app bundle" ;; 1) warn "--fluxer-app-url NOT found in the app bundle — the instance override will probably be ignored" ;; *) warn "couldn't locate app.asar to check --fluxer-app-url (looked at $ASAR)" ;; esac debug_rc=0; bundle_has 'fluxer-debug-info' || debug_rc=$? if (( debug_rc == 0 )) || [[ "${VERIFY:-0}" == 1 ]]; then if pgrep -f -- "$BIN" >/dev/null 2>&1; then warn "$EDITION is already running (single-instance) — skipping the launch-based check" else dbg="$(run_with_timeout 15 "$BIN" "--fluxer-app-url=${INSTANCE}" --fluxer-debug-info)" if grep -qF -- "$INSTANCE" <<<"$dbg"; then ok "app reports the instance override: $INSTANCE" else warn "could not confirm the override from --fluxer-debug-info output" fi fi else info "skipping launch-based check (--fluxer-debug-info not found in the bundle; VERIFY=1 to force)" fi echo ok "Done. Quit any running $EDITION first (menu bar → Quit, single-instance!), then open:" echo " $LAUNCHER_APP"