#!/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 </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 " \ --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 ""