Skip to content

Instantly share code, notes, and snippets.

@mfat
Created September 20, 2025 13:50
Show Gist options
  • Save mfat/45f433d04452ea75a4df2ac313d5ae90 to your computer and use it in GitHub Desktop.
Save mfat/45f433d04452ea75a4df2ac313d5ae90 to your computer and use it in GitHub Desktop.
Pyinstaller spec example for pygobject
# 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)
#!/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
# 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