From 402183765cab695c62fed89e5bff0fde4ddccdf5 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 15:05:36 +0300 Subject: [PATCH] fix: tray main-thread message loop, numpy <2.0 pin, installer config copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite tray to run on main thread (pystray owns message loop, uvicorn in background thread) — fixes unresponsive confirmation dialogs - Use native Windows MessageBoxW instead of tkinter (embedded Python has no tkinter) - Pin numpy <2.0 to fix soundcard's numpy.fromstring (removed in 2.0) - Strip transitive numpy 2.x wheels in build script - Installer copies config.example.yaml as config.yaml on fresh install - Suppress noisy screen_brightness_control warnings --- build-dist-windows.sh | 13 +++- installer.nsi | 4 ++ media_server/main.py | 45 +++++++++++--- media_server/tray.py | 140 +++++++++++++++++++++++------------------- pyproject.toml | 2 +- 5 files changed, 127 insertions(+), 77 deletions(-) diff --git a/build-dist-windows.sh b/build-dist-windows.sh index 0932630..f471c64 100644 --- a/build-dist-windows.sh +++ b/build-dist-windows.sh @@ -74,17 +74,24 @@ WIN_DEPS=( # Visualizer dependencies VIS_DEPS=( "soundcard>=0.4.0" - "numpy>=1.24.0" + "numpy>=1.24.0,<2.0" ) ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}") for dep in "${ALL_DEPS[@]}"; do - pip download --quiet --dest "$WHEEL_DIR" \ + pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \ --platform win_amd64 --python-version "${PYTHON_SHORT}" \ --implementation cp --only-binary :all: \ "$dep" 2>/dev/null || \ - pip download --quiet --dest "$WHEEL_DIR" "$dep" + pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \ + --python-version "${PYTHON_SHORT}" \ + "$dep" +done + +# Remove numpy 2.x wheels pulled as transitive deps (soundcard requires <2.0) +for f in "$WHEEL_DIR"/numpy-2*; do + [ -f "$f" ] && echo "Removing incompatible: $(basename "$f")" && rm "$f" done # Install wheels into site-packages diff --git a/installer.nsi b/installer.nsi index 0a460c0..b8c1b81 100644 --- a/installer.nsi +++ b/installer.nsi @@ -73,6 +73,10 @@ Section "!Core (required)" SecCore ; Copy entire distribution File /r "dist\media-server\*.*" + ; Create config.yaml from example if it doesn't already exist (preserve user config on upgrade) + IfFileExists "$INSTDIR\config.yaml" +2 + CopyFiles /SILENT "$INSTDIR\config.example.yaml" "$INSTDIR\config.yaml" + ; Create uninstaller WriteUninstaller "$INSTDIR\uninstall.exe" diff --git a/media_server/main.py b/media_server/main.py index 15880c9..f472969 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -51,6 +51,9 @@ def setup_logging(): handlers=[handler], ) + # Suppress noisy third-party loggers + logging.getLogger("screen_brightness_control").setLevel(logging.ERROR) + @asynccontextmanager async def lifespan(app: FastAPI): @@ -240,23 +243,47 @@ def main(): print("\nAuthentication is DISABLED (no tokens configured)") return - # Start system tray icon (unless disabled) - tray_icon = None - if not args.no_tray: - from .tray import start_tray + from .tray import PYSTRAY_AVAILABLE, TrayManager - tray_icon = start_tray(args.host, args.port) + use_tray = PYSTRAY_AVAILABLE and not args.no_tray - try: + if use_tray: + import asyncio + import threading + + # Run uvicorn in a background thread so tray owns the main thread message loop + uv_config = uvicorn.Config( + "media_server.main:app", + host=args.host, + port=args.port, + log_level=settings.log_level.lower(), + ) + server = uvicorn.Server(uv_config) + + def run_server(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(server.serve()) + + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + + # Tray on main thread (blocking) + tray = TrayManager( + port=args.port, + on_exit=lambda: setattr(server, "should_exit", True), + ) + tray.run() + + # Tray exited — wait for server to finish graceful shutdown + server_thread.join(timeout=10) + else: uvicorn.run( "media_server.main:app", host=args.host, port=args.port, reload=False, ) - finally: - if tray_icon is not None: - tray_icon.stop() if __name__ == "__main__": diff --git a/media_server/tray.py b/media_server/tray.py index 9cf6d08..93ef8b3 100644 --- a/media_server/tray.py +++ b/media_server/tray.py @@ -1,13 +1,13 @@ """System tray icon for Media Server.""" +import ctypes import io import logging import os -import signal -import subprocess import sys -import threading import webbrowser +from pathlib import Path +from typing import Callable from PIL import Image, ImageDraw @@ -16,28 +16,27 @@ logger = logging.getLogger(__name__) # pystray is optional — tray silently disabled when missing try: import pystray + + PYSTRAY_AVAILABLE = True except ImportError: pystray = None + PYSTRAY_AVAILABLE = False -# tkinter is optional — used for confirmation dialogs -try: - import tkinter as tk - from tkinter import messagebox -except ImportError: - tk = None - messagebox = None + +# Windows-native confirmation (no tkinter needed) +_MB_YESNO = 0x04 +_MB_ICONQUESTION = 0x20 +_MB_TOPMOST = 0x40000 +_MB_SETFOREGROUND = 0x10000 +_IDYES = 6 def _confirm(title: str, message: str) -> bool: - """Show a Yes/No confirmation dialog. Returns True if user clicks Yes.""" - if messagebox is None: - return True # No tkinter — skip confirmation - root = tk.Tk() - root.withdraw() - root.attributes("-topmost", True) - result = messagebox.askyesno(title, message, parent=root) - root.destroy() - return result + """Show a Yes/No dialog using native Windows MessageBox.""" + result = ctypes.windll.user32.MessageBoxW( + 0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND + ) + return result == _IDYES def _create_icon_image(size: int = 64) -> Image.Image: @@ -66,15 +65,24 @@ def _create_icon_image(size: int = 64) -> Image.Image: def _load_icon_image() -> Image.Image: - """Load the SVG app icon, falling back to a generated image.""" + """Load the ICO/SVG app icon, falling back to a generated image.""" + icons_dir = Path(__file__).parent / "static" / "icons" + + # Try .ico first (best for Windows tray) + ico_path = icons_dir / "icon.ico" + if ico_path.exists(): + try: + return Image.open(ico_path) + except Exception: + pass + + # Try SVG via cairosvg try: import cairosvg - svg_path = os.path.join( - os.path.dirname(__file__), "static", "icons", "icon.svg" - ) - if os.path.exists(svg_path): - png_data = cairosvg.svg2png(url=svg_path, output_width=64, output_height=64) + svg_path = icons_dir / "icon.svg" + if svg_path.exists(): + png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64) return Image.open(io.BytesIO(png_data)) except Exception: pass @@ -82,53 +90,57 @@ def _load_icon_image() -> Image.Image: return _create_icon_image() -def start_tray(host: str, port: int) -> "pystray.Icon | None": - """Start system tray icon in a background thread. +class TrayManager: + """Manages the system tray icon and its context menu. - Returns the Icon instance (call icon.stop() to remove), or None if - pystray is not installed. + Call ``run()`` on the **main thread** — it blocks until ``stop()`` + is called (from any thread) or the user picks *Shutdown* from the menu. """ - if pystray is None: - logger.info("pystray not installed — tray icon disabled") - return None - url = f"http://{'localhost' if host == '0.0.0.0' else host}:{port}" + def __init__(self, port: int, on_exit: Callable[[], None]) -> None: + if not PYSTRAY_AVAILABLE: + raise ImportError("pystray is required for system tray support") - def on_show_ui(_icon, _item): - webbrowser.open(url) + self._port = port + self._on_exit = on_exit - def on_restart(_icon, _item): - if not _confirm("Restart Media Server", "Are you sure you want to restart the server?"): + menu = pystray.Menu( + pystray.MenuItem("Show UI", self._show_ui, default=True), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Restart", self._restart), + pystray.MenuItem("Shutdown", self._shutdown), + ) + + self._icon = pystray.Icon( + name="media-server", + icon=_load_icon_image(), + title="Media Server", + menu=menu, + ) + + def _show_ui(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None: + webbrowser.open(f"http://localhost:{self._port}") + + def _restart(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None: + if not _confirm("Media Server", "Restart the server?"): return logger.info("Restart requested from tray") - _icon.stop() - # Re-launch the same process, then exit current - subprocess.Popen([sys.executable] + sys.argv) - os.kill(os.getpid(), signal.SIGINT) + self._icon.stop() + self._on_exit() + os.environ["MEDIA_SERVER_RESTART"] = "1" + os.execv(sys.executable, [sys.executable] + sys.argv) - def on_exit(_icon, _item): - if not _confirm("Exit Media Server", "Are you sure you want to shut down the server?"): + def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None: + if not _confirm("Media Server", "Shut down the server?"): return - logger.info("Exit requested from tray") - _icon.stop() - os.kill(os.getpid(), signal.SIGINT) + logger.info("Shutdown requested from tray") + self._on_exit() + self._icon.stop() - menu = pystray.Menu( - pystray.MenuItem("Show UI", on_show_ui, default=True), - pystray.Menu.SEPARATOR, - pystray.MenuItem("Restart", on_restart), - pystray.MenuItem("Shutdown", on_exit), - ) + def run(self) -> None: + """Block the calling thread running the tray message loop.""" + self._icon.run() - icon = pystray.Icon( - name="media-server", - icon=_load_icon_image(), - title="Media Server", - menu=menu, - ) - - thread = threading.Thread(target=icon.run, daemon=True) - thread.start() - logger.info("System tray icon started") - - return icon + def stop(self) -> None: + """Stop the tray icon from any thread.""" + self._icon.stop() diff --git a/pyproject.toml b/pyproject.toml index b847b98..0b7d2b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ windows = [ ] visualizer = [ "soundcard>=0.4.0", - "numpy>=1.24.0", + "numpy>=1.24.0,<2.0", ] dev = [ "pytest>=7.0",