scripts/install/flinttrade-install.sh
Rama main · réplica de lectura del repositorio original.
#!/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 <tag> Install an exact release tag
--build-from-source Package the Electron shell from a trusted checkout
--src <dir> 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