Fix Windows frontend missing files + system tray support

- web_server.py: Use sys._MEIPASS for PyInstaller 6.x data file paths
- windows_integration.py: Same _MEIPASS fix for Flask folder override
- huntarr.spec: Use Tree() for frontend, exclude node_modules, add pystray
- huntarr_installer.nsi: Fix File /r glob, remove empty CreateDirectory
- requirements.txt: Enable pystray for Windows
- system_tray.py: Rewrite with proper _MEIPASS icon loading + shutdown callback
- main.py: Integrate system tray on Windows frozen builds
- windows-build-nsis.yml: Add pystray install + frontend verification step
This commit is contained in:
Admin9705
2026-02-18 18:56:25 -05:00
parent 783c736ba2
commit 9fcb357a10
8 changed files with 256 additions and 257 deletions

View File

@@ -52,6 +52,8 @@ jobs:
pip install apprise==1.6.0
pip install markdown==3.4.3
pip install pyyaml==6.0.1
# System tray support
pip install pystray==0.19.5
python -m pip show setuptools pyinstaller
- name: Create directories
@@ -75,6 +77,16 @@ jobs:
# Display contents of dist/Huntarr
dir dist/Huntarr
# Verify frontend files were bundled (catch missing files early)
if (!(Test-Path "dist/Huntarr/_internal/frontend/templates/index.html")) {
Write-Host "WARNING: frontend/templates not found in _internal/, checking alternate locations..."
if (!(Test-Path "dist/Huntarr/_internal/templates/index.html")) {
Write-Error "FATAL: Frontend templates missing from PyInstaller bundle!"
exit 1
}
}
Write-Host "Frontend templates verified in bundle."
- name: Install NSIS
run: |
choco install nsis -y

View File

@@ -9,17 +9,11 @@ spec_dir = pathlib.Path(os.path.dirname(os.path.abspath(SPECPATH)))
project_dir = spec_dir.parent.parent # Go up two levels to project root
# In GitHub Actions, the current working directory is already the project root
# Check if we're in GitHub Actions by looking at the environment
if os.environ.get('GITHUB_ACTIONS'):
# Use the current directory instead
project_dir = pathlib.Path(os.getcwd())
# Print current directory and list files for debugging
print(f"Current directory: {os.getcwd()}")
print(f"Project directory: {project_dir}")
print("Files in current directory:")
for file in os.listdir(os.getcwd()):
print(f" {file}")
# Find main.py file
main_py_path = project_dir / 'main.py'
@@ -30,34 +24,48 @@ if not main_py_path.exists():
print(f"Found main.py at: {main_py_path}")
else:
print("ERROR: main.py not found!")
# Use a placeholder that will cause an error with a clearer message
main_py_path = project_dir / 'main.py'
block_cipher = None
# Create a list of data files to include with absolute paths
# ---------------------------------------------------------------------------
# Data files to bundle.
# PyInstaller 6.x places these under _internal/ (sys._MEIPASS).
# We include frontend/templates and frontend/static under 'frontend/' so
# code using _MEIPASS/frontend/templates finds them.
# We explicitly EXCLUDE node_modules and frontend/src (dev-only).
# ---------------------------------------------------------------------------
from PyInstaller.building.datastruct import Tree
# Collect frontend/templates and frontend/static, skipping node_modules and src
frontend_templates = Tree(
str(project_dir / 'frontend' / 'templates'),
prefix='frontend/templates',
excludes=['*.pyc', '__pycache__'],
)
frontend_static = Tree(
str(project_dir / 'frontend' / 'static'),
prefix='frontend/static',
excludes=['*.pyc', '__pycache__'],
)
datas = [
(str(project_dir / 'frontend'), 'frontend'),
(str(project_dir / 'src'), 'src'),
]
# Add apprise data files to fix attachment directory error
# Also add templates/static at top-level as legacy fallback
datas.append((str(project_dir / 'frontend' / 'templates'), 'templates'))
datas.append((str(project_dir / 'frontend' / 'static'), 'static'))
# Add apprise data files
try:
import apprise
import os
apprise_path = os.path.dirname(apprise.__file__)
# Add apprise's attachment, plugins, and config directories
apprise_attachment_path = os.path.join(apprise_path, 'attachment')
apprise_plugins_path = os.path.join(apprise_path, 'plugins')
apprise_config_path = os.path.join(apprise_path, 'config')
if os.path.exists(apprise_attachment_path):
datas.append((apprise_attachment_path, 'apprise/attachment'))
if os.path.exists(apprise_plugins_path):
datas.append((apprise_plugins_path, 'apprise/plugins'))
if os.path.exists(apprise_config_path):
datas.append((apprise_config_path, 'apprise/config'))
for subdir in ('attachment', 'plugins', 'config'):
p = os.path.join(apprise_path, subdir)
if os.path.exists(p):
datas.append((p, f'apprise/{subdir}'))
print(f"Added apprise data directories from: {apprise_path}")
except ImportError:
print("Warning: apprise not found, skipping apprise data files")
@@ -70,28 +78,20 @@ if os.path.exists(str(project_dir / 'tools')):
if os.path.exists(str(project_dir / 'assets')):
datas.append((str(project_dir / 'assets'), 'assets'))
# Ensure all frontend template files are included
if os.path.exists(str(project_dir / 'frontend')):
print(f"Including frontend directory at {str(project_dir / 'frontend')}")
# Make sure we include all frontend template files
datas.append((str(project_dir / 'frontend/templates'), 'templates'))
datas.append((str(project_dir / 'frontend/static'), 'static'))
# Add distribution/windows/resources for system tray and helpers
resources_dir = project_dir / 'distribution' / 'windows' / 'resources'
if resources_dir.exists():
datas.append((str(resources_dir), 'resources'))
print(f"Including Windows resources from: {resources_dir}")
# Explicitly check for the login template
login_template = project_dir / 'frontend/templates/login.html'
if os.path.exists(login_template):
print(f"Found login.html at {login_template}")
else:
print(f"WARNING: login.html not found at {login_template}")
# List all available templates for debugging
template_dir = project_dir / 'frontend/templates'
if os.path.exists(template_dir):
print("Available templates:")
for template_file in os.listdir(template_dir):
print(f" - {template_file}")
else:
print(f"WARNING: Template directory not found at {template_dir}")
# Debug: verify frontend files
template_dir = project_dir / 'frontend' / 'templates'
if template_dir.exists():
print("Available templates:")
for f in os.listdir(template_dir):
print(f" - {f}")
else:
print(f"WARNING: Template directory not found at {template_dir}")
a = Analysis(
[str(main_py_path)],
@@ -145,7 +145,7 @@ a = Analysis(
'concurrent.futures',
# Apprise notification support
'apprise',
'apprise.common',
'apprise.common',
'apprise.conversion',
'apprise.decorators',
'apprise.locale',
@@ -176,6 +176,9 @@ a = Analysis(
'cryptography.hazmat.primitives.ciphers',
'cryptography.hazmat.backends',
'cryptography.hazmat.backends.openssl',
# System tray support
'pystray',
'pystray._win32',
],
hookspath=[],
hooksconfig={},
@@ -199,13 +202,13 @@ exe = EXE(
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False, # Hide console window - Huntarr runs as background service
console=False, # Hide console window — runs as system tray app
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=str(project_dir / 'frontend/static/logo/huntarr.ico'),
icon=str(project_dir / 'frontend' / 'static' / 'logo' / 'huntarr.ico'),
)
coll = COLLECT(
@@ -213,6 +216,8 @@ coll = COLLECT(
a.binaries,
a.zipfiles,
a.datas,
frontend_templates,
frontend_static,
strip=False,
upx=True,
upx_exclude=[],

View File

@@ -89,15 +89,15 @@ Section "Huntarr Application (required)" SecCore
; Delete existing service if present (for upgrading from service to non-service)
nsExec::ExecToLog '"$INSTDIR\${EXENAME}" --remove-service'
; Copy all files from dist directory
!echo "Copying files from '${PROJECT_ROOT}\dist\Huntarr\*.*'"
File /r "${PROJECT_ROOT}\dist\Huntarr\*.*"
; Copy all files from dist directory (PyInstaller output)
!echo "Copying files from '${PROJECT_ROOT}\dist\Huntarr\'"
File /r "${PROJECT_ROOT}\dist\Huntarr\*"
; Copy version.txt file
!echo "Copying version.txt from '${PROJECT_ROOT}\version.txt'"
File "${PROJECT_ROOT}\version.txt"
; Create required directories
; Create required config directories (these are NOT in the PyInstaller bundle)
CreateDirectory "$INSTDIR\config"
CreateDirectory "$INSTDIR\config\logs"
CreateDirectory "$INSTDIR\config\stateful"
@@ -109,8 +109,6 @@ Section "Huntarr Application (required)" SecCore
CreateDirectory "$INSTDIR\config\tally"
CreateDirectory "$INSTDIR\config\eros"
CreateDirectory "$INSTDIR\logs"
CreateDirectory "$INSTDIR\frontend\templates"
CreateDirectory "$INSTDIR\frontend\static"
; Set permissions (using PowerShell to avoid quoting issues)
nsExec::ExecToLog 'powershell -Command "& {Set-Acl -Path \"$INSTDIR\config\" -AclObject (Get-Acl -Path \"$INSTDIR\config\")}"'

View File

@@ -1,199 +1,132 @@
"""
Windows System Tray Icon for Huntarr
Provides a system tray icon with menu options to control Huntarr
Provides a system tray icon with menu options to control Huntarr.
Runs in a daemon thread alongside the Waitress web server.
"""
import os
import sys
import threading
import webbrowser
import pystray
from PIL import Image
import logging
logger = logging.getLogger('HuntarrSystemTray')
def _safe_port():
"""Parse port from env with fallback. Avoids crash on invalid PORT."""
"""Parse port from env with fallback."""
try:
return int(os.environ.get("HUNTARR_PORT", os.environ.get("PORT", 9705)))
except (TypeError, ValueError):
return 9705
def _load_icon_image():
"""Load the Huntarr icon from bundled data files.
Search order:
1. _MEIPASS/frontend/static/logo/huntarr.ico (PyInstaller 6.x)
2. _MEIPASS/static/logo/huntarr.ico (legacy fallback)
3. exe_dir/frontend/static/logo/huntarr.ico (manual copy)
4. source tree relative path (dev mode)
Falls back to a generated 64x64 orange placeholder.
"""
from PIL import Image
candidates = []
if getattr(sys, 'frozen', False):
meipass = getattr(sys, '_MEIPASS', os.path.dirname(sys.executable))
exe_dir = os.path.dirname(sys.executable)
candidates += [
os.path.join(meipass, 'frontend', 'static', 'logo', 'huntarr.ico'),
os.path.join(meipass, 'static', 'logo', 'huntarr.ico'),
os.path.join(exe_dir, 'frontend', 'static', 'logo', 'huntarr.ico'),
]
else:
# Running from source
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
candidates.append(os.path.join(project_root, 'frontend', 'static', 'logo', 'huntarr.ico'))
for path in candidates:
if os.path.exists(path):
logger.info(f"Loading tray icon from: {path}")
return Image.open(path)
# Try PNG fallbacks
for base in candidates:
logo_dir = os.path.dirname(base)
for size in ('64', '48', '32'):
png = os.path.join(logo_dir, f'{size}.png')
if os.path.exists(png):
logger.info(f"Loading tray icon from: {png}")
return Image.open(png)
logger.warning("Huntarr icon not found, using placeholder")
return Image.new('RGB', (64, 64), color=(255, 127, 0))
class HuntarrSystemTray:
"""System tray icon for Huntarr on Windows"""
def __init__(self, port=None):
"""Initialize the system tray icon
Args:
port (int): Port number where Huntarr web interface is running.
If None, reads from HUNTARR_PORT or PORT env.
"""
"""System tray icon for Huntarr on Windows."""
def __init__(self, port=None, shutdown_callback=None):
self.port = port if port is not None else _safe_port()
self.icon = None
self.running = True
self.icon_thread = None
def create_icon_image(self):
"""Create or load the icon image for the system tray"""
try:
# Try to load the Huntarr icon from the static folder
if getattr(sys, 'frozen', False):
# Running as PyInstaller bundle
base_path = sys._MEIPASS
else:
# Running as script
base_path = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
icon_path = os.path.join(base_path, 'frontend', 'static', 'logo', 'huntarr.ico')
if os.path.exists(icon_path):
logger.info(f"Loading icon from: {icon_path}")
return Image.open(icon_path)
else:
# Fallback: Try PNG versions
for size in ['64', '48', '32']:
png_path = os.path.join(base_path, 'frontend', 'static', 'logo', f'{size}.png')
if os.path.exists(png_path):
logger.info(f"Loading icon from: {png_path}")
return Image.open(png_path)
# Final fallback: Create a simple icon
logger.warning("Could not find Huntarr icon, creating placeholder")
return self._create_placeholder_icon()
except Exception as e:
logger.error(f"Error loading icon: {e}")
return self._create_placeholder_icon()
def _create_placeholder_icon(self):
"""Create a simple placeholder icon"""
# Create a 64x64 orange/blue icon
img = Image.new('RGB', (64, 64), color=(255, 127, 0))
return img
def open_web_interface(self, icon=None, item=None):
"""Open the Huntarr web interface in the default browser"""
self._shutdown_callback = shutdown_callback
self._icon = None
self._thread = None
# -- Menu actions --
def _open_web(self, icon=None, item=None):
try:
url = f"http://localhost:{self.port}"
webbrowser.open(url)
logger.info(f"Opened web interface: {url}")
logger.info(f"Opened browser: {url}")
except Exception as e:
logger.error(f"Error opening web interface: {e}")
def show_about(self, icon=None, item=None):
"""Show about information (opens web interface)"""
self.open_web_interface()
def exit_app(self, icon=None, item=None):
"""Exit Huntarr application"""
logger.info("System tray exit requested")
self.running = False
if self.icon:
self.icon.stop()
# Signal the main application to shut down
try:
from primary.background import stop_event
if not stop_event.is_set():
stop_event.set()
logger.info("Stop event set for main application")
except Exception as e:
logger.error(f"Error signaling main application shutdown: {e}")
# Give threads time to clean up
import time
time.sleep(1)
# Force exit if needed
os._exit(0)
def create_menu(self):
"""Create the system tray context menu"""
return pystray.Menu(
pystray.MenuItem(
"Open Huntarr",
self.open_web_interface,
default=True
),
pystray.MenuItem(
"About Huntarr",
self.show_about
),
pystray.Menu.SEPARATOR,
pystray.MenuItem(
"Exit",
self.exit_app
)
)
def run(self):
"""Run the system tray icon (blocking)"""
try:
logger.info("Starting system tray icon...")
# Create the icon
image = self.create_icon_image()
menu = self.create_menu()
self.icon = pystray.Icon(
"Huntarr",
image,
"Huntarr - Media Management",
menu
)
# Run the icon (this is blocking)
logger.info("System tray icon running")
self.icon.run()
except Exception as e:
logger.error(f"Error running system tray icon: {e}")
logger.exception(e)
logger.error(f"Error opening browser: {e}")
def _exit_app(self, icon=None, item=None):
logger.info("Exit requested from system tray")
self.stop()
if self._shutdown_callback:
try:
self._shutdown_callback()
except Exception as e:
logger.error(f"Shutdown callback error: {e}")
# -- Lifecycle --
def start(self):
"""Start the system tray icon in a separate thread"""
try:
logger.info("Starting system tray in background thread...")
self.icon_thread = threading.Thread(
target=self.run,
name="SystemTrayThread",
daemon=True
)
self.icon_thread.start()
logger.info("System tray thread started")
return True
except Exception as e:
logger.error(f"Error starting system tray thread: {e}")
return False
"""Start the tray icon in a daemon thread. Non-blocking."""
self._thread = threading.Thread(target=self._run, name="SystemTrayThread", daemon=True)
self._thread.start()
logger.info("System tray thread started")
def stop(self):
"""Stop the system tray icon"""
"""Stop the tray icon."""
if self._icon:
try:
self._icon.stop()
except Exception:
pass
logger.info("System tray icon stopped")
def _run(self):
try:
self.running = False
if self.icon:
self.icon.stop()
logger.info("System tray icon stopped")
import pystray
image = _load_icon_image()
menu = pystray.Menu(
pystray.MenuItem("Open Huntarr", self._open_web, default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Exit", self._exit_app),
)
self._icon = pystray.Icon("Huntarr", image, "Huntarr", menu)
logger.info("System tray icon running")
self._icon.run() # blocking
except Exception as e:
logger.error(f"Error stopping system tray: {e}")
logger.error(f"System tray error: {e}", exc_info=True)
def create_system_tray(port=None):
"""Create and return a system tray instance
Args:
port (int): Port number where Huntarr is running
Returns:
HuntarrSystemTray: System tray instance
"""
return HuntarrSystemTray(port=port)
if __name__ == '__main__':
# Test the system tray
logging.basicConfig(level=logging.INFO)
tray = HuntarrSystemTray()
tray.run() # Blocking call for testing
def create_system_tray(port=None, shutdown_callback=None):
"""Factory function. Returns a HuntarrSystemTray instance."""
return HuntarrSystemTray(port=port, shutdown_callback=shutdown_callback)

35
main.py
View File

@@ -235,9 +235,30 @@ def run_web_server():
web_logger.info(f"Starting web server on {host}:{port} (Debug: {debug_mode})...")
# TODO: System tray implementation temporarily disabled
# Will be re-enabled in a future update after thorough testing
# For now, console=False in PyInstaller spec provides silent background operation
# Start Windows system tray icon (non-debug, Windows-only, frozen builds)
_system_tray = None
if (not debug_mode
and sys.platform == 'win32'
and getattr(sys, 'frozen', False)):
try:
# Import from bundled resources or source tree
try:
from resources.system_tray import create_system_tray
except ImportError:
from distribution.windows.resources.system_tray import create_system_tray
def _tray_shutdown():
"""Called when user clicks Exit in the tray menu."""
if not stop_event.is_set():
stop_event.set()
if not shutdown_requested.is_set():
shutdown_requested.set()
_system_tray = create_system_tray(port=port, shutdown_callback=_tray_shutdown)
_system_tray.start()
web_logger.info("Windows system tray icon initialized")
except Exception as e:
web_logger.warning(f"System tray not available: {e}")
# Log the current authentication mode once at startup
try:
@@ -308,6 +329,14 @@ def run_web_server():
# Shutdown sequence
web_logger.info("Shutdown signal received. Stopping Waitress server...")
# Stop system tray icon
if _system_tray:
try:
_system_tray.stop()
except Exception:
pass
try:
waitress_server.close()
web_logger.info("Waitress server close() called.")

View File

@@ -18,4 +18,4 @@ urllib3>=2.6.0 # CVE-2025-66418, CVE-2025-66471: unbounded decompression DoS
idna>=3.7 # CVE-2024-3651: encode() quadratic DoS
Pillow>=10.3.0 # CVE-2024-28219: buffer overflow in _imagingcms.c
# pystray==0.19.5; sys_platform == 'win32' # For Windows system tray icon (disabled - needs more testing)
pystray==0.19.5; sys_platform == 'win32' # Windows system tray icon

View File

@@ -72,17 +72,33 @@ def integrate_windows_helpers(app=None):
# Ensure paths are properly configured
if app:
# Basic template and static path detection for Windows
base_dir = Path(sys.executable).parent
template_dir = base_dir / "frontend" / "templates"
static_dir = base_dir / "frontend" / "static"
# PyInstaller 6.x: data files live under _MEIPASS (_internal/)
meipass = getattr(sys, '_MEIPASS', None)
base_dir = Path(meipass) if meipass else Path(sys.executable).parent
if template_dir.exists():
logger.info(f"Setting Flask template folder to: {template_dir}")
app.template_folder = str(template_dir)
# Check _MEIPASS paths first, then exe_dir fallbacks
template_candidates = [
base_dir / "frontend" / "templates",
base_dir / "templates",
Path(sys.executable).parent / "frontend" / "templates",
]
static_candidates = [
base_dir / "frontend" / "static",
base_dir / "static",
Path(sys.executable).parent / "frontend" / "static",
]
if static_dir.exists():
logger.info(f"Setting Flask static folder to: {static_dir}")
app.static_folder = str(static_dir)
for td in template_candidates:
if td.exists():
logger.info(f"Setting Flask template folder to: {td}")
app.template_folder = str(td)
break
for sd in static_candidates:
if sd.exists():
logger.info(f"Setting Flask static folder to: {sd}")
app.static_folder = str(sd)
break
else:
# When running from source code, check if the helper is in the distribution directory
project_root = Path(__file__).parent.parent.parent.parent

View File

@@ -67,17 +67,24 @@ log.setLevel(logging.DEBUG) # Change to DEBUG to see all Flask/Werkzeug logs
# Configure template and static paths with proper PyInstaller support
if getattr(sys, 'frozen', False):
# PyInstaller sets this attribute - use paths relative to the executable
base_path = os.path.dirname(sys.executable)
# Path candidates for MacOS app bundles and other PyInstaller formats
# PyInstaller 6.x puts data files under _internal/ (sys._MEIPASS).
# sys.executable points to the .exe itself; data files are NOT next to it.
exe_dir = os.path.dirname(sys.executable)
meipass = getattr(sys, '_MEIPASS', exe_dir)
print(f"PyInstaller mode - exe_dir: {exe_dir}, _MEIPASS: {meipass}")
# Search candidates in priority order.
# _MEIPASS paths first (PyInstaller 6.x), then exe_dir fallbacks,
# then macOS .app bundle Resources paths.
template_candidates = [
os.path.join(base_path, 'templates'), # Direct templates folder
os.path.join(base_path, '..', 'Resources', 'frontend', 'templates'), # Mac app bundle Resources path
os.path.join(base_path, 'frontend', 'templates'), # Alternate structure
os.path.join(os.path.dirname(base_path), 'Resources', 'frontend', 'templates') # Mac app bundle with different path
os.path.join(meipass, 'frontend', 'templates'), # PyInstaller 6.x: _internal/frontend/templates
os.path.join(meipass, 'templates'), # PyInstaller 6.x: _internal/templates (duplicate datas entry)
os.path.join(exe_dir, 'frontend', 'templates'), # Legacy / manual copy beside .exe
os.path.join(exe_dir, 'templates'), # Legacy flat layout
os.path.join(exe_dir, '..', 'Resources', 'frontend', 'templates'), # macOS .app bundle
os.path.join(os.path.dirname(exe_dir), 'Resources', 'frontend', 'templates'),
]
# Find the first existing templates directory
template_dir = None
for candidate in template_candidates:
candidate_path = os.path.abspath(candidate)
@@ -90,16 +97,16 @@ if getattr(sys, 'frozen', False):
break
else:
print(f"Warning: setup.html not found in {template_dir}")
# Similar approach for static files
static_candidates = [
os.path.join(base_path, 'static'),
os.path.join(base_path, '..', 'Resources', 'frontend', 'static'),
os.path.join(base_path, 'frontend', 'static'),
os.path.join(os.path.dirname(base_path), 'Resources', 'frontend', 'static')
os.path.join(meipass, 'frontend', 'static'),
os.path.join(meipass, 'static'),
os.path.join(exe_dir, 'frontend', 'static'),
os.path.join(exe_dir, 'static'),
os.path.join(exe_dir, '..', 'Resources', 'frontend', 'static'),
os.path.join(os.path.dirname(exe_dir), 'Resources', 'frontend', 'static'),
]
# Find the first existing static directory
static_dir = None
for candidate in static_candidates:
candidate_path = os.path.abspath(candidate)
@@ -107,16 +114,15 @@ if getattr(sys, 'frozen', False):
static_dir = candidate_path
print(f"Found valid static directory: {static_dir}")
break
# If no valid directories found, use defaults
if not template_dir:
template_dir = os.path.join(base_path, 'templates')
template_dir = os.path.join(meipass, 'frontend', 'templates')
print(f"Warning: Using default template dir: {template_dir}")
if not static_dir:
static_dir = os.path.join(base_path, 'static')
static_dir = os.path.join(meipass, 'frontend', 'static')
print(f"Warning: Using default static dir: {static_dir}")
print(f"PyInstaller mode - Using template dir: {template_dir}")
print(f"PyInstaller mode - Using static dir: {static_dir}")
print(f"Template dir exists: {os.path.exists(template_dir)}")