From aff03a2e9bdf13474010a70423129c634e8f8588 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 1 Feb 2026 22:51:36 +0100 Subject: [PATCH] feat: add multi-platform packaging and auto-update infrastructure - Add PyInstaller configuration (clide.spec) for cross-platform builds - Add build scripts for macOS (DMG), Linux (AppImage), and Windows (Inno Setup) - Add Gitea Actions CI/CD workflow for automated releases - Add auto-update service with Gitea API integration - Add 'clide update' CLI command for checking and installing updates Packaging outputs: - macOS: Signed/notarized .app bundle in DMG - Linux: Portable AppImage (glibc 2.17+) - Windows: Inno Setup installer Co-Authored-By: Claude Opus 4.5 --- .gitea/workflows/release.yml | 126 +++++++++++ .gitignore | 1 + Makefile | 46 +++- clide.spec | 149 +++++++++++++ clide/cli.py | 51 +++++ clide/services/update_service.py | 359 +++++++++++++++++++++++++++++++ packaging/clide.desktop | 11 + packaging/entitlements.plist | 11 + scripts/build-linux.sh | 85 ++++++++ scripts/build-macos.sh | 101 +++++++++ scripts/build-windows.ps1 | 102 +++++++++ 11 files changed, 1032 insertions(+), 10 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 clide.spec create mode 100644 clide/services/update_service.py create mode 100644 packaging/clide.desktop create mode 100644 packaging/entitlements.plist create mode 100755 scripts/build-linux.sh create mode 100755 scripts/build-macos.sh create mode 100644 scripts/build-windows.ps1 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 00000000..e17e548b --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,126 @@ +# Gitea Actions workflow for building and releasing Clide +# Triggers on version tags (v*) +# Builds Linux AppImage and Windows installer automatically +# macOS must be built locally (no macOS runners) + +name: Build and Release + +on: + push: + tags: + - 'v*' + +env: + PYTHON_VERSION: '3.12' + +jobs: + # Build Linux AppImage + build-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[build]" + + - name: Install FUSE (for appimagetool) + run: | + sudo apt-get update + sudo apt-get install -y fuse libfuse2 + + - name: Build AppImage + run: | + chmod +x scripts/build-linux.sh + ./scripts/build-linux.sh ${{ github.ref_name }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: linux-appimage + path: dist/*.AppImage + retention-days: 5 + + # Build Windows installer + build-windows: + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[build]" + + - name: Install Inno Setup + run: choco install innosetup -y + + - name: Build installer + run: .\scripts\build-windows.ps1 -Version ${{ github.ref_name }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: dist/*-setup.exe + retention-days: 5 + + # Create GitHub/Gitea release with all artifacts + create-release: + needs: [build-linux, build-windows] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download Linux artifact + uses: actions/download-artifact@v4 + with: + name: linux-appimage + path: artifacts/linux + + - name: Download Windows artifact + uses: actions/download-artifact@v4 + with: + name: windows-installer + path: artifacts/windows + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/linux/*.AppImage + artifacts/windows/*-setup.exe + draft: true + generate_release_notes: true + body: | + ## Installation + + ### Linux (AppImage) + ```bash + chmod +x Clide-*-linux-*.AppImage + ./Clide-*-linux-*.AppImage + ``` + + ### Windows + Download and run the installer. For best experience, use Windows Terminal. + + ### macOS + macOS builds are created manually. See releases for DMG when available. + + ## Requirements + - **Linux**: glibc 2.17+ (Ubuntu 18.04+, Fedora 25+, etc.) + - **Windows**: Windows 10+ with Windows Terminal recommended + - **macOS**: macOS 10.13+ (when available) diff --git a/.gitignore b/.gitignore index 4b0218ca..44af8b2b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ MANIFEST # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec +!clide.spec # Installer logs pip-log.txt diff --git a/Makefile b/Makefile index 671f52be..b628b823 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ -.PHONY: setup run test test-single typecheck lint format build build-all clean help +.PHONY: setup run test test-single typecheck lint format build build-macos build-linux build-windows build-all clean help PYTHON := python3.12 VENV := .venv BIN := $(VENV)/bin TEST ?= tests/ +VERSION ?= 1.0.0 # Colors for output BLUE := \033[0;34m @@ -13,15 +14,21 @@ RESET := \033[0m help: @echo "$(BLUE)Clide Development Commands$(RESET)" @echo "" - @echo "$(GREEN)setup$(RESET) Create venv and install dependencies" - @echo "$(GREEN)run$(RESET) Run the application" - @echo "$(GREEN)test$(RESET) Run all tests" - @echo "$(GREEN)test-single$(RESET) Run single test (TEST=path::test_name)" - @echo "$(GREEN)typecheck$(RESET) Run mypy type checking" - @echo "$(GREEN)lint$(RESET) Run ruff linter" - @echo "$(GREEN)format$(RESET) Run ruff formatter" - @echo "$(GREEN)build$(RESET) Build executable for current platform" - @echo "$(GREEN)clean$(RESET) Remove build artifacts and caches" + @echo "$(GREEN)setup$(RESET) Create venv and install dependencies" + @echo "$(GREEN)run$(RESET) Run the application" + @echo "$(GREEN)test$(RESET) Run all tests" + @echo "$(GREEN)test-single$(RESET) Run single test (TEST=path::test_name)" + @echo "$(GREEN)typecheck$(RESET) Run mypy type checking" + @echo "$(GREEN)lint$(RESET) Run ruff linter" + @echo "$(GREEN)format$(RESET) Run ruff formatter" + @echo "$(GREEN)build$(RESET) Build executable for current platform" + @echo "$(GREEN)clean$(RESET) Remove build artifacts and caches" + @echo "" + @echo "$(BLUE)Distribution Builds$(RESET)" + @echo "" + @echo "$(GREEN)build-macos$(RESET) Build macOS DMG (VERSION=x.x.x)" + @echo "$(GREEN)build-linux$(RESET) Build Linux AppImage (VERSION=x.x.x)" + @echo "$(GREEN)build-windows$(RESET) Build Windows installer (VERSION=x.x.x)" setup: @echo "Creating virtual environment..." @@ -93,3 +100,22 @@ ci-test: ci-build: pip install -e ".[build]" pyinstaller clide.spec --clean + +# Distribution builds +build-macos: + @echo "$(BLUE)Building macOS distribution...$(RESET)" + ./scripts/build-macos.sh $(VERSION) + +build-linux: + @echo "$(BLUE)Building Linux AppImage...$(RESET)" + ./scripts/build-linux.sh $(VERSION) + +build-windows: + @echo "$(BLUE)Building Windows installer...$(RESET)" + powershell -ExecutionPolicy Bypass -File scripts/build-windows.ps1 -Version $(VERSION) + +build-all: build-linux + @echo "" + @echo "$(GREEN)Linux build complete.$(RESET)" + @echo "$(BLUE)Note:$(RESET) macOS build requires: make build-macos VERSION=$(VERSION)" + @echo "$(BLUE)Note:$(RESET) Windows build requires Windows: make build-windows VERSION=$(VERSION)" diff --git a/clide.spec b/clide.spec new file mode 100644 index 00000000..496a8a91 --- /dev/null +++ b/clide.spec @@ -0,0 +1,149 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller spec file for Clide. + +Cross-platform configuration that produces: +- macOS: .app bundle (universal2 for Intel + Apple Silicon) +- Linux: Single executable (for AppImage packaging) +- Windows: Single executable (for Inno Setup packaging) +""" + +import sys + +block_cipher = None + +# Collect data files +datas = [ + ('clide/templates', 'clide/templates'), # Skill templates (SKILL.md files) +] + +# Hidden imports for dynamic modules +hiddenimports = [ + # Textual internals + 'textual._context', + 'textual.css', + # Tree-sitter grammars + 'tree_sitter_python', + 'tree_sitter_javascript', + 'tree_sitter_typescript', + 'tree_sitter_html', + 'tree_sitter_css', + 'tree_sitter_json', + 'tree_sitter_yaml', + 'tree_sitter_toml', + 'tree_sitter_markdown', + 'tree_sitter_rust', + 'tree_sitter_go', + 'tree_sitter_java', + 'tree_sitter_bash', + # Watchdog backends + 'watchdog.observers.fsevents', # macOS + 'watchdog.observers.inotify', # Linux + 'watchdog.observers.read_directory_changes', # Windows + # Pydantic + 'pydantic', + 'pydantic_settings', + # Vendored pyte + 'clide.vendor.pyte', +] + +# Exclude dev/test dependencies +excludes = [ + 'pytest', + 'mypy', + 'ruff', + 'pre_commit', + 'pytest_asyncio', + 'pytest_textual_snapshot', + 'pytest_cov', +] + +a = Analysis( + ['clide/__main__.py'], + pathex=[], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=excludes, + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure, cipher=block_cipher) + +# Platform-specific executable settings +if sys.platform == 'darwin': + # macOS: Create .app bundle + # Use CLIDE_UNIVERSAL=1 env var for universal binary (requires fat Python) + import os + target_arch = 'universal2' if os.environ.get('CLIDE_UNIVERSAL') else None + + exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='clide', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, # UPX breaks macOS code signing + console=True, + target_arch=target_arch, + ) + coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + name='Clide', + ) + app = BUNDLE( + coll, + name='Clide.app', + icon=None, # TODO: Add icon.icns + bundle_identifier='net.schweitz.clide', + info_plist={ + 'CFBundleShortVersionString': '1.0.0', + 'CFBundleName': 'Clide', + 'CFBundleDisplayName': 'Clide', + 'NSHighResolutionCapable': True, + 'LSEnvironment': { + 'TERM': 'xterm-256color', + }, + }, + ) +elif sys.platform == 'win32': + # Windows: Single executable + exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='clide', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + icon=None, # TODO: Add icon.ico + ) +else: + # Linux: Single executable (for AppImage packaging) + exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='clide', + debug=False, + bootloader_ignore_signals=False, + strip=True, + upx=True, + console=True, + ) diff --git a/clide/cli.py b/clide/cli.py index b0c85eba..d527c548 100644 --- a/clide/cli.py +++ b/clide/cli.py @@ -52,3 +52,54 @@ def config() -> None: def extensions() -> None: """List installed extensions.""" typer.echo("Extension manager not yet implemented") + + +@app.command() +def update( + check_only: Annotated[ + bool, + typer.Option("--check", "-c", help="Only check for updates, don't install"), + ] = False, + force: Annotated[ + bool, + typer.Option("--force", "-f", help="Force update even if already on latest"), + ] = False, +) -> None: + """Check for and install updates. + + Updates are downloaded from git.schweitz.net releases. + User settings in ~/.clide/ are preserved across updates. + """ + _ = force # TODO: Implement force update functionality + from clide.services.update_service import check_for_updates, perform_update + + typer.echo(f"Current version: {__version__}") + typer.echo("Checking for updates...") + + if check_only: + result = check_for_updates() + if result.error: + typer.echo(f"Error: {result.error}", err=True) + raise typer.Exit(1) + + if result.update_available: + typer.echo(f"Update available: {result.latest_version}") + if result.release_info and result.release_info.release_notes: + typer.echo("\nRelease notes:") + typer.echo(result.release_info.release_notes[:500]) + else: + typer.echo("Already running the latest version.") + return + + # Perform update with progress indication + def progress_callback(downloaded: int, total: int) -> None: + if total > 0: + pct = (downloaded / total) * 100 + typer.echo(f"\rDownloading: {pct:.1f}%", nl=False) + + success, message = perform_update(progress_callback) + typer.echo("") # Newline after progress + typer.echo(message) + + if not success: + raise typer.Exit(1) diff --git a/clide/services/update_service.py b/clide/services/update_service.py new file mode 100644 index 00000000..7117c8e7 --- /dev/null +++ b/clide/services/update_service.py @@ -0,0 +1,359 @@ +"""Auto-update service for Clide. + +Checks for updates from the release server and handles self-updating. +User settings in ~/.clide/ are preserved across updates. +""" + +from __future__ import annotations + +import json +import logging +import os +import platform +import shutil +import stat +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from urllib.error import URLError +from urllib.request import Request, urlopen + +logger = logging.getLogger(__name__) + +# Update server configuration +UPDATE_SERVER = "https://git.schweitz.net" +REPO_OWNER = "jeroen" # TODO: Update with actual owner +REPO_NAME = "clide" # TODO: Update with actual repo name + +# Current version (injected at build time or read from package) +try: + from clide import __version__ as CURRENT_VERSION +except ImportError: + CURRENT_VERSION = "0.0.0" + + +@dataclass +class ReleaseInfo: + """Information about a release.""" + + version: str + tag_name: str + download_url: str + release_notes: str + published_at: str + + +@dataclass +class UpdateCheckResult: + """Result of checking for updates.""" + + update_available: bool + current_version: str + latest_version: str | None + release_info: ReleaseInfo | None + error: str | None = None + + +def get_platform_asset_name() -> str: + """Get the expected asset name for the current platform.""" + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "darwin": + return "macos.dmg" + elif system == "linux": + # Normalize architecture names + if machine in ("x86_64", "amd64"): + arch = "x86_64" + elif machine in ("aarch64", "arm64"): + arch = "aarch64" + else: + arch = machine + return f"linux-{arch}.AppImage" + elif system == "windows": + return "windows-setup.exe" + else: + raise RuntimeError(f"Unsupported platform: {system}") + + +def parse_version(version: str) -> tuple[int, ...]: + """Parse a version string into a tuple for comparison.""" + # Remove 'v' prefix if present + version = version.lstrip("v") + # Split and convert to integers + parts = [] + for part in version.split("."): + # Handle versions like "1.0.0-beta" + num_part = "" + for char in part: + if char.isdigit(): + num_part += char + else: + break + parts.append(int(num_part) if num_part else 0) + return tuple(parts) + + +def is_newer_version(current: str, latest: str) -> bool: + """Check if latest version is newer than current.""" + return parse_version(latest) > parse_version(current) + + +def check_for_updates() -> UpdateCheckResult: + """Check the release server for available updates. + + Returns: + UpdateCheckResult with update status and release info. + """ + try: + # Gitea API endpoint for releases + api_url = f"{UPDATE_SERVER}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest" + + request = Request(api_url) + request.add_header("Accept", "application/json") + request.add_header("User-Agent", f"Clide/{CURRENT_VERSION}") + + with urlopen(request, timeout=10) as response: + data = json.loads(response.read().decode("utf-8")) + + tag_name = data.get("tag_name", "") + latest_version = tag_name.lstrip("v") + + # Find the download URL for current platform + platform_asset = get_platform_asset_name() + download_url = None + + for asset in data.get("assets", []): + if platform_asset in asset.get("name", ""): + download_url = asset.get("browser_download_url") + break + + if not download_url: + # Try constructing URL from release + download_url = f"{UPDATE_SERVER}/{REPO_OWNER}/{REPO_NAME}/releases/download/{tag_name}/Clide-{tag_name}-{platform_asset}" + + release_info = ReleaseInfo( + version=latest_version, + tag_name=tag_name, + download_url=download_url, + release_notes=data.get("body", ""), + published_at=data.get("published_at", ""), + ) + + update_available = is_newer_version(CURRENT_VERSION, latest_version) + + return UpdateCheckResult( + update_available=update_available, + current_version=CURRENT_VERSION, + latest_version=latest_version, + release_info=release_info, + ) + + except URLError as e: + logger.error(f"Failed to check for updates: {e}") + return UpdateCheckResult( + update_available=False, + current_version=CURRENT_VERSION, + latest_version=None, + release_info=None, + error=f"Network error: {e.reason}", + ) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse update response: {e}") + return UpdateCheckResult( + update_available=False, + current_version=CURRENT_VERSION, + latest_version=None, + release_info=None, + error="Invalid response from update server", + ) + except Exception as e: + logger.error(f"Unexpected error checking for updates: {e}") + return UpdateCheckResult( + update_available=False, + current_version=CURRENT_VERSION, + latest_version=None, + release_info=None, + error=str(e), + ) + + +def download_update(release_info: ReleaseInfo, progress_callback=None) -> Path: + """Download the update to a temporary location. + + Args: + release_info: Release information with download URL. + progress_callback: Optional callback(bytes_downloaded, total_bytes). + + Returns: + Path to the downloaded file. + + Raises: + RuntimeError: If download fails. + """ + try: + request = Request(release_info.download_url) + request.add_header("User-Agent", f"Clide/{CURRENT_VERSION}") + + # Create temp file with appropriate extension + suffix = Path(release_info.download_url).suffix or "" + fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix="clide_update_") + os.close(fd) + + with urlopen(request, timeout=300) as response: + total_size = int(response.headers.get("Content-Length", 0)) + downloaded = 0 + chunk_size = 8192 + + with open(temp_path, "wb") as f: + while True: + chunk = response.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if progress_callback: + progress_callback(downloaded, total_size) + + return Path(temp_path) + + except Exception as e: + logger.error(f"Failed to download update: {e}") + raise RuntimeError(f"Download failed: {e}") from e + + +def get_executable_path() -> Path: + """Get the path to the current executable.""" + if getattr(sys, "frozen", False): + # Running as compiled executable + return Path(sys.executable) + else: + # Running as Python script + return Path(sys.argv[0]).resolve() + + +def apply_update(downloaded_file: Path) -> bool: + """Apply the downloaded update. + + This replaces the current executable with the new version. + On macOS/Linux, this can happen while the app is running. + On Windows, we need to use a helper script. + + Args: + downloaded_file: Path to the downloaded update file. + + Returns: + True if update was applied successfully. + """ + system = platform.system().lower() + current_exe = get_executable_path() + + try: + if system == "darwin": + # macOS: For DMG, just inform user to install manually + # For direct binary updates, we can replace in-place + if downloaded_file.suffix == ".dmg": + logger.info(f"DMG downloaded to: {downloaded_file}") + return True # User needs to install manually + + # Direct binary replacement + backup_path = current_exe.with_suffix(".backup") + shutil.copy2(current_exe, backup_path) + shutil.copy2(downloaded_file, current_exe) + current_exe.chmod( + current_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH, + ) + backup_path.unlink() + return True + + elif system == "linux": + # Linux: AppImage can be replaced directly + if downloaded_file.suffix == ".AppImage": + backup_path = current_exe.with_suffix(".backup") + shutil.copy2(current_exe, backup_path) + shutil.copy2(downloaded_file, current_exe) + current_exe.chmod( + current_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH, + ) + backup_path.unlink() + return True + return False + + elif system == "windows": + # Windows: Can't replace running executable directly + # Create a batch script to replace after exit + batch_script = current_exe.parent / "update_clide.bat" + new_exe = downloaded_file + + script_content = f"""@echo off +echo Updating Clide... +timeout /t 2 /nobreak >nul +copy /y "{new_exe}" "{current_exe}" +del "{new_exe}" +del "%~f0" +echo Update complete! +""" + batch_script.write_text(script_content) + logger.info(f"Update script created: {batch_script}") + logger.info("Please restart Clide to complete the update.") + return True + + return False + + except Exception as e: + logger.error(f"Failed to apply update: {e}") + return False + finally: + # Clean up downloaded file if it still exists and wasn't moved + if downloaded_file.exists() and system != "windows": + try: + downloaded_file.unlink() + except Exception: + pass + + +def perform_update(progress_callback=None) -> tuple[bool, str]: + """Check for and perform an update. + + Args: + progress_callback: Optional callback for download progress. + + Returns: + Tuple of (success, message). + """ + # Check for updates + result = check_for_updates() + + if result.error: + return False, f"Failed to check for updates: {result.error}" + + if not result.update_available: + return True, f"Already running the latest version ({result.current_version})" + + if not result.release_info: + return False, "No release information available" + + # Download update + try: + downloaded_file = download_update(result.release_info, progress_callback) + except RuntimeError as e: + return False, str(e) + + # Apply update + if apply_update(downloaded_file): + system = platform.system().lower() + if system == "windows": + return True, f"Update to {result.latest_version} downloaded. Restart Clide to complete." + elif system == "darwin" and downloaded_file.suffix == ".dmg": + return ( + True, + f"Update {result.latest_version} downloaded to {downloaded_file}. Please install manually.", + ) + else: + return ( + True, + f"Updated to {result.latest_version}. Restart Clide to use the new version.", + ) + else: + return False, "Failed to apply update" diff --git a/packaging/clide.desktop b/packaging/clide.desktop new file mode 100644 index 00000000..e0254389 --- /dev/null +++ b/packaging/clide.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=Clide +Comment=TUI IDE for Claude Code +GenericName=IDE +Exec=clide +Icon=clide +Type=Application +Categories=Development;IDE;TextEditor; +Keywords=claude;code;ai;ide;terminal; +Terminal=true +StartupNotify=false diff --git a/packaging/entitlements.plist b/packaging/entitlements.plist new file mode 100644 index 00000000..eda1e559 --- /dev/null +++ b/packaging/entitlements.plist @@ -0,0 +1,11 @@ + + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh new file mode 100755 index 00000000..203cda0e --- /dev/null +++ b/scripts/build-linux.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Build script for Linux AppImage distribution +# Creates a portable AppImage that runs on most Linux distributions +# +# Prerequisites: +# - wget (for downloading appimagetool) +# - FUSE (for running appimagetool) +# +# Usage: ./scripts/build-linux.sh [VERSION] + +set -e + +VERSION="${1:-1.0.0}" +ARCH=$(uname -m) + +echo "========================================" +echo "Building Clide $VERSION for Linux ($ARCH)" +echo "========================================" + +# Ensure we're in the project root +cd "$(dirname "$0")/.." + +# Build with PyInstaller +echo "Building with PyInstaller..." +pyinstaller clide.spec --clean --noconfirm + +# Create AppDir structure +echo "Creating AppDir structure..." +rm -rf dist/Clide.AppDir +mkdir -p dist/Clide.AppDir/usr/bin +mkdir -p dist/Clide.AppDir/usr/share/applications +mkdir -p dist/Clide.AppDir/usr/share/icons/hicolor/256x256/apps + +# Copy binary +cp dist/clide dist/Clide.AppDir/usr/bin/ + +# Copy desktop file +cp packaging/clide.desktop dist/Clide.AppDir/ +cp packaging/clide.desktop dist/Clide.AppDir/usr/share/applications/ + +# Create placeholder icon (TODO: Replace with actual icon) +# For now, create a simple placeholder +if [ ! -f packaging/clide.png ]; then + echo "Note: No icon found at packaging/clide.png, AppImage will have no icon" +else + cp packaging/clide.png dist/Clide.AppDir/clide.png + cp packaging/clide.png dist/Clide.AppDir/usr/share/icons/hicolor/256x256/apps/clide.png +fi + +# Create AppRun launcher +cat > dist/Clide.AppDir/AppRun << 'EOF' +#!/bin/bash +# AppRun - Entry point for AppImage +SELF=$(readlink -f "$0") +HERE=${SELF%/*} +export PATH="${HERE}/usr/bin:${PATH}" +export TERM="${TERM:-xterm-256color}" +exec "${HERE}/usr/bin/clide" "$@" +EOF +chmod +x dist/Clide.AppDir/AppRun + +# Download appimagetool if needed +APPIMAGETOOL="appimagetool-${ARCH}.AppImage" +if [ ! -f "$APPIMAGETOOL" ]; then + echo "Downloading appimagetool..." + wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${ARCH}.AppImage" \ + -O "$APPIMAGETOOL" + chmod +x "$APPIMAGETOOL" +fi + +# Create AppImage +echo "Creating AppImage..." +rm -f "dist/Clide-$VERSION-linux-$ARCH.AppImage" +ARCH=$ARCH ./"$APPIMAGETOOL" dist/Clide.AppDir "dist/Clide-$VERSION-linux-$ARCH.AppImage" + +# Clean up AppDir +rm -rf dist/Clide.AppDir + +echo "" +echo "========================================" +echo "Build complete!" +echo "Output: dist/Clide-$VERSION-linux-$ARCH.AppImage" +echo "" +echo "To run: chmod +x dist/Clide-$VERSION-linux-$ARCH.AppImage && ./dist/Clide-$VERSION-linux-$ARCH.AppImage" +echo "========================================" diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh new file mode 100755 index 00000000..2987e06c --- /dev/null +++ b/scripts/build-macos.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Build script for macOS distribution +# Creates a signed and notarized DMG installer +# +# Prerequisites: +# - Xcode Command Line Tools: xcode-select --install +# - create-dmg: brew install create-dmg +# - Apple Developer ID certificate installed in Keychain +# - Keychain profile for notarytool: xcrun notarytool store-credentials +# +# Usage: ./scripts/build-macos.sh [VERSION] + +set -e + +VERSION="${1:-1.0.0}" +DEVELOPER_ID="${DEVELOPER_ID:-Developer ID Application: Your Name (TEAMID)}" +KEYCHAIN_PROFILE="${KEYCHAIN_PROFILE:-AC_PASSWORD}" + +echo "========================================" +echo "Building Clide $VERSION for macOS" +echo "========================================" + +# Ensure we're in the project root +cd "$(dirname "$0")/.." + +# Check for required tools +if ! command -v create-dmg &> /dev/null; then + echo "Error: create-dmg not found. Install with: brew install create-dmg" + exit 1 +fi + +# Build with PyInstaller +echo "Building with PyInstaller..." +pyinstaller clide.spec --clean --noconfirm + +# Check if signing is configured +if [[ "$DEVELOPER_ID" == *"Your Name"* ]]; then + echo "" + echo "Warning: DEVELOPER_ID not configured. Skipping code signing." + echo "To enable signing, set DEVELOPER_ID environment variable." + echo "" + SKIP_SIGNING=true +else + SKIP_SIGNING=false +fi + +if [[ "$SKIP_SIGNING" == "false" ]]; then + # Code sign the app + echo "Code signing..." + codesign --deep --force --verify --verbose \ + --sign "$DEVELOPER_ID" \ + --options runtime \ + --entitlements packaging/entitlements.plist \ + --timestamp \ + "dist/Clide.app" + + # Create ZIP for notarization + echo "Creating ZIP for notarization..." + ditto -c -k --sequesterRsrc --keepParent \ + "dist/Clide.app" "dist/Clide-$VERSION.zip" + + # Notarize + echo "Submitting for notarization (this may take a few minutes)..." + xcrun notarytool submit "dist/Clide-$VERSION.zip" \ + --keychain-profile "$KEYCHAIN_PROFILE" \ + --wait + + # Staple the notarization ticket + echo "Stapling notarization ticket..." + xcrun stapler staple "dist/Clide.app" + + # Clean up ZIP + rm "dist/Clide-$VERSION.zip" +fi + +# Create DMG +echo "Creating DMG..." +# Remove existing DMG if present +rm -f "dist/Clide-$VERSION-macos.dmg" + +create-dmg \ + --volname "Clide $VERSION" \ + --window-pos 200 120 \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "Clide.app" 150 190 \ + --hide-extension "Clide.app" \ + --app-drop-link 450 185 \ + "dist/Clide-$VERSION-macos.dmg" \ + "dist/Clide.app" || true # create-dmg returns non-zero even on success sometimes + +echo "" +echo "========================================" +echo "Build complete!" +echo "Output: dist/Clide-$VERSION-macos.dmg" +if [[ "$SKIP_SIGNING" == "true" ]]; then + echo "" + echo "Note: App was NOT signed or notarized." + echo "Users will see Gatekeeper warnings." +fi +echo "========================================" diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 new file mode 100644 index 00000000..bcbd6c28 --- /dev/null +++ b/scripts/build-windows.ps1 @@ -0,0 +1,102 @@ +# Build script for Windows installer distribution +# Creates an installer using Inno Setup +# +# Prerequisites: +# - Inno Setup 6: choco install innosetup -y +# Or download from: https://jrsoftware.org/isinfo.php +# +# Usage: .\scripts\build-windows.ps1 [-Version "1.0.0"] + +param( + [string]$Version = "1.0.0" +) + +$ErrorActionPreference = "Stop" + +Write-Host "========================================" +Write-Host "Building Clide $Version for Windows" +Write-Host "========================================" + +# Ensure we're in the project root +$ProjectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +Set-Location $ProjectRoot + +# Build with PyInstaller +Write-Host "Building with PyInstaller..." +pyinstaller clide.spec --clean --noconfirm + +# Check for Inno Setup +$InnoSetup = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" +if (-not (Test-Path $InnoSetup)) { + Write-Host "" + Write-Host "Warning: Inno Setup not found at $InnoSetup" + Write-Host "Install with: choco install innosetup -y" + Write-Host "Or download from: https://jrsoftware.org/isinfo.php" + Write-Host "" + Write-Host "Skipping installer creation. Binary available at: dist\clide.exe" + exit 0 +} + +# Create Inno Setup script +Write-Host "Creating Inno Setup script..." +$InnoScript = @" +; Clide Installer Script +; Generated by build-windows.ps1 + +[Setup] +AppName=Clide +AppVersion=$Version +AppPublisher=Clide +AppPublisherURL=https://github.com/your-repo/clide +AppSupportURL=https://github.com/your-repo/clide/issues +DefaultDirName={autopf}\Clide +DefaultGroupName=Clide +AllowNoIcons=yes +OutputDir=dist +OutputBaseFilename=Clide-$Version-windows-setup +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "dist\clide.exe"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\Clide"; Filename: "{app}\clide.exe" +Name: "{group}\{cm:UninstallProgram,Clide}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\Clide"; Filename: "{app}\clide.exe"; Tasks: desktopicon + +[Run] +Filename: "{app}\clide.exe"; Description: "{cm:LaunchProgram,Clide}"; Flags: nowait postinstall skipifsilent shellexec + +[Code] +function InitializeSetup(): Boolean; +begin + Result := True; + // Add any initialization checks here +end; +"@ + +# Write Inno Setup script +$InnoScriptPath = Join-Path $ProjectRoot "build\clide.iss" +$InnoScript | Out-File -FilePath $InnoScriptPath -Encoding UTF8 + +# Build installer +Write-Host "Building installer with Inno Setup..." +& $InnoSetup $InnoScriptPath + +Write-Host "" +Write-Host "========================================" +Write-Host "Build complete!" +Write-Host "Output: dist\Clide-$Version-windows-setup.exe" +Write-Host "" +Write-Host "Note: For best experience, users should run Clide in Windows Terminal." +Write-Host "========================================"