Skip to content

version

version #569

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 }}