mirror of
https://github.com/plexguide/Huntarr.io.git
synced 2026-05-19 17:54:27 -04:00
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:
12
.github/workflows/windows-build-nsis.yml
vendored
12
.github/workflows/windows-build-nsis.yml
vendored
@@ -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
|
||||
|
||||
@@ -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=[],
|
||||
|
||||
@@ -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\")}"'
|
||||
|
||||
@@ -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
35
main.py
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
Reference in New Issue
Block a user