version #569
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
name: macOS Installer Build (Intel x86_64) | |
# Add permissions needed for creating releases | |
permissions: | |
contents: write | |
on: | |
push: | |
branches: | |
- '*' # This will trigger on any branch push | |
tags: | |
- "*" # This will trigger on any tag push | |
pull_request: | |
branches: | |
- main | |
jobs: | |
build-macos-intel-installer: | |
name: Build macOS Intel Installer | |
runs-on: macos-latest | |
steps: | |
- name: Checkout code | |
uses: actions/checkout@v3 | |
with: | |
fetch-depth: 0 | |
- name: Set up Python 3.9 | |
uses: actions/setup-python@v4 | |
with: | |
python-version: '3.9' | |
- name: Install dependencies | |
run: | | |
python -m pip install --upgrade pip | |
pip install -r requirements.txt | |
pip install py2app==0.28.6 pyinstaller==6.1.0 | |
- name: Extract metadata | |
id: meta | |
run: | | |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then | |
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT | |
echo "IS_TAG=true" >> $GITHUB_OUTPUT | |
else | |
echo "VERSION=$(cat version.txt)" >> $GITHUB_OUTPUT | |
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT | |
echo "IS_TAG=false" >> $GITHUB_OUTPUT | |
fi | |
- name: Create app_launcher.py | |
run: | | |
cat > app_launcher.py << 'EOF' | |
#!/usr/bin/env python3 | |
import os | |
import sys | |
import logging | |
import shutil | |
import json | |
import traceback | |
import time | |
from datetime import datetime | |
# Ensure Desktop log for critical errors | |
desktop_log = os.path.expanduser("~/Desktop/huntarr_error.log") | |
with open(desktop_log, "a") as f: | |
f.write(f"\n[{datetime.now().isoformat()}] Starting app_launcher.py\n") | |
# Setup base paths before anything else - Use a location that doesn't require admin privileges | |
# First try the app bundle location, then fall back to Documents folder which usually has permissions | |
bundle_dir = os.path.dirname(os.path.abspath(sys.argv[0])) | |
if hasattr(sys, "_MEIPASS"): | |
bundle_dir = sys._MEIPASS | |
app_name = "Huntarr" | |
# Use app's own directory for storage to avoid permission issues | |
if os.path.exists(os.path.join(bundle_dir, "Contents", "Resources")): | |
# Running as a .app bundle | |
with open(desktop_log, "a") as f: | |
f.write(f"Detected running as .app bundle\n") | |
resources_dir = os.path.join(bundle_dir, "Contents", "Resources") | |
config_dir = os.path.join(resources_dir, "config") | |
else: | |
# Create config in user's Documents folder as a safer alternative | |
home = os.path.expanduser("~") | |
config_dir = os.path.join(home, "Documents", app_name, "config") | |
with open(desktop_log, "a") as f: | |
f.write(f"Using Documents folder for configuration: {config_dir}\n") | |
# Set up log directory | |
log_dir = os.path.join(config_dir, "logs") | |
# Create essential directories | |
for dir_path in [ | |
config_dir, | |
os.path.join(config_dir, "settings"), | |
os.path.join(config_dir, "stateful"), | |
os.path.join(config_dir, "user"), | |
os.path.join(config_dir, "logs"), | |
os.path.join(config_dir, "scheduler"), | |
]: | |
try: | |
os.makedirs(dir_path, exist_ok=True) | |
with open(desktop_log, "a") as f: | |
f.write(f"Created directory: {dir_path}\n") | |
except Exception as e: | |
with open(desktop_log, "a") as f: | |
f.write(f"Error creating directory {dir_path}: {str(e)}\n") | |
# Configure logging | |
try: | |
logging.basicConfig( | |
level=logging.DEBUG, | |
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", | |
handlers=[ | |
logging.FileHandler(os.path.join(log_dir, "huntarr.log")), | |
logging.StreamHandler() | |
] | |
) | |
logger = logging.getLogger("Huntarr") | |
logger.setLevel(logging.DEBUG) | |
# Make sure log messages appear at the appropriate levels | |
for handler in logger.handlers: | |
handler.setLevel(logging.DEBUG) | |
with open(desktop_log, "a") as f: | |
f.write("Logging system initialized\n") | |
except Exception as e: | |
with open(desktop_log, "a") as f: | |
f.write(f"Error setting up logging: {str(e)}\n") | |
raise | |
# Create necessary default config files if they don't exist | |
try: | |
# Create default scheduler file | |
scheduler_dir = os.path.join(config_dir, "scheduler") | |
scheduler_file = os.path.join(scheduler_dir, "schedule.json") | |
if not os.path.exists(scheduler_file): | |
default_schedule = { | |
"global": [], | |
"sonarr": [], | |
"radarr": [], | |
"lidarr": [], | |
"readarr": [] | |
} | |
with open(scheduler_file, "w") as f: | |
json.dump(default_schedule, f, indent=2) | |
logger.debug(f"Created default scheduler file at {scheduler_file}") | |
# Create default general.json with appropriate timeouts | |
general_file = os.path.join(config_dir, "settings", "general.json") | |
if not os.path.exists(general_file): | |
default_general = { | |
"api_timeout": 120, | |
"command_wait_delay": 1, | |
"command_wait_attempts": 600, | |
"log_level": "DEBUG" | |
} | |
with open(general_file, "w") as f: | |
json.dump(default_general, f, indent=2) | |
logger.debug(f"Created default general settings at {general_file}") | |
except Exception as e: | |
logger.exception(f"Error creating default config files: {str(e)}") | |
try: | |
# Set environment variables to mimic Docker container | |
os.environ["HUNTARR_CONFIG_DIR"] = config_dir | |
os.environ["FLASK_ENV"] = "production" | |
# Create a file to record the config location for other processes | |
config_location_file = os.path.join(os.path.dirname(desktop_log), "huntarr_config_location.txt") | |
with open(config_location_file, "w") as f: | |
f.write(config_dir) | |
# Make sure we have write permissions to the config directory | |
test_file_path = os.path.join(config_dir, "write_test.txt") | |
try: | |
with open(test_file_path, "w") as f: | |
f.write("Permission test") | |
os.remove(test_file_path) | |
logger.debug(f"Confirmed write permissions to {config_dir}") | |
except Exception as perm_error: | |
logger.error(f"No write permission to {config_dir}: {str(perm_error)}") | |
# Try to use a temporary directory if we can't write to our preferred locations | |
import tempfile | |
temp_config_dir = os.path.join(tempfile.gettempdir(), f"huntarr_{int(time.time())}") | |
os.makedirs(temp_config_dir, exist_ok=True) | |
config_dir = temp_config_dir | |
log_dir = os.path.join(config_dir, "logs") | |
os.makedirs(log_dir, exist_ok=True) | |
os.environ["HUNTARR_CONFIG_DIR"] = config_dir | |
logger.warning(f"Switched to temporary directory: {config_dir}") | |
with open(config_location_file, "w") as f: | |
f.write(config_dir) | |
# Log environment information for debugging | |
logger.debug(f"Python version: {sys.version}") | |
logger.debug(f"Python executable: {sys.executable}") | |
logger.debug(f"Current working directory: {os.getcwd()}") | |
logger.debug(f"HUNTARR_CONFIG_DIR = {os.environ.get('HUNTARR_CONFIG_DIR')}") | |
# List all environment variables for debugging | |
logger.debug("Environment variables:") | |
for key, value in sorted(os.environ.items()): | |
logger.debug(f" {key} = {value}") | |
# Check if running in PyInstaller bundle | |
if hasattr(sys, "_MEIPASS"): | |
logger.debug(f"Running in PyInstaller bundle: {sys._MEIPASS}") | |
bundle_dir = sys._MEIPASS | |
os.chdir(bundle_dir) | |
logger.debug(f"Changed working directory to: {os.getcwd()}") | |
# List bundle contents for debugging | |
logger.debug("Bundle contents:") | |
for root, dirs, files in os.walk(bundle_dir, topdown=True, followlinks=False): | |
rel_path = os.path.relpath(root, bundle_dir) | |
if rel_path == ".": | |
rel_path = "" | |
logger.debug(f" Directory: {rel_path}") | |
for file in files: | |
logger.debug(f" {os.path.join(rel_path, file)}") | |
# Import main module with debug info | |
logger.debug("Attempting to import main module...") | |
try: | |
sys.path.insert(0, os.getcwd()) | |
import main | |
logger.debug("Main module imported successfully") | |
except ImportError as e: | |
logger.error(f"Failed to import main: {str(e)}") | |
raise | |
# Start the main application | |
logger.debug("Starting main application") | |
main.main() | |
except Exception as e: | |
logger.exception(f"Fatal error in app_launcher: {str(e)}") | |
with open(desktop_log, "a") as f: | |
f.write(f"\n[{datetime.now().isoformat()}] FATAL ERROR: {str(e)}\n") | |
f.write("\nTraceback:\n") | |
traceback.print_exc(file=f) | |
# Add more diagnostic information | |
f.write("\n\nSystem Information:\n") | |
f.write(f"Python version: {sys.version}\n") | |
f.write(f"Python path: {sys.path}\n") | |
f.write(f"Working directory: {os.getcwd()}\n") | |
# Try to list directory contents | |
try: | |
f.write("\nDirectory contents:\n") | |
for item in os.listdir(os.getcwd()): | |
f.write(f" {item}\n") | |
except Exception as dir_err: | |
f.write(f"Error listing directory: {str(dir_err)}\n") | |
EOF | |
- name: Create runtime hook | |
run: | | |
mkdir -p hooks | |
cat > hooks/runtime_hook.py << 'EOF' | |
import os | |
import sys | |
# Set up app environment variables | |
os.environ["FLASK_ENV"] = "production" | |
# Setup config directory path in user's home | |
home = os.path.expanduser("~") | |
config_dir = os.path.join(home, "Library", "Application Support", "Huntarr", "config") | |
os.environ["HUNTARR_CONFIG_DIR"] = config_dir | |
# Add bundle resources directory to path | |
if ".app" in sys.executable: | |
bundle_dir = os.path.abspath(os.path.dirname(sys.executable)) | |
resources_dir = os.path.abspath(os.path.join(bundle_dir, "..", "Resources")) | |
if resources_dir not in sys.path: | |
sys.path.insert(0, resources_dir) | |
EOF | |
- name: Create icon | |
run: | | |
mkdir -p icon.iconset | |
sips -s format png frontend/static/logo/huntarr.ico --out icon.iconset/icon_16x16.png --resampleWidth 16 || true | |
sips -s format png frontend/static/logo/huntarr.ico --out icon.iconset/icon_32x32.png --resampleWidth 32 || true | |
sips -s format png frontend/static/logo/huntarr.ico --out icon.iconset/icon_64x64.png --resampleWidth 64 || true | |
sips -s format png frontend/static/logo/huntarr.ico --out icon.iconset/icon_128x128.png --resampleWidth 128 || true | |
sips -s format png frontend/static/logo/huntarr.ico --out icon.iconset/icon_256x256.png --resampleWidth 256 || true | |
sips -s format png frontend/static/logo/huntarr.ico --out icon.iconset/icon_512x512.png --resampleWidth 512 || true | |
cp icon.iconset/icon_32x32.png icon.iconset/icon_16x16@2x.png || true | |
cp icon.iconset/icon_64x64.png icon.iconset/icon_32x32@2x.png || true | |
cp icon.iconset/icon_128x128.png icon.iconset/icon_64x64@2x.png || true | |
cp icon.iconset/icon_256x256.png icon.iconset/icon_128x128@2x.png || true | |
cp icon.iconset/icon_512x512.png icon.iconset/icon_256x256@2x.png || true | |
iconutil -c icns icon.iconset -o frontend/static/logo/huntarr.icns || true | |
# If icon conversion fails, create a fallback icon | |
if [ ! -f frontend/static/logo/huntarr.icns ]; then | |
cp /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns frontend/static/logo/huntarr.icns || true | |
fi | |
- name: Create PyInstaller spec | |
run: | | |
cat > Huntarr.spec << 'EOF' | |
# -*- mode: python ; coding: utf-8 -*- | |
from PyInstaller.building.api import PYZ, EXE, COLLECT | |
from PyInstaller.building.build_main import Analysis | |
from PyInstaller.building.datastruct import Tree | |
import os | |
import sys | |
block_cipher = None | |
a = Analysis( | |
['app_launcher.py'], | |
pathex=['.'], | |
binaries=[], | |
datas=[ | |
('frontend', 'frontend'), | |
('version.txt', '.'), | |
('README.md', '.'), | |
('LICENSE', '.'), | |
('src', 'src'), | |
], | |
hiddenimports=[ | |
'flask', | |
'flask.json', | |
'requests', | |
'waitress', | |
'bcrypt', | |
'qrcode', | |
'PIL', | |
'pyotp', | |
'qrcode.image.pil', | |
'routes', | |
'main', | |
], | |
hookspath=['hooks'], | |
hooksconfig={}, | |
runtime_hooks=['hooks/runtime_hook.py'], | |
excludes=[], | |
win_no_prefer_redirects=False, | |
win_private_assemblies=False, | |
cipher=block_cipher, | |
noarchive=False, | |
) | |
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) | |
exe = EXE( | |
pyz, | |
a.scripts, | |
[], | |
exclude_binaries=True, | |
name='Huntarr', | |
debug=False, | |
bootloader_ignore_signals=False, | |
strip=False, | |
upx=True, | |
console=True, | |
disable_windowed_traceback=False, | |
argv_emulation=True, | |
target_arch=None, | |
codesign_identity=None, | |
entitlements_file=None, | |
icon='frontend/static/logo/huntarr.icns', | |
) | |
coll = COLLECT( | |
exe, | |
a.binaries, | |
a.zipfiles, | |
a.datas, | |
strip=False, | |
upx=True, | |
upx_exclude=[], | |
name='Huntarr', | |
) | |
app = BUNDLE( | |
coll, | |
name='Huntarr.app', | |
icon='frontend/static/logo/huntarr.icns', | |
bundle_identifier='io.huntarr.app', | |
info_plist={ | |
'CFBundleShortVersionString': '${{ steps.meta.outputs.VERSION }}', | |
'CFBundleVersion': '${{ steps.meta.outputs.VERSION }}', | |
'NSHighResolutionCapable': True, | |
'NSRequiresAquaSystemAppearance': False, | |
'LSEnvironment': { | |
'HUNTARR_CONFIG_DIR': '~/Library/Application Support/Huntarr/config', | |
'PYTHONPATH': '@executable_path/../Resources', | |
}, | |
'CFBundleDocumentTypes': [], | |
'NSPrincipalClass': 'NSApplication', | |
}, | |
) | |
EOF | |
- name: Build macOS app bundle | |
run: python -m PyInstaller Huntarr.spec --clean | |
- name: Create PKG installer | |
run: | | |
# Create a simple postinstall script | |
mkdir -p scripts | |
cat > scripts/postinstall << 'EOF' | |
#!/bin/bash | |
# Create config directory in user's Application Support | |
mkdir -p "$HOME/Library/Application Support/Huntarr/config" | |
mkdir -p "$HOME/Library/Application Support/Huntarr/config/settings" | |
mkdir -p "$HOME/Library/Application Support/Huntarr/config/stateful" | |
mkdir -p "$HOME/Library/Application Support/Huntarr/config/user" | |
mkdir -p "$HOME/Library/Application Support/Huntarr/config/logs" | |
# Set permissions | |
chmod -R 755 "$HOME/Library/Application Support/Huntarr" | |
exit 0 | |
EOF | |
chmod +x scripts/postinstall | |
# Create PKG installer | |
version="${{ steps.meta.outputs.VERSION }}" | |
branch="${{ steps.meta.outputs.BRANCH }}" | |
if [[ "${{ steps.meta.outputs.IS_TAG }}" == "true" ]]; then | |
pkg_name="Huntarr-${version}-mac-intel.pkg" | |
else | |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then | |
pkg_name="Huntarr-${version}-mac-main-intel.pkg" | |
elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then | |
pkg_name="Huntarr-${version}-mac-dev-intel.pkg" | |
else | |
# Sanitize branch name by replacing slashes with hyphens | |
branch_safe=$(echo "${branch}" | tr '/' '-') | |
pkg_name="Huntarr-${version}-mac-${branch_safe}-intel.pkg" | |
fi | |
fi | |
pkgbuild --root dist/ \ | |
--scripts scripts/ \ | |
--identifier io.huntarr.app \ | |
--version ${version} \ | |
--install-location /Applications \ | |
${pkg_name} | |
- name: Upload installer as artifact | |
uses: actions/upload-artifact@v4 | |
with: | |
name: huntarr-macos-intel-installer | |
path: '*.pkg' | |
retention-days: 30 | |
- name: Upload to release | |
if: steps.meta.outputs.IS_TAG == 'true' | |
uses: softprops/action-gh-release@v1 | |
with: | |
files: '*.pkg' | |
env: | |
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |