Created
September 20, 2025 13:50
-
-
Save mfat/45f433d04452ea75a4df2ac313d5ae90 to your computer and use it in GitHub Desktop.
Pyinstaller spec example for pygobject
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # hook-gtk_runtime.py (packaging file, not app source) | |
| import os, sys | |
| from pathlib import Path | |
| if sys.platform == "darwin": | |
| # Find .../Contents robustly from the running executable | |
| cur = Path(sys.executable).resolve() | |
| for _ in range(8): | |
| if (cur / "Contents").exists(): | |
| contents = cur / "Contents"; break | |
| cur = cur.parent | |
| else: | |
| contents = Path(getattr(sys, "_MEIPASS", Path.cwd())) / ".." | |
| resources = (contents / "Resources").resolve() | |
| frameworks = (contents / "Frameworks").resolve() | |
| gi_paths = [ | |
| str(resources / "girepository-1.0"), | |
| str(resources / "gi_typelibs"), # PyInstallerβs GI dump | |
| ] | |
| os.environ["GI_TYPELIB_PATH"] = ":".join([p for p in gi_paths if Path(p).exists()]) | |
| os.environ["GSETTINGS_SCHEMA_DIR"] = str(resources / "share" / "glib-2.0" / "schemas") | |
| os.environ["XDG_DATA_DIRS"] = str(resources / "share") | |
| # Set up GDK-Pixbuf loaders | |
| gdkpixbuf_module_dir = frameworks / "lib" / "gdk-pixbuf" / "loaders" | |
| gdkpixbuf_module_file = resources / "lib" / "gdk-pixbuf" / "loaders.cache" | |
| if gdkpixbuf_module_dir.exists(): | |
| os.environ["GDK_PIXBUF_MODULEDIR"] = str(gdkpixbuf_module_dir) | |
| print(f"DEBUG: Set GDK_PIXBUF_MODULEDIR = {gdkpixbuf_module_dir}") | |
| if gdkpixbuf_module_file.exists(): | |
| os.environ["GDK_PIXBUF_MODULE_FILE"] = str(gdkpixbuf_module_file) | |
| print(f"DEBUG: Set GDK_PIXBUF_MODULE_FILE = {gdkpixbuf_module_file}") | |
| # Set up keyring environment for macOS (like the working bundle) | |
| os.environ["KEYRING_BACKEND"] = "keyring.backends.macOS.Keyring" | |
| os.environ["PYTHON_KEYRING_BACKEND"] = "keyring.backends.macOS.Keyring" | |
| # Ensure keyring can access the user's keychain | |
| if "HOME" not in os.environ: | |
| os.environ["HOME"] = os.path.expanduser("~") | |
| if "USER" not in os.environ: | |
| os.environ["USER"] = os.environ.get("LOGNAME", "unknown") | |
| if "LOGNAME" not in os.environ: | |
| os.environ["LOGNAME"] = os.environ.get("USER", "unknown") | |
| if "SHELL" not in os.environ: | |
| os.environ["SHELL"] = "/bin/bash" | |
| # Critical for macOS keychain access (from working bundle) | |
| os.environ["KEYCHAIN_ACCESS_GROUP"] = "*" | |
| # Set up XDG directories for keyring | |
| home = os.environ["HOME"] | |
| os.environ["XDG_CONFIG_HOME"] = os.path.join(home, ".config") | |
| os.environ["XDG_DATA_HOME"] = os.path.join(home, ".local", "share") | |
| os.environ["XDG_CACHE_HOME"] = os.path.join(home, ".cache") | |
| # Create XDG directories if they don't exist | |
| for xdg_dir in ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]: | |
| xdg_path = os.environ[xdg_dir] | |
| os.makedirs(xdg_path, exist_ok=True) | |
| # Set PATH explicitly for double-click launches (like working bundle) | |
| # This ensures the app has access to all necessary tools including system Python | |
| system_paths = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] | |
| current_path = os.environ.get("PATH", "") | |
| os.environ["PATH"] = ":".join(system_paths + [current_path]) | |
| # Add bundled sshpass to PATH | |
| bundled_bin = str(resources / "bin") | |
| if Path(bundled_bin).exists(): | |
| os.environ["PATH"] = f"{bundled_bin}:{os.environ['PATH']}" | |
| print(f"DEBUG: Added bundled bin to PATH: {bundled_bin}") | |
| # Add GI modules to Python path for Cairo bindings | |
| gi_modules_path = str(frameworks / "gi") | |
| if Path(gi_modules_path).exists(): | |
| current_pythonpath = os.environ.get("PYTHONPATH", "") | |
| if current_pythonpath: | |
| os.environ["PYTHONPATH"] = f"{gi_modules_path}:{current_pythonpath}" | |
| else: | |
| os.environ["PYTHONPATH"] = gi_modules_path | |
| print(f"DEBUG: Added GI modules to PYTHONPATH: {gi_modules_path}") | |
| # Include both Frameworks and Frameworks/Frameworks for libraries | |
| fallback_paths = [str(frameworks), str(frameworks / "Frameworks")] | |
| os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = ":".join(fallback_paths) | |
| # Also set DYLD_LIBRARY_PATH for GObject Introspection | |
| os.environ["DYLD_LIBRARY_PATH"] = ":".join(fallback_paths) | |
| print(f"DEBUG: DYLD_FALLBACK_LIBRARY_PATH = {os.environ['DYLD_FALLBACK_LIBRARY_PATH']}") | |
| print(f"DEBUG: DYLD_LIBRARY_PATH = {os.environ['DYLD_LIBRARY_PATH']}") | |
| print(f"DEBUG: frameworks = {frameworks}") | |
| print(f"DEBUG: frameworks/Frameworks = {frameworks / 'Frameworks'}") | |
| print(f"DEBUG: resources = {resources}") | |
| print(f"DEBUG: GI_TYPELIB_PATH = {os.environ.get('GI_TYPELIB_PATH', 'NOT SET')}") | |
| os.environ.pop("LD_LIBRARY_PATH", None) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Build script for SSHPilot PyInstaller bundle | |
| # This script activates the Homebrew virtual environment and builds the bundle | |
| set -e # Exit on any error | |
| echo "π Building SSHPilot PyInstaller bundle..." | |
| # Check if we're in the right directory | |
| if [ ! -f "sshpilot.spec" ]; then | |
| echo "β Error: sshpilot.spec not found. Please run this script from the project root directory." | |
| exit 1 | |
| fi | |
| # Check if virtual environment exists, create if not | |
| if [ ! -d ".venv-homebrew" ]; then | |
| echo "π¦ Creating Homebrew virtual environment..." | |
| # Detect architecture and set Homebrew path | |
| ARCH=$(uname -m) | |
| if [ "$ARCH" = "arm64" ]; then | |
| # Apple Silicon Mac | |
| HOMEBREW_PREFIX="/opt/homebrew" | |
| echo "π Detected Apple Silicon Mac (ARM64)" | |
| else | |
| # Intel Mac | |
| HOMEBREW_PREFIX="/usr/local" | |
| echo "π» Detected Intel Mac (x86_64)" | |
| fi | |
| # Check if Homebrew Python is available | |
| PYTHON_PATH="$HOMEBREW_PREFIX/opt/python@3.13/bin/python3.13" | |
| if [ ! -f "$PYTHON_PATH" ]; then | |
| echo "β Homebrew Python 3.13 not found at $PYTHON_PATH" | |
| echo "Please install it with:" | |
| echo " brew install python@3.13" | |
| exit 1 | |
| fi | |
| echo "π Using Python from: $PYTHON_PATH" | |
| # Create virtual environment using Homebrew Python | |
| "$PYTHON_PATH" -m venv .venv-homebrew | |
| echo "β Virtual environment created successfully" | |
| # Activate and install PyInstaller | |
| echo "π¦ Installing PyInstaller..." | |
| source .venv-homebrew/bin/activate | |
| pip install PyInstaller | |
| echo "β PyInstaller installed successfully" | |
| else | |
| echo "π¦ Activating existing Homebrew virtual environment..." | |
| source .venv-homebrew/bin/activate | |
| fi | |
| echo "π¨ Running PyInstaller..." | |
| python -m PyInstaller --clean --noconfirm sshpilot.spec | |
| # Check if build was successful | |
| if [ -d "dist/SSHPilot.app" ]; then | |
| echo "β Build successful! Bundle created at: dist/SSHPilot.app" | |
| # Create DMG file using create-dmg | |
| echo "π¦ Creating DMG file..." | |
| # Check if create-dmg is installed | |
| if ! command -v create-dmg &> /dev/null; then | |
| echo "β create-dmg is not installed. Please install it with:" | |
| echo " brew install create-dmg" | |
| echo "" | |
| echo "π SSHPilot bundle is ready!" | |
| echo "π Location: $(pwd)/dist/SSHPilot.app" | |
| echo "π You can now run: open dist/SSHPilot.app" | |
| exit 0 | |
| fi | |
| # Read version from __init__.py | |
| VERSION=$(grep -o '__version__ = "[^"]*"' sshpilot/__init__.py | cut -d'"' -f2) | |
| if [ -z "$VERSION" ]; then | |
| echo "β οΈ Could not read version from sshpilot/__init__.py, using date instead" | |
| VERSION=$(date +%Y%m%d) | |
| fi | |
| echo "DEBUG: Detected version: $VERSION" | |
| DMG_NAME="sshPilot-${VERSION}.dmg" | |
| DMG_PATH="dist/${DMG_NAME}" | |
| echo "DEBUG: DMG will be created as: $DMG_PATH" | |
| # Remove existing DMG if it exists | |
| if [ -f "$DMG_PATH" ]; then | |
| rm "$DMG_PATH" | |
| fi | |
| # Create DMG using create-dmg | |
| echo "π¨ Creating DMG with create-dmg..." | |
| if create-dmg \ | |
| --volname "sshPilot" \ | |
| --volicon "packaging/macos/sshpilot.icns" \ | |
| --window-pos 200 120 \ | |
| --window-size 800 400 \ | |
| --icon-size 100 \ | |
| --icon "SSHPilot.app" 200 190 \ | |
| --hide-extension "SSHPilot.app" \ | |
| --app-drop-link 600 185 \ | |
| --skip-jenkins \ | |
| "$DMG_PATH" \ | |
| "dist/SSHPilot.app"; then | |
| if [ -f "$DMG_PATH" ]; then | |
| echo "β DMG created successfully!" | |
| echo "" | |
| echo "π SSHPilot bundle and DMG are ready!" | |
| echo "π Bundle: $(pwd)/dist/SSHPilot.app" | |
| echo "π DMG: $(pwd)/$DMG_PATH" | |
| echo "π You can now run: open dist/SSHPilot.app" | |
| echo "π Or mount the DMG: open $DMG_PATH" | |
| else | |
| echo "β οΈ DMG command succeeded, but file not found." | |
| echo "" | |
| echo "π SSHPilot bundle is ready!" | |
| echo "π Location: $(pwd)/dist/SSHPilot.app" | |
| echo "π You can now run: open dist/SSHPilot.app" | |
| fi | |
| else | |
| echo "β Failed to create DMG with create-dmg" | |
| echo "β οΈ DMG creation failed, but bundle was created successfully." | |
| echo "" | |
| echo "π SSHPilot bundle is ready!" | |
| echo "π Location: $(pwd)/dist/SSHPilot.app" | |
| echo "π You can now run: open dist/SSHPilot.app" | |
| fi | |
| else | |
| echo "β Build failed! Bundle not found at dist/SSHPilot.app" | |
| exit 1 | |
| fi |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # sshpilot.spec β build with: pyinstaller --clean sshpilot.spec | |
| import os, glob, platform | |
| from PyInstaller.utils.hooks import collect_submodules | |
| app_name = "SSHPilot" | |
| entry_py = "run.py" | |
| icon_file = "packaging/macos/sshpilot.icns" | |
| # Detect architecture and set Homebrew path | |
| arch = platform.machine() | |
| if arch == "arm64": | |
| # Apple Silicon Mac | |
| homebrew = "/opt/homebrew" | |
| print(f"π Detected Apple Silicon Mac (ARM64), using Homebrew at: {homebrew}") | |
| else: | |
| # Intel Mac | |
| homebrew = "/usr/local/" | |
| print(f"π» Detected Intel Mac (x86_64), using Homebrew at: {homebrew}") | |
| hb_lib = f"{homebrew}/lib" | |
| hb_share = f"{homebrew}/share" | |
| hb_gir = f"{hb_lib}/girepository-1.0" | |
| # Keep list tight; expand if otool shows missing libs | |
| gtk_libs_patterns = [ | |
| "libadwaita-1.*.dylib", | |
| "libgtk-4.*.dylib", | |
| "libgdk-4.*.dylib", | |
| "libgdk_pixbuf-2.0.*.dylib", | |
| "libvte-2.91.*.dylib", | |
| "libvte-2.91-gtk4.*.dylib", | |
| "libgraphene-1.0.*.dylib", | |
| "libpango-1.*.dylib", | |
| "libpangocairo-1.*.dylib", | |
| "libharfbuzz.*.dylib", | |
| "libfribidi.*.dylib", | |
| "libcairo.*.dylib", | |
| "libcairo-gobject.*.dylib", | |
| "libgobject-2.0.*.dylib", | |
| "libglib-2.0.*.dylib", | |
| "libgio-2.0.*.dylib", | |
| "libgmodule-2.0.*.dylib", | |
| "libintl.*.dylib", | |
| "libffi.*.dylib", | |
| "libicu*.dylib", | |
| ] | |
| binaries = [] | |
| for pat in gtk_libs_patterns: | |
| for src in glob.glob(os.path.join(hb_lib, pat)): | |
| # Special handling for VTE and Adwaita libraries to avoid nested Frameworks structure | |
| if "vte" in pat.lower() or "adwaita" in pat.lower(): | |
| binaries.append((src, ".")) # Place directly in Frameworks root | |
| else: | |
| binaries.append((src, "Frameworks")) | |
| # GI typelibs | |
| datas = [] | |
| for typelib in glob.glob(os.path.join(hb_gir, "*.typelib")): | |
| datas.append((typelib, "girepository-1.0")) | |
| # Shared data: schemas, icons, gtk-4.0 assets | |
| datas += [ | |
| (os.path.join(hb_share, "glib-2.0", "schemas"), "Resources/share/glib-2.0/schemas"), | |
| (os.path.join(hb_share, "icons", "Adwaita"), "Resources/share/icons/Adwaita"), | |
| (os.path.join(hb_share, "gtk-4.0"), "Resources/share/gtk-4.0"), | |
| ("sshpilot", "sshpilot"), | |
| ("sshpilot/resources/sshpilot.gresource", "Resources/sshpilot"), | |
| ("sshpilot/io.github.mfat.sshpilot.svg", "share/icons"), | |
| ] | |
| # Add libadwaita locale files if they exist | |
| libadwaita_locale = "/opt/homebrew/Cellar/libadwaita/1.7.6/share/locale" | |
| if os.path.exists(libadwaita_locale): | |
| datas.append((libadwaita_locale, "Resources/share/locale")) | |
| print(f"Added libadwaita locale files: {libadwaita_locale}") | |
| # Add GDK-Pixbuf loaders and cache | |
| gdkpixbuf_loaders = f"{homebrew}/lib/gdk-pixbuf-2.0/2.10.0" | |
| if os.path.exists(gdkpixbuf_loaders): | |
| datas.append((gdkpixbuf_loaders, "Resources/lib/gdk-pixbuf-2.0/2.10.0")) | |
| print(f"Added GDK-Pixbuf loaders: {gdkpixbuf_loaders}") | |
| # Add keyring package files explicitly | |
| keyring_package = f"{homebrew}/lib/python3.13/site-packages/keyring" | |
| if os.path.exists(keyring_package): | |
| datas.append((keyring_package, "keyring")) | |
| print(f"Added keyring package: {keyring_package}") | |
| # Optional helper binaries | |
| sshpass = f"{homebrew}/bin/sshpass" | |
| if os.path.exists(sshpass): | |
| binaries.append((sshpass, "Resources/bin")) | |
| # Cairo Python bindings (required for Cairo Context) | |
| cairo_gi_binding = f"{homebrew}/lib/python3.13/site-packages/gi/_gi_cairo.cpython-313-darwin.so" | |
| if os.path.exists(cairo_gi_binding): | |
| binaries.append((cairo_gi_binding, "gi")) | |
| hiddenimports = collect_submodules("gi") | |
| hiddenimports += ["gi._gi_cairo", "gi.repository.cairo", "cairo"] | |
| # Add keyring for askpass functionality | |
| hiddenimports += ["keyring"] | |
| # Add all keyring backends | |
| hiddenimports += ["keyring.backends", "keyring.backends.macOS", "keyring.backends.libsecret", "keyring.backends.SecretService"] | |
| block_cipher = None | |
| a = Analysis( | |
| [entry_py], | |
| pathex=[], | |
| binaries=binaries, | |
| datas=datas, | |
| hiddenimports=hiddenimports, | |
| hookspath=["."], | |
| runtime_hooks=["hook-gtk_runtime.py"], | |
| noarchive=False, | |
| ) | |
| pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) | |
| exe = EXE( | |
| pyz, | |
| a.scripts, | |
| [], | |
| exclude_binaries=True, | |
| name=app_name, | |
| icon=icon_file if os.path.exists(icon_file) else None, | |
| console=False, | |
| ) | |
| coll = COLLECT( | |
| exe, | |
| a.binaries, | |
| a.zipfiles, | |
| a.datas, | |
| strip=False, | |
| upx=False, | |
| name=app_name, | |
| ) | |
| app = BUNDLE( | |
| coll, | |
| name=f"{app_name}.app", | |
| icon=icon_file if os.path.exists(icon_file) else None, | |
| bundle_identifier="app.sshpilot", | |
| info_plist={ | |
| "NSHighResolutionCapable": True, | |
| "LSMinimumSystemVersion": "12.0", | |
| }, | |
| ) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment