fix: tray main-thread message loop, numpy <2.0 pin, installer config copy
- 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
This commit is contained in:
+10
-3
@@ -74,17 +74,24 @@ WIN_DEPS=(
|
|||||||
# Visualizer dependencies
|
# Visualizer dependencies
|
||||||
VIS_DEPS=(
|
VIS_DEPS=(
|
||||||
"soundcard>=0.4.0"
|
"soundcard>=0.4.0"
|
||||||
"numpy>=1.24.0"
|
"numpy>=1.24.0,<2.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}")
|
ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}")
|
||||||
|
|
||||||
for dep in "${ALL_DEPS[@]}"; do
|
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}" \
|
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
|
||||||
--implementation cp --only-binary :all: \
|
--implementation cp --only-binary :all: \
|
||||||
"$dep" 2>/dev/null || \
|
"$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
|
done
|
||||||
|
|
||||||
# Install wheels into site-packages
|
# Install wheels into site-packages
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ Section "!Core (required)" SecCore
|
|||||||
; Copy entire distribution
|
; Copy entire distribution
|
||||||
File /r "dist\media-server\*.*"
|
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
|
; Create uninstaller
|
||||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
|||||||
+36
-9
@@ -51,6 +51,9 @@ def setup_logging():
|
|||||||
handlers=[handler],
|
handlers=[handler],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Suppress noisy third-party loggers
|
||||||
|
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@@ -240,23 +243,47 @@ def main():
|
|||||||
print("\nAuthentication is DISABLED (no tokens configured)")
|
print("\nAuthentication is DISABLED (no tokens configured)")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Start system tray icon (unless disabled)
|
from .tray import PYSTRAY_AVAILABLE, TrayManager
|
||||||
tray_icon = None
|
|
||||||
if not args.no_tray:
|
|
||||||
from .tray import start_tray
|
|
||||||
|
|
||||||
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(
|
uvicorn.run(
|
||||||
"media_server.main:app",
|
"media_server.main:app",
|
||||||
host=args.host,
|
host=args.host,
|
||||||
port=args.port,
|
port=args.port,
|
||||||
reload=False,
|
reload=False,
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
if tray_icon is not None:
|
|
||||||
tray_icon.stop()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+76
-64
@@ -1,13 +1,13 @@
|
|||||||
"""System tray icon for Media Server."""
|
"""System tray icon for Media Server."""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
@@ -16,28 +16,27 @@ logger = logging.getLogger(__name__)
|
|||||||
# pystray is optional — tray silently disabled when missing
|
# pystray is optional — tray silently disabled when missing
|
||||||
try:
|
try:
|
||||||
import pystray
|
import pystray
|
||||||
|
|
||||||
|
PYSTRAY_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pystray = None
|
pystray = None
|
||||||
|
PYSTRAY_AVAILABLE = False
|
||||||
|
|
||||||
# tkinter is optional — used for confirmation dialogs
|
|
||||||
try:
|
# Windows-native confirmation (no tkinter needed)
|
||||||
import tkinter as tk
|
_MB_YESNO = 0x04
|
||||||
from tkinter import messagebox
|
_MB_ICONQUESTION = 0x20
|
||||||
except ImportError:
|
_MB_TOPMOST = 0x40000
|
||||||
tk = None
|
_MB_SETFOREGROUND = 0x10000
|
||||||
messagebox = None
|
_IDYES = 6
|
||||||
|
|
||||||
|
|
||||||
def _confirm(title: str, message: str) -> bool:
|
def _confirm(title: str, message: str) -> bool:
|
||||||
"""Show a Yes/No confirmation dialog. Returns True if user clicks Yes."""
|
"""Show a Yes/No dialog using native Windows MessageBox."""
|
||||||
if messagebox is None:
|
result = ctypes.windll.user32.MessageBoxW(
|
||||||
return True # No tkinter — skip confirmation
|
0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND
|
||||||
root = tk.Tk()
|
)
|
||||||
root.withdraw()
|
return result == _IDYES
|
||||||
root.attributes("-topmost", True)
|
|
||||||
result = messagebox.askyesno(title, message, parent=root)
|
|
||||||
root.destroy()
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _create_icon_image(size: int = 64) -> Image.Image:
|
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:
|
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:
|
try:
|
||||||
import cairosvg
|
import cairosvg
|
||||||
|
|
||||||
svg_path = os.path.join(
|
svg_path = icons_dir / "icon.svg"
|
||||||
os.path.dirname(__file__), "static", "icons", "icon.svg"
|
if svg_path.exists():
|
||||||
)
|
png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64)
|
||||||
if os.path.exists(svg_path):
|
|
||||||
png_data = cairosvg.svg2png(url=svg_path, output_width=64, output_height=64)
|
|
||||||
return Image.open(io.BytesIO(png_data))
|
return Image.open(io.BytesIO(png_data))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -82,53 +90,57 @@ def _load_icon_image() -> Image.Image:
|
|||||||
return _create_icon_image()
|
return _create_icon_image()
|
||||||
|
|
||||||
|
|
||||||
def start_tray(host: str, port: int) -> "pystray.Icon | None":
|
class TrayManager:
|
||||||
"""Start system tray icon in a background thread.
|
"""Manages the system tray icon and its context menu.
|
||||||
|
|
||||||
Returns the Icon instance (call icon.stop() to remove), or None if
|
Call ``run()`` on the **main thread** — it blocks until ``stop()``
|
||||||
pystray is not installed.
|
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):
|
self._port = port
|
||||||
webbrowser.open(url)
|
self._on_exit = on_exit
|
||||||
|
|
||||||
def on_restart(_icon, _item):
|
menu = pystray.Menu(
|
||||||
if not _confirm("Restart Media Server", "Are you sure you want to restart the server?"):
|
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
|
return
|
||||||
logger.info("Restart requested from tray")
|
logger.info("Restart requested from tray")
|
||||||
_icon.stop()
|
self._icon.stop()
|
||||||
# Re-launch the same process, then exit current
|
self._on_exit()
|
||||||
subprocess.Popen([sys.executable] + sys.argv)
|
os.environ["MEDIA_SERVER_RESTART"] = "1"
|
||||||
os.kill(os.getpid(), signal.SIGINT)
|
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||||
|
|
||||||
def on_exit(_icon, _item):
|
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
|
||||||
if not _confirm("Exit Media Server", "Are you sure you want to shut down the server?"):
|
if not _confirm("Media Server", "Shut down the server?"):
|
||||||
return
|
return
|
||||||
logger.info("Exit requested from tray")
|
logger.info("Shutdown requested from tray")
|
||||||
_icon.stop()
|
self._on_exit()
|
||||||
os.kill(os.getpid(), signal.SIGINT)
|
self._icon.stop()
|
||||||
|
|
||||||
menu = pystray.Menu(
|
def run(self) -> None:
|
||||||
pystray.MenuItem("Show UI", on_show_ui, default=True),
|
"""Block the calling thread running the tray message loop."""
|
||||||
pystray.Menu.SEPARATOR,
|
self._icon.run()
|
||||||
pystray.MenuItem("Restart", on_restart),
|
|
||||||
pystray.MenuItem("Shutdown", on_exit),
|
|
||||||
)
|
|
||||||
|
|
||||||
icon = pystray.Icon(
|
def stop(self) -> None:
|
||||||
name="media-server",
|
"""Stop the tray icon from any thread."""
|
||||||
icon=_load_icon_image(),
|
self._icon.stop()
|
||||||
title="Media Server",
|
|
||||||
menu=menu,
|
|
||||||
)
|
|
||||||
|
|
||||||
thread = threading.Thread(target=icon.run, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
logger.info("System tray icon started")
|
|
||||||
|
|
||||||
return icon
|
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ windows = [
|
|||||||
]
|
]
|
||||||
visualizer = [
|
visualizer = [
|
||||||
"soundcard>=0.4.0",
|
"soundcard>=0.4.0",
|
||||||
"numpy>=1.24.0",
|
"numpy>=1.24.0,<2.0",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user