Files
APIClient-Agent/build_installer.sh
2026-03-28 18:24:08 +05:30

259 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# build_installer.sh - Build APIClient-Agent standalone distribution
#
# Usage:
# ./build_installer.sh [--onefile] [--clean] [--deb] [--dmg]
#
# Options:
# --onefile Bundle into a single executable (larger, slower start) instead
# of the default one-directory layout (recommended for Linux/Mac)
# --clean Remove previous dist/ and build/ before building
# --deb Also build a .deb package after the build (requires fpm)
# --dmg Also build a .dmg image after the build (macOS only, requires
# create-dmg: brew install create-dmg)
#
# Requirements:
# Python 3.11+, a virtual environment at ./venv, and all pip deps installed.
# PyInstaller is installed automatically from requirements.txt.
# =============================================================================
set -euo pipefail
APP_NAME="APIClient-Agent"
APP_VERSION="2.0.0"
APP_BUNDLE_ID="co.ekika.apiclient-agent"
MAIN_SCRIPT="main.py"
ICON_PNG="assets/app_logo.png"
ICON_ICO="assets/app_logo.ico"
ICON_ICNS="assets/app_logo.icns"
PYTHON="${PYTHON:-venv/bin/python}"
PIP="${PIP:-venv/bin/pip}"
# ── Argument parsing ──────────────────────────────────────────────────────────
OPT_ONEFILE=false
OPT_CLEAN=false
OPT_DEB=false
OPT_DMG=false
for arg in "$@"; do
case $arg in
--onefile) OPT_ONEFILE=true ;;
--clean) OPT_CLEAN=true ;;
--deb) OPT_DEB=true ;;
--dmg) OPT_DMG=true ;;
*)
echo "Unknown option: $arg"
echo "Usage: $0 [--onefile] [--clean] [--deb] [--dmg]"
exit 1
;;
esac
done
# ── Helpers ───────────────────────────────────────────────────────────────────
info() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
ok() { echo -e "\033[1;32m[ OK ]\033[0m $*"; }
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
die() { echo -e "\033[1;31m[FAIL]\033[0m $*" >&2; exit 1; }
# ── Platform detection ────────────────────────────────────────────────────────
OS="$(uname -s)"
case "$OS" in
Linux*) PLATFORM=linux ;;
Darwin*) PLATFORM=macos ;;
MINGW*|CYGWIN*|MSYS*) PLATFORM=windows ;;
*) die "Unsupported platform: $OS" ;;
esac
info "Platform: $PLATFORM"
# ── Preflight checks ──────────────────────────────────────────────────────────
[[ -f "$MAIN_SCRIPT" ]] || die "$MAIN_SCRIPT not found. Run from the project root."
[[ -d "venv" ]] || die "Virtual environment 'venv/' not found. Create it first:
python3 -m venv venv && venv/bin/pip install -r requirements.txt"
[[ -f "requirements.txt" ]] || die "requirements.txt not found."
info "Python: $($PYTHON --version)"
# ── Clean ─────────────────────────────────────────────────────────────────────
if $OPT_CLEAN; then
info "Cleaning previous build artifacts..."
rm -rf dist/ build/ "${APP_NAME}.spec"
ok "Cleaned."
fi
# ── Install / upgrade dependencies ───────────────────────────────────────────
info "Installing/verifying dependencies..."
$PIP install -q -r requirements.txt
ok "Dependencies ready."
# ── Prepare icon ─────────────────────────────────────────────────────────────
# PyInstaller needs .ico on Windows, .icns on macOS, .png on Linux.
ICON_ARG=""
if [[ "$PLATFORM" == "windows" ]]; then
if [[ -f "$ICON_ICO" ]]; then
ICON_ARG="--icon=$ICON_ICO"
else
warn ".ico icon not found at $ICON_ICO - building without icon."
fi
elif [[ "$PLATFORM" == "macos" ]]; then
if [[ -f "$ICON_ICNS" ]]; then
ICON_ARG="--icon=$ICON_ICNS"
elif [[ -f "$ICON_PNG" ]]; then
# Convert PNG -> ICNS using sips + iconutil if available
if command -v sips &>/dev/null && command -v iconutil &>/dev/null; then
info "Converting PNG to ICNS..."
ICONSET_DIR="/tmp/${APP_NAME}.iconset"
mkdir -p "$ICONSET_DIR"
for sz in 16 32 64 128 256 512; do
sips -z $sz $sz "$ICON_PNG" --out "$ICONSET_DIR/icon_${sz}x${sz}.png" &>/dev/null
sips -z $((sz*2)) $((sz*2)) "$ICON_PNG" --out "$ICONSET_DIR/icon_${sz}x${sz}@2x.png" &>/dev/null
done
iconutil -c icns "$ICONSET_DIR" -o "$ICON_ICNS" && ok "ICNS created."
ICON_ARG="--icon=$ICON_ICNS"
else
warn "sips/iconutil not available; building without .icns icon."
fi
fi
elif [[ "$PLATFORM" == "linux" ]]; then
[[ -f "$ICON_PNG" ]] && ICON_ARG="--icon=$ICON_PNG" || warn "PNG icon not found."
fi
# ── PyInstaller arguments ─────────────────────────────────────────────────────
PYINSTALLER_ARGS=(
$([[ $OPT_ONEFILE == true ]] && echo "--onefile" || echo "--onedir")
"--windowed"
"--name" "$APP_NAME"
"--add-data" "app:app"
"--add-data" "assets:assets"
"--clean"
"--noconfirm"
)
[[ -n "$ICON_ARG" ]] && PYINSTALLER_ARGS+=("$ICON_ARG")
# macOS bundle metadata
if [[ "$PLATFORM" == "macos" ]]; then
PYINSTALLER_ARGS+=(
"--osx-bundle-identifier" "$APP_BUNDLE_ID"
)
fi
# Hidden imports that PyInstaller may miss
PYINSTALLER_ARGS+=(
"--hidden-import" "PyQt6.QtSvg"
"--hidden-import" "PyQt6.QtNetwork"
"--hidden-import" "httpx._transports.default"
"--hidden-import" "websockets.legacy.client"
"--hidden-import" "anthropic"
)
PYINSTALLER_ARGS+=("$MAIN_SCRIPT")
# ── Build ─────────────────────────────────────────────────────────────────────
info "Running PyInstaller..."
$PYTHON -m PyInstaller "${PYINSTALLER_ARGS[@]}"
# ── Verify output ─────────────────────────────────────────────────────────────
if [[ $OPT_ONEFILE == true ]]; then
BIN="dist/${APP_NAME}"
[[ "$PLATFORM" == "windows" ]] && BIN="${BIN}.exe"
[[ -f "$BIN" ]] || die "Build failed: expected $BIN not found."
ok "Executable: $BIN ($(du -sh "$BIN" | cut -f1))"
else
OUT_DIR="dist/${APP_NAME}"
[[ -d "$OUT_DIR" ]] || die "Build failed: expected $OUT_DIR not found."
ok "Distribution directory: $OUT_DIR ($(du -sh "$OUT_DIR" | cut -f1))"
fi
# ── .deb package ─────────────────────────────────────────────────────────────
if $OPT_DEB; then
if [[ "$PLATFORM" != "linux" ]]; then
warn ".deb packaging is only supported on Linux. Skipping."
elif ! command -v fpm &>/dev/null; then
warn "fpm not found. Install it with: gem install fpm
Then re-run with --deb."
else
info "Building .deb package..."
DEB_NAME="apiclient-agent_${APP_VERSION}_amd64.deb"
# Write a post-install script that creates a desktop entry
POSTINST=$(mktemp)
cat > "$POSTINST" <<'EOF'
#!/bin/bash
cat > /usr/share/applications/apiclient-agent.desktop <<DESK
[Desktop Entry]
Name=APIClient - Agent
Comment=AI-first API testing client
Exec=/opt/apiclient-agent/APIClient-Agent
Icon=/opt/apiclient-agent/_internal/assets/app_logo.png
Terminal=false
Type=Application
Categories=Development;Network;
Keywords=API;REST;HTTP;AI;
DESK
update-desktop-database /usr/share/applications 2>/dev/null || true
EOF
chmod +x "$POSTINST"
fpm \
--input-type dir \
--output-type deb \
--name "apiclient-agent" \
--version "$APP_VERSION" \
--architecture amd64 \
--maintainer "EKIKA.co <hello@ekika.co>" \
--description "APIClient - Agent: AI-first API testing desktop client (Python/PyQt6)" \
--url "https://git.ekika.co/EKIKA.co/APIClient-Agent" \
--license "MIT" \
--category "Development" \
--after-install "$POSTINST" \
--package "$DEB_NAME" \
"dist/${APP_NAME}/=/opt/apiclient-agent"
rm -f "$POSTINST"
[[ -f "$DEB_NAME" ]] && ok ".deb package: $DEB_NAME ($(du -sh "$DEB_NAME" | cut -f1))" \
|| warn ".deb build may have failed."
fi
fi
# ── .dmg image (macOS) ───────────────────────────────────────────────────────
if $OPT_DMG; then
if [[ "$PLATFORM" != "macos" ]]; then
warn ".dmg packaging is only supported on macOS. Skipping."
elif ! command -v create-dmg &>/dev/null; then
warn "create-dmg not found. Install it with: brew install create-dmg
Then re-run with --dmg."
else
info "Building .dmg image..."
DMG_NAME="${APP_NAME}-${APP_VERSION}.dmg"
APP_BUNDLE="dist/${APP_NAME}.app"
[[ -d "$APP_BUNDLE" ]] || die ".app bundle not found at $APP_BUNDLE"
create-dmg \
--volname "$APP_NAME $APP_VERSION" \
--window-pos 200 120 \
--window-size 600 400 \
--icon-size 100 \
--icon "${APP_NAME}.app" 175 190 \
--hide-extension "${APP_NAME}.app" \
--app-drop-link 425 190 \
"$DMG_NAME" \
"$APP_BUNDLE"
[[ -f "$DMG_NAME" ]] && ok ".dmg image: $DMG_NAME ($(du -sh "$DMG_NAME" | cut -f1))" \
|| warn ".dmg build may have failed."
fi
fi
# ── Summary ───────────────────────────────────────────────────────────────────
echo ""
echo " ┌─────────────────────────────────────────────┐"
printf " │ %-43s│\n" "${APP_NAME} v${APP_VERSION} build complete"
printf " │ Platform : %-31s│\n" "$PLATFORM"
if [[ $OPT_ONEFILE == true ]]; then
printf " │ Output : %-31s│\n" "dist/${APP_NAME}"
else
printf " │ Output : %-31s│\n" "dist/${APP_NAME}/"
fi
echo " └─────────────────────────────────────────────┘"
echo ""