#!/usr/bin/env bash # FlintTrade Electron shell installer / updater (macOS + Linux) # # The default path installs the small published Electron shell. On first # launch, that shell acquires and builds FlintTrade source with its bundled, # checksum-pinned bootstrap. Contributors can instead package the Electron # shell from a trusted source checkout with --build-from-source. # # curl -fsSL https://flinttrade.vercel.app/install.sh | bash # curl -fsSL https://flinttrade.vercel.app/install.sh | bash -s -- --build-from-source # # The origin above is the last-resort public site. The hosted /install.sh # redirect stays valid on a custom domain; this desktop installer does not # read FLINTTRADE_SITE_URL (the web installer does). set -euo pipefail REPO_SLUG="navaneeshnagarajan/FlintTrade" REPO_URL="https://github.com/$REPO_SLUG.git" GITHUB_API_BASE="https://api.github.com/repos/$REPO_SLUG" RELEASE_DOWNLOAD_BASE="https://github.com/$REPO_SLUG/releases/download" RELEASE_API_OVERRIDE="${FLINTTRADE_GITHUB_RELEASES_API:-}" PINNED_PNPM_VERSION="10.34.5" RELEASE_TAG_PATTERN='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' SRC_DIR="${FLINTTRADE_SRC_DIR:-$HOME/.flinttrade/source-build/FlintTrade}" REF="${FLINTTRADE_REF:-}" CHANNEL="${FLINTTRADE_CHANNEL:-beta}" NO_LAUNCH="${FLINTTRADE_NO_LAUNCH:-0}" BUILD_FROM_SOURCE="${FLINTTRADE_BUILD_FROM_SOURCE:-0}" DRY_RUN="${FLINTTRADE_DRY_RUN:-0}" DOWNLOADED_ASSET_PATH="" DMG_MOUNT_DIR="" RELEASE_ASSET_VERIFIED=0 UPDATE_ATTESTATION_BOUND=0 ACTIVE_SWAP_BACKUP="" ACTIVE_SWAP_HAD_PREVIOUS=0 ACTIVE_SWAP_PENDING=0 ACTIVE_SWAP_TARGET="" ACTIVE_SWAP_TRANSACTION_DIR="" INSTALL_TRANSACTION_ACTIVE=0 INSTALL_TRANSACTION_TARGETS=("") INSTALL_TRANSACTION_BACKUPS=("") INSTALL_TRANSACTION_HAD_PREVIOUS=("") DEFERRED_INSTALL_SIGNAL=0 SHELL_RECEIPT_DIR="$HOME/.local/state/flinttrade" SHELL_RECEIPT_PATH="$SHELL_RECEIPT_DIR/shell-install.receipt" INSTALLED_LINUX_ICON="" LINUX_EXISTING_INSTALL_PROVED=0 TMP_DIRS=("") say() { printf '\033[1;36m[flinttrade]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[flinttrade]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[flinttrade]\033[0m ERROR: %s\n' "$*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1; } valid_release_tag() { printf '%s\n' "$1" | grep -Eq "$RELEASE_TAG_PATTERN"; } usage() { cat <<'USAGE' FlintTrade Electron shell installer / updater (macOS + Linux) Flags: --channel beta|stable Release channel to install (default: beta) --ref Install an exact release tag --build-from-source Package the Electron shell from a trusted checkout --src Checkout used by --build-from-source. Default: ~/.flinttrade/source-build/FlintTrade Override with the FLINTTRADE_SRC_DIR environment variable (the flag wins over the variable). The web installer (flinttrade-web-install.sh) deprecates FLINTTRADE_SRC_DIR in favour of FLINTTRADE_WEB_SRC_DIR for its managed source checkout. --update Alias for the default install/update flow --yes Compatibility flag for the bundled updater --no-launch Do not launch FlintTrade after installing --dry-run Resolve and verify policy without installing Where things land: ~/.flinttrade/source-build/FlintTrade the --src checkout (multi-GB once built) ~/.flinttrade/tools/source-build its private toolchain caches Both live under ~/.flinttrade so uninstall.sh --purge can find and remove them, and so a source build never writes into your own global npm/pnpm/uv caches. USAGE } while [ $# -gt 0 ]; do case "$1" in --channel) CHANNEL="${2:?--channel needs beta or stable}"; shift 2 ;; --ref) REF="${2:?--ref needs a value}"; shift 2 ;; --build-from-source) BUILD_FROM_SOURCE=1; shift ;; --src) SRC_DIR="${2:?--src needs a value}"; shift 2 ;; --update) shift ;; --yes) shift ;; --no-launch) NO_LAUNCH=1; shift ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; *) die "Unknown flag: $1 (see --help)" ;; esac done case "$CHANNEL" in beta|stable) ;; *) die "--channel must be beta or stable" ;; esac if [ -n "$REF" ] && ! valid_release_tag "$REF"; then die "--ref must be a semantic-version release tag such as v0.6.0-beta.13" fi detach_dmg_mount() { [ -n "$DMG_MOUNT_DIR" ] || return 0 local target="$DMG_MOUNT_DIR" i for i in 1 2 3 4 5; do if hdiutil detach "$target" -quiet >/dev/null 2>&1; then DMG_MOUNT_DIR="" return 0 fi sleep 1 done hdiutil detach "$target" -force -quiet >/dev/null 2>&1 || true DMG_MOUNT_DIR="" } restore_interrupted_swap() { [ "$ACTIVE_SWAP_PENDING" = "1" ] || return 0 if [ -e "$ACTIVE_SWAP_TARGET" ] || [ -L "$ACTIVE_SWAP_TARGET" ]; then rm -rf "$ACTIVE_SWAP_TARGET" 2>/dev/null \ || return 1 fi if [ "$ACTIVE_SWAP_HAD_PREVIOUS" = "1" ]; then [ -n "$ACTIVE_SWAP_BACKUP" ] && { [ -e "$ACTIVE_SWAP_BACKUP" ] || [ -L "$ACTIVE_SWAP_BACKUP" ]; } \ || return 1 mv "$ACTIVE_SWAP_BACKUP" "$ACTIVE_SWAP_TARGET" 2>/dev/null || return 1 fi ACTIVE_SWAP_BACKUP="" ACTIVE_SWAP_HAD_PREVIOUS=0 ACTIVE_SWAP_PENDING=0 ACTIVE_SWAP_TARGET="" ACTIVE_SWAP_TRANSACTION_DIR="" } rollback_install_files() { [ "$INSTALL_TRANSACTION_ACTIVE" = "1" ] || return 0 local index target backup had_previous failed=0 for ((index=${#INSTALL_TRANSACTION_TARGETS[@]} - 1; index >= 1; index--)); do target="${INSTALL_TRANSACTION_TARGETS[$index]}" backup="${INSTALL_TRANSACTION_BACKUPS[$index]}" had_previous="${INSTALL_TRANSACTION_HAD_PREVIOUS[$index]}" if [ -e "$target" ] || [ -L "$target" ]; then rm -f "$target" 2>/dev/null || { failed=1; continue; } fi if [ "$had_previous" = "1" ]; then mv "$backup" "$target" 2>/dev/null || failed=1 fi done [ "$failed" = "0" ] || return 1 INSTALL_TRANSACTION_TARGETS=("") INSTALL_TRANSACTION_BACKUPS=("") INSTALL_TRANSACTION_HAD_PREVIOUS=("") INSTALL_TRANSACTION_ACTIVE=0 } begin_install_transaction() { [ "$INSTALL_TRANSACTION_ACTIVE" = "0" ] \ || die "An Electron shell install transaction is already active." INSTALL_TRANSACTION_TARGETS=("") INSTALL_TRANSACTION_BACKUPS=("") INSTALL_TRANSACTION_HAD_PREVIOUS=("") INSTALL_TRANSACTION_ACTIVE=1 } defer_install_transaction_signals() { DEFERRED_INSTALL_SIGNAL=0 trap 'DEFERRED_INSTALL_SIGNAL=129' HUP trap 'DEFERRED_INSTALL_SIGNAL=130' INT trap 'DEFERRED_INSTALL_SIGNAL=143' TERM } restore_install_signal_traps() { trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM } publish_file_transactionally() { local staged="$1" target="$2" backup had_previous=0 preserve_status=0 deferred_signal [ "$INSTALL_TRANSACTION_ACTIVE" = "1" ] \ || die "Refusing to publish an installer-owned file outside the shell transaction." [ -f "$staged" ] && [ ! -L "$staged" ] \ || die "The staged installer-owned file is not ordinary: $staged" backup="$(mktemp "$(dirname "$target")/.flinttrade-rollback.XXXXXX")" \ || die "Could not reserve rollback state for $target." rm -f "$backup" || die "Could not prepare rollback state for $target." TMP_DIRS+=("$backup") # A signal handled after the old target moves but before its rollback record # is complete would otherwise make cleanup delete the only backup. Defer the # exit (not the signal observation) across this tiny move+journal section. defer_install_transaction_signals if [ -e "$target" ] || [ -L "$target" ]; then if mv "$target" "$backup"; then had_previous=1 else preserve_status=$? if { [ -e "$backup" ] || [ -L "$backup" ]; } \ && [ ! -e "$target" ] && [ ! -L "$target" ]; then # The rename completed before the signal/failure status was observed; # journal the only surviving copy so cleanup can restore it. had_previous=1 preserve_status=0 fi fi fi if [ "$preserve_status" = "0" ]; then INSTALL_TRANSACTION_TARGETS+=("$target") INSTALL_TRANSACTION_BACKUPS+=("$backup") INSTALL_TRANSACTION_HAD_PREVIOUS+=("$had_previous") fi restore_install_signal_traps # Once normal exit traps are restored, a newly delivered signal exits # immediately; any signal observed while deferred is now stable to inspect. deferred_signal="$DEFERRED_INSTALL_SIGNAL" [ "$preserve_status" = "0" ] \ || die "Could not preserve the previous installer-owned file at $target." [ "$deferred_signal" = "0" ] || exit "$deferred_signal" mv "$staged" "$target" || die "Could not publish the installer-owned file at $target." } commit_install_transaction() { [ "$INSTALL_TRANSACTION_ACTIVE" = "1" ] \ || die "No Electron shell install transaction is active." INSTALL_TRANSACTION_TARGETS=("") INSTALL_TRANSACTION_BACKUPS=("") INSTALL_TRANSACTION_HAD_PREVIOUS=("") INSTALL_TRANSACTION_ACTIVE=0 ACTIVE_SWAP_BACKUP="" ACTIVE_SWAP_HAD_PREVIOUS=0 ACTIVE_SWAP_PENDING=0 ACTIVE_SWAP_TARGET="" ACTIVE_SWAP_TRANSACTION_DIR="" } cleanup() { trap - HUP INT TERM detach_dmg_mount local dir protected="" backup keep if ! rollback_install_files; then warn "Interrupted shell integration publication could not fully restore its previous files." fi if ! restore_interrupted_swap; then protected="$ACTIVE_SWAP_TRANSACTION_DIR" warn "Interrupted shell replacement could not restore its backup; preserving it at $ACTIVE_SWAP_BACKUP." fi for dir in "${TMP_DIRS[@]}"; do [ -n "$protected" ] && [ "$dir" = "$protected" ] && continue keep=0 if [ "$INSTALL_TRANSACTION_ACTIVE" = "1" ]; then for backup in "${INSTALL_TRANSACTION_BACKUPS[@]}"; do [ -n "$backup" ] && [ "$dir" = "$backup" ] && keep=1 done fi [ "$keep" = "1" ] && continue [ -n "$dir" ] && rm -rf "$dir" 2>/dev/null || true done } trap cleanup EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM lowercase() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } update_parent_identity() { local pid="$1" if [ -r "/proc/$pid/stat" ]; then # The start-time field is number 20 after Linux's parenthesised comm field. /usr/bin/sed 's/^[^)]*) //' "/proc/$pid/stat" 2>/dev/null | /usr/bin/awk '{print $20}' return 0 fi /bin/ps -p "$pid" -o lstart= -o command= 2>/dev/null || true } wait_for_update_parent_exit() { local pid="$1" original_identity="$2" local timeout="${FLINTTRADE_UPDATE_PARENT_TIMEOUT_SECONDS:-3600}" elapsed=0 current_identity state case "$timeout" in ''|*[!0-9]*) die "The update-parent timeout must be an integer." ;; esac [ "$timeout" -ge 1 ] && [ "$timeout" -le 3900 ] \ || die "The update-parent timeout must be between 1 and 3900 seconds." while kill -0 "$pid" 2>/dev/null; do state="$(/bin/ps -p "$pid" -o state= 2>/dev/null | /usr/bin/tr -d '[:space:]' || true)" case "$state" in Z*) return 0 ;; esac current_identity="$(update_parent_identity "$pid")" if [ -n "$original_identity" ] && [ -n "$current_identity" ] \ && [ "$current_identity" != "$original_identity" ]; then # The exact parent exited and the OS reused its numeric PID. return 0 fi if [ "$elapsed" -ge $((timeout * 5)) ]; then kill -0 "$pid" 2>/dev/null || return 0 current_identity="$(update_parent_identity "$pid")" if [ -n "$original_identity" ] && [ -n "$current_identity" ] \ && [ "$current_identity" != "$original_identity" ]; then return 0 fi die "The verified shell installer timed out waiting for its exact Electron parent to exit; no installed shell was changed." fi /bin/sleep 0.2 elapsed=$((elapsed + 1)) done } signal_update_handoff() { [ "$DRY_RUN" = "1" ] && return 0 local marker="${FLINTTRADE_UPDATE_HANDOFF:-}" [ -n "$marker" ] || return 0 [ "$RELEASE_ASSET_VERIFIED" = "1" ] \ || die "Refusing an updater handoff before release checksum verification." [ "$UPDATE_ATTESTATION_BOUND" = "1" ] \ || die "Refusing an updater handoff before app-verified release attestation binding." [ -x /bin/ps ] && [ -x /bin/sleep ] && [ -x /usr/bin/tr ] \ || die "The verified installer handoff cannot inspect the exact parent on this system." if [ -r "/proc/${FLINTTRADE_UPDATE_PARENT_PID:-}/stat" ]; then [ -x /usr/bin/sed ] && [ -x /usr/bin/awk ] \ || die "The verified installer handoff cannot inspect the exact parent on this system." fi local parent_pid="${FLINTTRADE_UPDATE_PARENT_PID:-}" parent_identity case "$parent_pid" in ''|*[!0-9]*) die "The verified installer handoff requires an exact numeric parent PID." ;; esac [ "${#parent_pid}" -le 10 ] && [ "$parent_pid" -gt 1 ] \ && [ "$parent_pid" -le 4294967295 ] && [ "$parent_pid" -ne "$$" ] \ || die "The verified installer handoff parent PID is invalid." kill -0 "$parent_pid" 2>/dev/null \ || die "The verified installer handoff parent is no longer running." parent_identity="$(update_parent_identity "$parent_pid")" [ -n "$parent_identity" ] \ || die "The verified installer handoff could not bind the exact parent identity." (set -o noclobber; : > "$marker") 2>/dev/null \ || die "Could not create the new verified installer handoff marker." say "Verified handoff marker created; waiting for the exact Electron parent to exit." wait_for_update_parent_exit "$parent_pid" "$parent_identity" say "The exact Electron parent exited; beginning transactional shell replacement." } replace_directory_transactionally() { local staged="$1" target="$2" transaction_dir="$3" required_executable="$4" local backup="$transaction_dir/previous" had_previous=0 [ -d "$staged" ] && [ -x "$staged/$required_executable" ] \ || { warn "The staged shell failed executable validation."; return 1; } if [ -e "$backup" ] || [ -L "$backup" ]; then warn "The shell replacement backup path already exists." return 1 fi ACTIVE_SWAP_BACKUP="$backup" ACTIVE_SWAP_HAD_PREVIOUS=0 ACTIVE_SWAP_PENDING=1 ACTIVE_SWAP_TARGET="$target" ACTIVE_SWAP_TRANSACTION_DIR="$transaction_dir" if [ -e "$target" ] || [ -L "$target" ]; then ACTIVE_SWAP_HAD_PREVIOUS=1 if ! mv "$target" "$backup"; then ACTIVE_SWAP_BACKUP="" ACTIVE_SWAP_HAD_PREVIOUS=0 ACTIVE_SWAP_PENDING=0 ACTIVE_SWAP_TARGET="" ACTIVE_SWAP_TRANSACTION_DIR="" warn "Could not preserve the currently installed shell before replacement." return 1 fi had_previous=1 fi if ! mv "$staged" "$target"; then if restore_interrupted_swap; then [ "$had_previous" = "1" ] \ && warn "Shell replacement failed; restored the previous installed shell." else die "Shell replacement failed and the previous shell could not be restored from $backup." fi return 1 fi if [ ! -x "$target/$required_executable" ]; then if restore_interrupted_swap; then [ "$had_previous" = "1" ] \ && warn "Installed-shell validation failed; restored the previous installed shell." else die "Installed-shell validation failed and the previous shell could not be restored from $backup." fi return 1 fi } ensure_shell_receipt_storage() { local current="${HOME%/}" component candidate name for component in .local state flinttrade; do current="$current/$component" [ ! -L "$current" ] || die "Refusing installer receipt storage through a symbolic-link path component: $current" if [ -e "$current" ]; then [ -d "$current" ] && [ -O "$current" ] \ || die "Refusing installer receipt storage through a non-owner-local path component: $current" fi done mkdir -p "$SHELL_RECEIPT_DIR" \ || die "Could not create the private Electron shell receipt directory." [ -d "$SHELL_RECEIPT_DIR" ] && [ ! -L "$SHELL_RECEIPT_DIR" ] && [ -O "$SHELL_RECEIPT_DIR" ] \ || die "The Electron shell receipt directory is not an owner-local ordinary directory." chmod 0700 "$SHELL_RECEIPT_DIR" \ || die "Could not make the Electron shell receipt directory private." for candidate in "$SHELL_RECEIPT_DIR"/* "$SHELL_RECEIPT_DIR"/.[!.]* "$SHELL_RECEIPT_DIR"/..?*; do [ -e "$candidate" ] || [ -L "$candidate" ] || continue name="$(basename "$candidate")" case "$name" in shell-install.receipt) ;; desktop-launch.log) [ -f "$candidate" ] && [ ! -L "$candidate" ] && [ -O "$candidate" ] \ || die "The existing shell launch log is not an owner-local ordinary file: $candidate" ;; *) die "Refusing to mix the shell receipt with unrecognised state: $candidate" ;; esac done } canonical_installed_path() { local target="$1" parent if [ -d "$target" ]; then (cd -P "$target" 2>/dev/null && pwd -P) return fi parent="$(cd -P "$(dirname "$target")" 2>/dev/null && pwd -P)" || return 1 printf '%s/%s' "${parent%/}" "$(basename "$target")" } receipt_sha256_file() { local target="$1" if need sha256sum; then sha256sum "$target" | awk '{print $1}' elif need shasum; then shasum -a 256 "$target" | awk '{print $1}' else die "Neither sha256sum nor shasum is available; refusing to create an unverifiable shell receipt." fi } receipt_safe_value() { case "$1" in *$'\n'*|*$'\r'*) die "Installer receipt values must be single-line paths." ;; esac } write_shell_receipt() { local kind="$1" target="$2" executable="$3" wrapper="$4" desktop="$5" icon="$6" local platform canonical_target canonical_executable canonical_wrapper="" canonical_desktop="" canonical_icon="" local executable_hash icon_hash="" receipt_tmp ensure_shell_receipt_storage [ -e "$target" ] && [ ! -L "$target" ] \ || die "The installed Electron shell target is not an ordinary receipt candidate." [ -f "$executable" ] && [ ! -L "$executable" ] && [ -x "$executable" ] \ || die "The installed Electron shell executable is not an ordinary receipt candidate." canonical_target="$(canonical_installed_path "$target")" \ || die "Could not canonicalise the installed Electron shell target for its receipt." canonical_executable="$(canonical_installed_path "$executable")" \ || die "Could not canonicalise the installed Electron shell executable for its receipt." if [ -n "$wrapper" ]; then [ -f "$wrapper" ] && [ ! -L "$wrapper" ] && [ -x "$wrapper" ] \ || die "The installed FlintTrade command wrapper is not an ordinary receipt candidate." canonical_wrapper="$(canonical_installed_path "$wrapper")" \ || die "Could not canonicalise the FlintTrade command wrapper for its receipt." fi if [ -n "$desktop" ]; then [ -f "$desktop" ] && [ ! -L "$desktop" ] \ || die "The installed FlintTrade desktop entry is not an ordinary receipt candidate." canonical_desktop="$(canonical_installed_path "$desktop")" \ || die "Could not canonicalise the FlintTrade desktop entry for its receipt." fi if [ -n "$icon" ]; then [ -f "$icon" ] && [ ! -L "$icon" ] \ || die "The installed FlintTrade icon is not an ordinary receipt candidate." canonical_icon="$(canonical_installed_path "$icon")" \ || die "Could not canonicalise the FlintTrade icon for its receipt." icon_hash="$(receipt_sha256_file "$canonical_icon")" fi platform="$(uname -s)" executable_hash="$(receipt_sha256_file "$canonical_executable")" for target in "$platform" "$kind" "$canonical_target" "$canonical_executable" "$canonical_wrapper" \ "$canonical_desktop" "$canonical_icon" "$executable_hash" "$icon_hash"; do receipt_safe_value "$target" done receipt_tmp="$(mktemp "$SHELL_RECEIPT_DIR/.shell-install.receipt.XXXXXX")" \ || die "Could not stage the Electron shell install receipt." TMP_DIRS+=("$receipt_tmp") chmod 0600 "$receipt_tmp" { printf '%s\n' \ 'format=flinttrade-electron-shell-v1' \ "platform=$platform" \ "kind=$kind" \ "target=$canonical_target" \ "executable=$canonical_executable" \ "executable_sha256=$executable_hash" \ "wrapper=$canonical_wrapper" \ "desktop=$canonical_desktop" \ "icon=$canonical_icon" \ "icon_sha256=$icon_hash" } > "$receipt_tmp" || die "Could not write the Electron shell install receipt." publish_file_transactionally "$receipt_tmp" "$SHELL_RECEIPT_PATH" } ensure_owner_local_directory() { local target="${1%/}" current="${HOME%/}" relative component case "$target" in "$HOME"|"$HOME"/*) ;; *) die "Refusing an integration directory outside the current user's home: $target" ;; esac relative="${target#"${HOME%/}"/}" while [ -n "$relative" ]; do case "$relative" in */*) component="${relative%%/*}"; relative="${relative#*/}" ;; *) component="$relative"; relative="" ;; esac current="$current/$component" [ ! -L "$current" ] || die "Refusing an installer integration path through a symbolic link: $current" if [ -e "$current" ] && [ ! -d "$current" ]; then die "Refusing a non-directory installer integration parent: $current" fi if [ -d "$current" ] && [ ! -O "$current" ]; then die "Refusing a non-owner-local installer integration parent: $current" fi done mkdir -p "$target" || die "Could not create installer integration directory $target." [ -d "$target" ] && [ ! -L "$target" ] && [ -O "$target" ] \ || die "Installer integration directory is not owner-local: $target" } prepare_integration_leaf() { local target="$1" ensure_owner_local_directory "$(dirname "$target")" if [ -L "$target" ] || { [ -e "$target" ] && [ ! -f "$target" ]; }; then die "Refusing to replace a non-ordinary installer integration path: $target" fi } preflight_linux_text_integration() { local target="$1" expected="$2" prepare_integration_leaf "$target" [ -e "$target" ] || return 0 [ "$LINUX_EXISTING_INSTALL_PROVED" = "1" ] \ || die "Refusing to replace an unproven same-name integration file: $target" [ "$(cat "$target")" = "$expected" ] \ || die "Refusing to replace a modified FlintTrade integration file: $target" } preflight_linux_integrations() { local executable="$1" wrapper="$HOME/.local/bin/flinttrade" local desktop="$HOME/.local/share/applications/flinttrade.desktop" local icon="$HOME/.local/share/icons/hicolor/128x128/apps/flinttrade.png" preflight_linux_text_integration "$wrapper" "$(linux_wrapper_content "$executable")" preflight_linux_text_integration "$desktop" "$(linux_desktop_content "$wrapper")" prepare_integration_leaf "$icon" if [ -e "$icon" ] && [ "$LINUX_EXISTING_INSTALL_PROVED" != "1" ]; then die "Refusing to replace an unproven same-name integration file: $icon" fi } atomic_write_integration_file() { local target="$1" mode="$2" content="$3" tmp prepare_integration_leaf "$target" if [ -e "$target" ]; then [ "$LINUX_EXISTING_INSTALL_PROVED" = "1" ] \ || die "Refusing to replace an unproven same-name integration file: $target" [ "$(cat "$target")" = "$content" ] \ || die "Refusing to replace a modified FlintTrade integration file: $target" fi tmp="$(mktemp "$(dirname "$target")/.flinttrade-integration.XXXXXX")" \ || die "Could not stage installer integration at $target." TMP_DIRS+=("$tmp") printf '%s\n' "$content" > "$tmp" chmod "$mode" "$tmp" publish_file_transactionally "$tmp" "$target" } atomic_copy_integration_file() { local source="$1" target="$2" tmp prepare_integration_leaf "$target" if [ -e "$target" ] && [ "$LINUX_EXISTING_INSTALL_PROVED" != "1" ]; then die "Refusing to replace an unproven same-name integration file: $target" fi tmp="$(mktemp "$(dirname "$target")/.flinttrade-integration.XXXXXX")" \ || die "Could not stage installer integration at $target." TMP_DIRS+=("$tmp") cp "$source" "$tmp" || die "Could not stage the FlintTrade application icon." chmod 0644 "$tmp" publish_file_transactionally "$tmp" "$target" } linux_wrapper_content() { printf '#!/bin/sh\nexec "%s" "$@"' "$1" } linux_desktop_content() { printf '%s\n' \ '[Desktop Entry]' \ 'Name=FlintTrade' \ "Exec=$1" \ 'Icon=flinttrade' \ 'Type=Application' \ 'Categories=Office;Finance;' \ 'Comment=Open-source self-hosted trading software' \ 'StartupWMClass=FlintTrade' } receipt_proves_existing_linux_target() { local kind="$1" target="$2" executable="$3" local line1 line2 line3 line4 line5 line6 line7 line8 line9 line10 extra actual mode [ -d "$SHELL_RECEIPT_DIR" ] && [ ! -L "$SHELL_RECEIPT_DIR" ] && [ -O "$SHELL_RECEIPT_DIR" ] || return 1 mode="$(/usr/bin/stat -c '%a' "$SHELL_RECEIPT_DIR" 2>/dev/null \ || /usr/bin/stat -f '%Lp' "$SHELL_RECEIPT_DIR" 2>/dev/null)" || return 1 [ "$mode" = "700" ] || return 1 [ -f "$SHELL_RECEIPT_PATH" ] && [ ! -L "$SHELL_RECEIPT_PATH" ] && [ -O "$SHELL_RECEIPT_PATH" ] || return 1 mode="$(/usr/bin/stat -c '%a' "$SHELL_RECEIPT_PATH" 2>/dev/null \ || /usr/bin/stat -f '%Lp' "$SHELL_RECEIPT_PATH" 2>/dev/null)" || return 1 [ "$mode" = "600" ] || return 1 { IFS= read -r line1 && IFS= read -r line2 && IFS= read -r line3 \ && IFS= read -r line4 && IFS= read -r line5 && IFS= read -r line6 \ && IFS= read -r line7 && IFS= read -r line8 && IFS= read -r line9 \ && IFS= read -r line10 && ! IFS= read -r extra } < "$SHELL_RECEIPT_PATH" || return 1 [ "$line1" = "format=flinttrade-electron-shell-v1" ] \ && [ "$line2" = "platform=Linux" ] \ && [ "$line3" = "kind=$kind" ] \ && [ "$line4" = "target=$target" ] \ && [ "$line5" = "executable=$executable" ] \ || return 1 case "$line6" in executable_sha256=*) ;; *) return 1 ;; esac case "$line7" in wrapper=*) ;; *) return 1 ;; esac case "$line8" in desktop=*) ;; *) return 1 ;; esac case "$line9" in icon=*) ;; *) return 1 ;; esac case "$line10" in icon_sha256=*) ;; *) return 1 ;; esac [ "${#line6}" -eq 82 ] || return 1 actual="$(receipt_sha256_file "$executable")" || return 1 [ "$(lowercase "$actual")" = "$(lowercase "${line6#executable_sha256=}")" ] } legacy_linux_integration_proves_target() { local executable="$1" wrapper="$HOME/.local/bin/flinttrade" desktop="$HOME/.local/share/applications/flinttrade.desktop" [ -f "$wrapper" ] && [ ! -L "$wrapper" ] && [ -x "$wrapper" ] \ && [ -f "$desktop" ] && [ ! -L "$desktop" ] \ && [ "$(cat "$wrapper")" = "$(linux_wrapper_content "$executable")" ] \ && [ "$(cat "$desktop")" = "$(linux_desktop_content "$wrapper")" ] } assert_existing_linux_target_owned() { local kind="$1" target="$2" executable="$3" update_mode="$4" canonical_target canonical_executable LINUX_EXISTING_INSTALL_PROVED=0 [ -e "$target" ] || [ -L "$target" ] || return 0 [ ! -L "$target" ] || die "Refusing to replace a symbolic-link shell target: $target" [ -f "$executable" ] && [ ! -L "$executable" ] && [ -x "$executable" ] \ || die "Refusing to replace an unproven same-name shell target: $target" canonical_target="$(canonical_installed_path "$target")" || die "Could not canonicalise existing shell target $target." canonical_executable="$(canonical_installed_path "$executable")" || die "Could not canonicalise existing shell executable $executable." if [ "$update_mode" != "fresh" ] \ || receipt_proves_existing_linux_target "$kind" "$canonical_target" "$canonical_executable" \ || legacy_linux_integration_proves_target "$canonical_executable"; then LINUX_EXISTING_INSTALL_PROVED=1 return 0 fi die "Refusing to replace an unrelated same-name shell target without a valid receipt or legacy FlintTrade integration proof: $target" } assert_existing_macos_target_owned() { local target="$1" update_mode="$2" bundle_id [ -e "$target" ] || [ -L "$target" ] || return 0 [ ! -L "$target" ] && [ -d "$target" ] \ || die "Refusing to replace a non-ordinary same-name macOS application: $target" [ "$update_mode" != "fresh" ] && return 0 [ -f "$target/Contents/Info.plist" ] && [ ! -L "$target/Contents/Info.plist" ] \ && [ -f "$target/Contents/MacOS/FlintTrade" ] && [ ! -L "$target/Contents/MacOS/FlintTrade" ] \ && [ -x "$target/Contents/MacOS/FlintTrade" ] \ || die "Refusing to replace an unproven same-name macOS application: $target" bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$target/Contents/Info.plist" 2>/dev/null || true)" [ "$bundle_id" = "com.flinttrade.app" ] \ || die "Refusing to replace a same-name macOS application with a different bundle identity: $target" } assert_no_other_linux_shell_target() { local selected="$1" candidate canonical_selected canonical_candidate canonical_selected="$(canonical_installed_path "$selected")" \ || die "Could not canonicalise the selected Linux shell target." for candidate in "$HOME/.local/bin/flinttrade.AppImage" "$HOME/.local/opt/flinttrade"; do [ -e "$candidate" ] || [ -L "$candidate" ] || continue [ ! -L "$candidate" ] \ || die "Refusing to install while a symbolic-link same-name Linux shell target is present: $candidate" canonical_candidate="$(canonical_installed_path "$candidate" 2>/dev/null || printf '%s' "$candidate")" [ "$canonical_candidate" = "$canonical_selected" ] \ || die "Refusing to install while another same-name Linux shell target is present: $candidate" done } assert_no_other_macos_shell_target() { local selected="$1" candidate canonical_selected canonical_candidate canonical_selected="$(canonical_installed_path "$selected")" \ || die "Could not canonicalise the selected macOS shell target." for candidate in "/Applications/FlintTrade.app" "$HOME/Applications/FlintTrade.app"; do [ -e "$candidate" ] || [ -L "$candidate" ] || continue [ ! -L "$candidate" ] \ || die "Refusing to install while a symbolic-link same-name macOS application is present: $candidate" canonical_candidate="$(canonical_installed_path "$candidate" 2>/dev/null || printf '%s' "$candidate")" [ "$canonical_candidate" = "$canonical_selected" ] \ || die "Refusing to install while another same-name macOS application is present: $candidate" done } redirect_source_build_caches() { # A source build is a contributor convenience, not a licence to rewrite the # operator's global toolchain state. Point every cache the JavaScript/uv # toolchain honours at a private tree under the canonical tools root, exactly # as packages/apps/desktop/electron/bootstrap.ts does for the managed # bootstrap — so the artefacts are isolated, reproducible, and reachable by # `uninstall.sh --purge` (which owns ~/.flinttrade/tools). local tools="$HOME/.flinttrade/tools/source-build" export COREPACK_HOME="$tools/corepack" export NPM_CONFIG_CACHE="$tools/npm-cache" export PNPM_HOME="$tools/pnpm-home" export UV_CACHE_DIR="$tools/uv-cache" export UV_PYTHON_INSTALL_DIR="$tools/python" # pnpm resolves its content-addressable store under XDG_DATA_HOME, and both # Corepack and pnpm fall back to XDG_CACHE_HOME; without these the redirects # above still leak into ~/.cache and ~/.local/share. export XDG_CACHE_HOME="$tools/xdg-cache" export XDG_DATA_HOME="$tools/xdg-data" mkdir -p "$COREPACK_HOME" "$NPM_CONFIG_CACHE" "$PNPM_HOME" "$UV_CACHE_DIR" \ "$UV_PYTHON_INSTALL_DIR" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" } pnpm_run() { redirect_source_build_caches if need corepack; then corepack pnpm "$@" return $? fi if need pnpm && [ "$(pnpm --version 2>/dev/null)" = "$PINNED_PNPM_VERSION" ]; then pnpm "$@" return $? fi if need npx; then npx --yes "pnpm@$PINNED_PNPM_VERSION" "$@" return $? fi return 127 } resolve_latest_tag() { sed 's/-/~/' | sort -V | tail -1 | sed 's/~/-/' } filter_release_tags_for_channel() { local tag without_build prerelease while IFS= read -r tag; do [ -n "$tag" ] || continue without_build="${tag%%+*}" prerelease="" case "$without_build" in *-*) prerelease="${without_build#*-}" ;; esac if [ "$CHANNEL" = "stable" ]; then [ -z "$prerelease" ] && printf '%s\n' "$tag" else case "$prerelease" in ""|beta|beta.*) printf '%s\n' "$tag" ;; esac fi done } normalised_arch() { case "$(uname -m)" in arm64|aarch64) printf 'arm64' ;; x86_64|amd64) printf 'x64' ;; *) die "Unsupported CPU architecture: $(uname -m)" ;; esac } release_api_url() { if [ -n "$RELEASE_API_OVERRIDE" ]; then case "$RELEASE_API_OVERRIDE" in https://*) printf '%s' "$RELEASE_API_OVERRIDE" ;; file://*) [ "${FLINTTRADE_ALLOW_LOCAL_ASSET:-0}" = "1" ] \ || die "Refusing non-HTTPS GitHub release API override" printf '%s' "$RELEASE_API_OVERRIDE" ;; *) die "Refusing non-HTTPS GitHub release API override" ;; esac elif [ -n "$REF" ]; then printf '%s/releases/tags/%s' "$GITHUB_API_BASE" "$REF" else printf '%s/releases?per_page=100' "$GITHUB_API_BASE" fi } # Select one complete top-level release object without depending on jq or a # system Python. The scanner tracks JSON strings and nested braces, so braces # in release notes cannot truncate the object. GitHub returns newest first. select_release_object() { local wanted="$1" required_suffix="$2" LC_ALL=C fold -w 8192 | awk -v wanted="$wanted" -v required_suffix="$required_suffix" \ -v download_base="$RELEASE_DOWNLOAD_BASE" ' function occurrences(text, marker, count, offset) { count = 0 while ((offset = index(text, marker)) > 0) { count++ text = substr(text, offset + length(marker)) } return count } BEGIN { depth = 0; started = 0; quoted = 0; escaped = 0; object = "" } { chunk = $0 segment_start = started ? 1 : 0 for (i = 1; i <= length(chunk); i++) { c = substr(chunk, i, 1) if (!started) { if (c == "{") { started = 1; depth = 1; segment_start = i } continue } if (quoted) { if (escaped) escaped = 0 else if (c == "\\") escaped = 1 else if (c == "\"") quoted = 0 continue } if (c == "\"") quoted = 1 else if (c == "{") depth++ else if (c == "}") { depth-- if (depth == 0) { object = object substr(chunk, segment_start, i - segment_start + 1) compact = object gsub(/[[:space:]]/, "", compact) tag_marker = "\"tag_name\":\"" tag_start = index(compact, tag_marker) tag = "" if (tag_start) { tag_rest = substr(compact, tag_start + length(tag_marker)) tag_finish = index(tag_rest, "\"") if (tag_finish) tag = substr(tag_rest, 1, tag_finish - 1) } metadata_prerelease = index(compact, "\"prerelease\":true") > 0 metadata_stable = index(compact, "\"prerelease\":false") > 0 tag_prerelease = tag ~ /^v[^+]*-/ semver = tag ~ /^v(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?([+][0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?$/ acceptable = index(compact, "\"draft\":false") > 0 && semver acceptable = acceptable && ((metadata_prerelease && tag_prerelease) || (metadata_stable && !tag_prerelease)) if (wanted == "stable") acceptable = acceptable && !tag_prerelease else if (wanted == "beta") acceptable = acceptable && (!tag_prerelease || tag ~ /-beta(\.|\+|$)/) if (acceptable && wanted != "exact") { version = tag sub(/^v/, "", version) asset_name = "FlintTrade-" version "-" required_suffix asset_url = download_base "/" tag "/" asset_name sums_url = download_base "/" tag "/SHA256SUMS.txt" acceptable = occurrences(compact, "\"name\":\"" asset_name "\"") == 1 acceptable = acceptable && occurrences(compact, "\"name\":\"SHA256SUMS.txt\"") == 1 acceptable = acceptable && occurrences(compact, "\"browser_download_url\":\"" asset_url "\"") == 1 acceptable = acceptable && occurrences(compact, "\"browser_download_url\":\"" sums_url "\"") == 1 } if (acceptable && selected == "") selected = object started = 0; quoted = 0; escaped = 0; object = ""; segment_start = 0 } } } if (started && segment_start > 0) object = object substr(chunk, segment_start) } END { if (selected != "") printf "%s", selected } ' } json_string_field() { local json="$1" field="$2" printf '%s' "$json" | tr -d '\n\r\t ' | awk -v field="$field" ' { marker = "\"" field "\":\"" start = index($0, marker) if (!start) exit 1 rest = substr($0, start + length(marker)) finish = index(rest, "\"") if (!finish) exit 1 print substr(rest, 1, finish - 1) } ' } asset_url_named() { local json="$1" name="$2" printf '%s' "$json" | tr -d '\n\r\t ' | awk -v name="$name" ' { name_marker = "\"name\":\"" name "\"" remaining = $0 count = 0 while ((name_start = index(remaining, name_marker)) > 0) { count++ if (count == 1) after_name = substr(remaining, name_start + length(name_marker)) remaining = substr(remaining, name_start + length(name_marker)) } if (count != 1) exit 1 marker = "\"browser_download_url\":\"" start = index(after_name, marker) if (!start) exit 1 rest = substr(after_name, start + length(marker)) finish = index(rest, "\"") if (!finish) exit 1 print substr(rest, 1, finish - 1) } ' } trusted_release_asset_url() { local url="$1" tag="$2" name="$3" [ "$url" = "$RELEASE_DOWNLOAD_BASE/$tag/$name" ] \ || die "Refusing asset URL outside the selected official release: $url" } checksum_for_asset() { local sums="$1" name="$2" hash hash="$(printf '%s\n' "$sums" | awk -v name="$name" ' ($2 == name || $2 == "*" name) { count++ value = $1 } END { if (count != 1) exit 1 print value } ')" || die "SHA256SUMS.txt does not contain exactly one checksum for $name" [ "${#hash}" -eq 64 ] || die "SHA256SUMS.txt has an invalid checksum for $name" case "$hash" in *[!0-9A-Fa-f]*) die "SHA256SUMS.txt has an invalid checksum for $name" ;; esac printf '%s' "$hash" } verify_sha256() { local file="$1" expected="$2" actual if need sha256sum; then actual="$(sha256sum "$file" | awk '{print $1}')" elif need shasum; then actual="$(shasum -a 256 "$file" | awk '{print $1}')" else die "Neither sha256sum nor shasum is available; refusing an unverified installer." fi if [ "$(lowercase "$actual")" != "$(lowercase "$expected")" ]; then die "Checksum mismatch for $(basename "$file"). Expected $expected, got $actual." fi say "Verified SHA-256 checksum from the selected release." } download_release_asset() { need curl || die "curl is required to download the FlintTrade installer." local os_name="$1" arch="$2" extension="$3" local api_url response wanted release tag version asset_name asset_url sums_name sums_url sums expected api_url="$(release_api_url)" say "Resolving FlintTrade shell release from GitHub ($api_url)..." response="$(curl -fsSL -H 'Accept: application/vnd.github+json' -H 'X-GitHub-Api-Version: 2022-11-28' "$api_url")" \ || die "Could not query the official GitHub release API." wanted="exact" if [ -z "$REF" ]; then wanted="$CHANNEL" fi release="$(printf '%s' "$response" | select_release_object "$wanted" "$os_name-$arch.$extension")" if [ -z "$release" ]; then if [ "$wanted" = "exact" ]; then die "No non-draft release was found for exact ref $REF." fi die "No complete non-draft $CHANNEL release was found for $os_name-$arch." fi tag="$(json_string_field "$release" tag_name)" || die "GitHub release metadata has no tag_name." valid_release_tag "$tag" || die "GitHub returned an invalid release tag." [ -z "$REF" ] || [ "$tag" = "$REF" ] || die "GitHub returned tag $tag while $REF was requested." version="${tag#v}" asset_name="FlintTrade-$version-$os_name-$arch.$extension" sums_name="SHA256SUMS.txt" asset_url="$(asset_url_named "$release" "$asset_name" || true)" sums_url="$(asset_url_named "$release" "$sums_name" || true)" [ -n "$asset_url" ] || die "Release $tag has no required $asset_name shell installer." [ -n "$sums_url" ] || die "Release $tag has no required SHA256SUMS.txt asset." trusted_release_asset_url "$asset_url" "$tag" "$asset_name" trusted_release_asset_url "$sums_url" "$tag" "$sums_name" sums="$(curl -fsSL "$sums_url")" || die "Could not download SHA256SUMS.txt from release $tag." expected="$(checksum_for_asset "$sums" "$asset_name")" if [ -n "${FLINTTRADE_UPDATE_HANDOFF:-}" ]; then local attested_name="${FLINTTRADE_UPDATE_ASSET_NAME:-}" local attested_hash="${FLINTTRADE_UPDATE_ASSET_SHA256:-}" [ "$attested_name" = "$asset_name" ] \ || die "The selected installer name did not match the app-verified release attestation." [ "${#attested_hash}" -eq 64 ] \ || die "The app-verified release attestation digest was invalid." case "$attested_hash" in *[!0-9a-f]*) die "The app-verified release attestation digest was invalid." ;; esac [ "$(lowercase "$expected")" = "$attested_hash" ] \ || die "SHA256SUMS.txt did not match the app-verified release attestation digest." expected="$attested_hash" UPDATE_ATTESTATION_BOUND=1 fi if [ "$DRY_RUN" = "1" ]; then say "DRY-RUN: would download $asset_name" say "DRY-RUN: $asset_url" say "DRY-RUN: would verify sha256 $expected from $sums_url" DOWNLOADED_ASSET_PATH="/tmp/$asset_name" return 0 fi local tmp_dir dest tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/flinttrade.XXXXXX")" TMP_DIRS+=("$tmp_dir") dest="$tmp_dir/$asset_name" say "Downloading $asset_name..." curl -fL "$asset_url" -o "$dest" verify_sha256 "$dest" "$expected" RELEASE_ASSET_VERIFIED=1 DOWNLOADED_ASSET_PATH="$dest" } install_macos_dmg() { local dmg="$1" mount_dir="${TMPDIR:-/tmp}/flinttrade-dmg-$$" dest="/Applications" local target_app requested_target canonical_parent update_mode="fresh" if [ -n "${FLINTTRADE_UPDATE_HANDOFF:-}" ] && [ "$DRY_RUN" != "1" ]; then requested_target="${FLINTTRADE_UPDATE_MACOS_APP_BUNDLE:-}" case "$requested_target" in /*/FlintTrade.app) ;; *) die "The macOS updater requires the exact absolute FlintTrade.app bundle path." ;; esac case "$requested_target" in */AppTranslocation/*) die "Refusing to update a translocated macOS app bundle." ;; esac [ -d "$requested_target" ] && [ ! -L "$requested_target" ] \ && [ -x "$requested_target/Contents/MacOS/FlintTrade" ] \ || die "The macOS updater target is not an ordinary installed FlintTrade.app bundle." canonical_parent="$(cd -P "$(dirname "$requested_target")" 2>/dev/null && pwd)" \ || die "The macOS updater target parent cannot be canonicalised." target_app="$canonical_parent/FlintTrade.app" [ "$target_app" = "$requested_target" ] \ || die "The macOS updater target must already be canonical and free of symlink aliases." [ -w "$requested_target" ] && [ -w "$canonical_parent" ] \ || die "The exact running macOS app bundle is not writable; no update was signalled." dest="$canonical_parent" update_mode="mac-update" else [ -w "$dest" ] || dest="$HOME/Applications" target_app="$dest/FlintTrade.app" fi if [ "$DRY_RUN" = "1" ]; then say "DRY-RUN: would mount $dmg and install FlintTrade.app at $target_app" return 0 fi ensure_shell_receipt_storage case "$dest" in "$HOME"|"$HOME"/*) ensure_owner_local_directory "$dest" ;; *) [ -d "$dest" ] && [ ! -L "$dest" ] || die "The macOS application destination is not an ordinary directory: $dest" ;; esac assert_existing_macos_target_owned "$target_app" "$update_mode" assert_no_other_macos_shell_target "$target_app" mkdir -p "$mount_dir" TMP_DIRS+=("$mount_dir") local attach_status=0 set +e +o pipefail yes 2>/dev/null | hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_dir" >/dev/null 2>&1 attach_status=${PIPESTATUS[1]} set -e -o pipefail [ "$attach_status" -eq 0 ] || die "Could not mount $dmg. Download and open it manually." DMG_MOUNT_DIR="$mount_dir" local app_path transaction_dir staged_app app_path="$(find "$mount_dir" -maxdepth 2 -name 'FlintTrade.app' -type d | head -1)" [ -n "$app_path" ] || die "No FlintTrade.app found in $dmg" transaction_dir="$(mktemp -d "$dest/.flinttrade-install.XXXXXX")" \ || die "Could not create an adjacent macOS shell staging directory." TMP_DIRS+=("$transaction_dir") staged_app="$transaction_dir/FlintTrade.app" if ! ditto "$app_path" "$staged_app"; then die "Could not stage FlintTrade.app from the verified disk image." fi [ -f "$staged_app/Contents/Info.plist" ] \ && [ -x "$staged_app/Contents/MacOS/FlintTrade" ] \ || die "The staged macOS shell is missing its expected bundle metadata or executable." signal_update_handoff begin_install_transaction say "Installing FlintTrade.app at $target_app..." replace_directory_transactionally \ "$staged_app" "$target_app" "$transaction_dir" "Contents/MacOS/FlintTrade" \ || die "Could not transactionally replace FlintTrade.app; the previous shell was preserved when present." write_shell_receipt \ "mac-app" "$target_app" "$target_app/Contents/MacOS/FlintTrade" "" "" "" commit_install_transaction detach_dmg_mount rm -rf "$mount_dir" say "Installed: $target_app" if [ "$NO_LAUNCH" != "1" ]; then open "$target_app"; fi } linux_fuse_available() { [ -e /dev/fuse ] || return 1 need fusermount || need fusermount3 || return 1 if need ldconfig && ldconfig -p 2>/dev/null | grep -q 'libfuse\.so\.2'; then return 0; fi local lib for lib in /usr/lib/*/libfuse.so.2 /usr/lib/libfuse.so.2 /usr/lib64/libfuse.so.2; do [ -e "$lib" ] && return 0 done return 1 } install_linux_launcher_extras() { local exec_target="$1" dest_bin="$2" icon_source="$3" local icon_dir="$HOME/.local/share/icons/hicolor/128x128/apps" INSTALLED_LINUX_ICON="" if [ -n "$icon_source" ] && [ -f "$icon_source" ]; then atomic_copy_integration_file "$icon_source" "$icon_dir/flinttrade.png" INSTALLED_LINUX_ICON="$icon_dir/flinttrade.png" elif [ -f "$icon_dir/flinttrade.png" ] && [ ! -L "$icon_dir/flinttrade.png" ] \ && [ "$LINUX_EXISTING_INSTALL_PROVED" = "1" ]; then INSTALLED_LINUX_ICON="$icon_dir/flinttrade.png" fi if [ "$exec_target" != "$dest_bin/flinttrade" ]; then atomic_write_integration_file \ "$dest_bin/flinttrade" 0755 "$(linux_wrapper_content "$exec_target")" fi case ":$PATH:" in *":$dest_bin:"*) ;; *) warn "$dest_bin is not on PATH; add it to run 'flinttrade' from a shell." ;; esac } launch_linux_app() { local executable="$1" log_dir="$HOME/.local/state/flinttrade" log pid mkdir -p "$log_dir" log="$log_dir/desktop-launch.log" nohup "$executable" > "$log" 2>&1 & pid=$! sleep 3 if ! kill -0 "$pid" 2>/dev/null; then tail -20 "$log" >&2 || true die "FlintTrade exited immediately; full log: $log" fi say "FlintTrade is starting. First launch builds the verified local source checkout (log: $log)." } install_linux_appimage_extracted() { local appimage="$1" dest_bin="$2" desktop_dir="$3" local opt_dir="${4:-$HOME/.local/opt/flinttrade}" local update_mode="${5:-fresh}" local opt_parent transaction_dir staged wrapper icon say "FUSE is unavailable; using the AppImage self-extraction install." opt_parent="$(dirname "$opt_dir")" if [ "$update_mode" = "fresh" ]; then ensure_owner_local_directory "$opt_parent" fi transaction_dir="$(mktemp -d "$opt_parent/.flinttrade-install.XXXXXX")" \ || die "Could not create an adjacent extracted-shell staging directory." TMP_DIRS+=("$transaction_dir") staged="$transaction_dir/new" mkdir -p "$staged" if ! (cd "$staged" && "$appimage" --appimage-extract >/dev/null 2>&1) \ || [ ! -x "$staged/squashfs-root/AppRun" ]; then warn "AppImage self-extraction failed; installing the image directly. libfuse2 may be needed to launch it." return 1 fi assert_existing_linux_target_owned \ "linux-extracted" "$opt_dir" "$opt_dir/squashfs-root/AppRun" "$update_mode" assert_no_other_linux_shell_target "$opt_dir" wrapper="$dest_bin/flinttrade" preflight_linux_integrations "$opt_dir/squashfs-root/AppRun" signal_update_handoff begin_install_transaction replace_directory_transactionally \ "$staged" "$opt_dir" "$transaction_dir" "squashfs-root/AppRun" \ || die "Could not transactionally replace the extracted FlintTrade shell; the previous shell was preserved when present." icon="$opt_dir/squashfs-root/resources/icons/app.png" install_linux_launcher_extras "$opt_dir/squashfs-root/AppRun" "$dest_bin" "$icon" atomic_write_integration_file \ "$desktop_dir/flinttrade.desktop" 0644 "$(linux_desktop_content "$wrapper")" write_shell_receipt \ "linux-extracted" "$opt_dir" "$opt_dir/squashfs-root/AppRun" \ "$wrapper" "$desktop_dir/flinttrade.desktop" "$INSTALLED_LINUX_ICON" commit_install_transaction say "Installed: $opt_dir (launcher: $wrapper)" if [ "$NO_LAUNCH" != "1" ]; then launch_linux_app "$wrapper"; fi } install_linux_appimage() { local appimage="$1" dest_bin="$HOME/.local/bin" desktop_dir="$HOME/.local/share/applications" local dest="$HOME/.local/bin/flinttrade.AppImage" canonical_parent canonical_target local extracted_root="" update_mode="fresh" has_appimage=0 has_extracted=0 if [ -n "${FLINTTRADE_UPDATE_HANDOFF:-}" ] && [ "$DRY_RUN" != "1" ]; then [ -n "${APPIMAGE:-}" ] && has_appimage=1 [ -n "${FLINTTRADE_UPDATE_LINUX_EXTRACTED_ROOT:-}" ] && has_extracted=1 [ $((has_appimage + has_extracted)) -eq 1 ] \ || die "The Linux updater requires exactly one exact target mode (AppImage or managed extracted root)." if [ "$has_appimage" = "1" ]; then case "$APPIMAGE" in /*) ;; *) die "The Linux updater requires the exact absolute running APPIMAGE path." ;; esac [ -f "$APPIMAGE" ] && [ ! -L "$APPIMAGE" ] \ || die "The Linux updater target is not an ordinary AppImage file." canonical_parent="$(cd -P "$(dirname "$APPIMAGE")" 2>/dev/null && pwd)" \ || die "The Linux updater target parent cannot be canonicalised." canonical_target="$canonical_parent/$(basename "$APPIMAGE")" [ "$canonical_target" = "$APPIMAGE" ] \ || die "The Linux updater target must already be canonical and free of symlink aliases." [ -w "$APPIMAGE" ] && [ -w "$canonical_parent" ] \ || die "The exact running AppImage is not writable; no update was signalled." dest="$APPIMAGE" update_mode="appimage" else extracted_root="$FLINTTRADE_UPDATE_LINUX_EXTRACTED_ROOT" case "$extracted_root" in /*/flinttrade) ;; *) die "The Linux updater requires the exact absolute managed extracted root." ;; esac [ -d "$extracted_root" ] && [ ! -L "$extracted_root" ] \ && [ -x "$extracted_root/squashfs-root/AppRun" ] \ || die "The Linux updater target is not an ordinary extracted FlintTrade shell." canonical_parent="$(cd -P "$(dirname "$extracted_root")" 2>/dev/null && pwd)" \ || die "The extracted Linux updater target parent cannot be canonicalised." canonical_target="$canonical_parent/flinttrade" [ "$canonical_target" = "$extracted_root" ] \ || die "The extracted Linux updater target must already be canonical and free of symlink aliases." [ -w "$extracted_root" ] && [ -w "$canonical_parent" ] \ || die "The exact extracted Linux shell is not writable; no update was signalled." update_mode="extracted" fi fi if [ "$DRY_RUN" = "1" ]; then say "DRY-RUN: would install $appimage to $dest" return 0 fi ensure_shell_receipt_storage ensure_owner_local_directory "$dest_bin" ensure_owner_local_directory "$desktop_dir" chmod 0755 "$appimage" if [ "$update_mode" = "extracted" ]; then install_linux_appimage_extracted "$appimage" "$dest_bin" "$desktop_dir" "$extracted_root" "$update_mode" return 0 fi if [ "$update_mode" = "fresh" ] && ! linux_fuse_available \ && install_linux_appimage_extracted "$appimage" "$dest_bin" "$desktop_dir" \ "$HOME/.local/opt/flinttrade" "$update_mode"; then return 0 fi local staged icon_tmp icon wrapper="$dest_bin/flinttrade" canonical_parent="$(cd -P "$(dirname "$dest")" 2>/dev/null && pwd -P)" \ || die "The Linux AppImage destination parent cannot be canonicalised." assert_existing_linux_target_owned "linux-appimage" "$dest" "$dest" "$update_mode" assert_no_other_linux_shell_target "$dest" preflight_linux_integrations "$dest" staged="$(mktemp "$canonical_parent/.flinttrade-appimage.XXXXXX")" \ || die "Could not create an adjacent AppImage staging file." TMP_DIRS+=("$staged") if ! install -m 0755 "$appimage" "$staged"; then die "Could not stage the verified AppImage beside its exact install target." fi if ! cmp -s "$appimage" "$staged"; then die "The staged AppImage does not match the verified release asset." fi signal_update_handoff begin_install_transaction publish_file_transactionally "$staged" "$dest" icon_tmp="$(mktemp -d "${TMPDIR:-/tmp}/flinttrade-icon.XXXXXX")" TMP_DIRS+=("$icon_tmp") (cd "$icon_tmp" && "$dest" --appimage-extract 'resources/icons/app.png' >/dev/null 2>&1) || true icon="$icon_tmp/squashfs-root/resources/icons/app.png" install_linux_launcher_extras "$dest" "$dest_bin" "$icon" atomic_write_integration_file \ "$desktop_dir/flinttrade.desktop" 0644 "$(linux_desktop_content "$wrapper")" write_shell_receipt \ "linux-appimage" "$dest" "$dest" "$wrapper" \ "$desktop_dir/flinttrade.desktop" "$INSTALLED_LINUX_ICON" commit_install_transaction say "Installed: $dest" if [ "$NO_LAUNCH" != "1" ]; then launch_linux_app "$wrapper"; fi } install_binary_release() { local os_name="$(uname -s)" package case "$os_name" in Darwin) download_release_asset mac universal dmg package="$DOWNLOADED_ASSET_PATH" install_macos_dmg "$package" ;; Linux) download_release_asset linux "$(normalised_arch)" AppImage package="$DOWNLOADED_ASSET_PATH" install_linux_appimage "$package" ;; *) die "Unsupported OS '$os_name'. On Windows, use install.ps1." ;; esac say "Done. First launch builds the verified local source checkout. Rerun this installer only for shell updates." } validate_source_origin() { [ -d "$SRC_DIR/.git" ] || return 0 local origin origin="$(git -C "$SRC_DIR" remote get-url origin 2>/dev/null || true)" case "$origin" in "$REPO_URL"|"${REPO_URL%.git}") ;; *) die "Refusing to update $SRC_DIR because origin is not the official HTTPS FlintTrade repository." ;; esac } validate_source_build_target() { local managed="$HOME/.flinttrade/src" source_path="$SRC_DIR" managed_path="$HOME/.flinttrade/src" case "$source_path" in "$managed"|"$managed"/*) die "Source-build mode refuses to mutate the managed active source tree." ;; esac if [ -d "$managed" ]; then managed_path="$(cd -P "$managed" && pwd)"; fi if [ -d "$source_path" ]; then source_path="$(cd -P "$source_path" && pwd)" elif [ -d "$(dirname "$source_path")" ]; then source_path="$(cd -P "$(dirname "$source_path")" && pwd)/$(basename "$source_path")" fi case "$source_path" in "$managed_path"|"$managed_path"/*) die "Source-build mode refuses to mutate the managed active source tree." ;; esac } check_source_prerequisites() { local missing=() node_version node_major node_minor node_header compiler need git || missing+=("Git from https://git-scm.com") if need node; then node_version="$(node -p 'process.versions.node')" node_major="${node_version%%.*}" node_minor="${node_version#*.}"; node_minor="${node_minor%%.*}" if [ "$node_major" -lt 22 ] || { [ "$node_major" -eq 22 ] && [ "$node_minor" -lt 12 ]; }; then missing+=("Node.js >= 22.12 (found v$node_version)") fi node_header="$(node -p 'require("path").resolve(require("path").dirname(process.execPath), "..", "include", "node", "node_api.h")')" [ -f "$node_header" ] || missing+=("Node.js N-API headers (missing $node_header)") else missing+=("Node.js >= 22.12") fi case "$(uname -s)" in Darwin) compiler=/usr/bin/clang ;; Linux) compiler=/usr/bin/cc ;; *) compiler= ;; esac [ -n "$compiler" ] && [ -x "$compiler" ] || missing+=("the platform C compiler required for atomic source promotion") if ! need corepack && ! need npx \ && { ! need pnpm || [ "$(pnpm --version 2>/dev/null)" != "$PINNED_PNPM_VERSION" ]; }; then missing+=("pnpm $PINNED_PNPM_VERSION through Corepack, npx, or a matching binary") fi if [ "${#missing[@]}" -gt 0 ]; then warn "Missing source-build prerequisites:" local hint for hint in "${missing[@]}"; do warn " - $hint"; done die "Install the tools above, then rerun with --build-from-source." fi } build_from_source() { local os_name="$(uname -s)" arch version package case "$os_name" in Darwin|Linux) ;; *) die "Unsupported OS '$os_name'. On Windows, use install.ps1." ;; esac say "Source-build mode packages only the Electron shell. Checking Git, Node, pnpm and the native compiler..." validate_source_build_target validate_source_origin check_source_prerequisites if [ "$DRY_RUN" = "1" ]; then say "DRY-RUN: would clone or update the pinned repository at ${REF:-the newest release tag}" say "DRY-RUN: would install the frozen JavaScript workspace and package only @flinttrade/desktop with pnpm $PINNED_PNPM_VERSION" return 0 fi if [ -z "$REF" ]; then say "Resolving newest release tag from the official repository..." REF="$(git ls-remote --tags --refs "$REPO_URL" 'v*' | awk -F/ '{print $NF}' \ | grep -E "$RELEASE_TAG_PATTERN" | filter_release_tags_for_channel | resolve_latest_tag)" [ -n "$REF" ] || die "Could not resolve a release tag from $REPO_URL" fi if [ -d "$SRC_DIR/.git" ]; then say "Updating trusted source workspace at $SRC_DIR..." git -C "$SRC_DIR" fetch --tags "$REPO_URL" git -C "$SRC_DIR" checkout --quiet "$REF" else say "Cloning FlintTrade ($REF) into $SRC_DIR..." mkdir -p "$(dirname "$SRC_DIR")" git clone --branch "$REF" --depth 1 "$REPO_URL" "$SRC_DIR" fi cd "$SRC_DIR" say "Installing the frozen JavaScript workspace with pnpm $PINNED_PNPM_VERSION..." pnpm_run install --frozen-lockfile version="${REF#v}" if [ "$os_name" = "Darwin" ]; then say "Packaging the universal Electron DMG..." pnpm_run --dir packages/apps/desktop run pack:mac package="$SRC_DIR/packages/apps/desktop/release/electron/FlintTrade-$version-mac-universal.dmg" [ -f "$package" ] || die "Electron packaging completed without $package" install_macos_dmg "$package" else arch="$(normalised_arch)" say "Packaging the Electron AppImage for $arch..." pnpm_run --dir packages/apps/desktop run "pack:linux:$arch" package="$SRC_DIR/packages/apps/desktop/release/electron/FlintTrade-$version-linux-$arch.AppImage" [ -f "$package" ] || die "Electron packaging completed without $package" install_linux_appimage "$package" fi } if [ "${FLINTTRADE_RESOLVE_TAGS_ONLY:-0}" = "1" ]; then filter_release_tags_for_channel | resolve_latest_tag exit 0 fi if [ "$BUILD_FROM_SOURCE" = "1" ]; then build_from_source else install_binary_release fi