Files
alexei.dolgolyov d131ba461c
Lint & Test / test (push) Successful in 20s
fix: production-readiness hardening — security, perf, a11y, observability
Security
- Default scripts_management, callbacks_management, links_management, and
  media_folders_management to False so a leaked token cannot escalate to RCE
  through admin CRUD endpoints.
- TokenSpec + scope hierarchy (read | control | admin); legacy bare-string
  api_tokens entries promote to admin for back-compat. Management endpoints
  now require admin scope.
- WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>)
  preferred over ?token= query so the token no longer lands in URL/history/
  Referer; query fallback retained for HA integration back-compat.
- Origin allow-list check on the WS endpoint (CSWSH defence).
- In-process token-bucket rate limiter: 5/min for failed auths,
  10/min for /api/scripts/execute and /api/callbacks/execute.
- shell=False subprocess path (shlex.split) + per-parameter regex `pattern`
  in ScriptParameterConfig to harden shell=true scripts against parameter
  injection (Windows cmd.exe env-var expansion).
- CSP gains form-action, worker-src, manifest-src directives.
- Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access
  logs; validate Gitea release tag against strict SemVer regex.
- noopener noreferrer + no-referrer referrerpolicy on every outbound link.
- icacls hardening of config.yaml on Windows (current user + SYSTEM +
  Administrators only); 0600 still enforced on POSIX.
- WS volume handler clamps input and never drops the socket on bad messages.

Performance
- Album-art read in windows_media gated by track key — was decoding the
  WinRT thumbnail twice per second regardless of track changes.
- /api/media/artwork returns content-derived ETag + Cache-Control so the
  browser sends If-None-Match and gets 304s on track repeats.
- Foreground-service ctypes argtypes hoisted to one-time module init
  (was re-declaring ~14 prototypes per probe).
- display_service _static_cache keyed by (edid_hash, ...) tuple with
  eviction of disappeared monitors — fixes stale capabilities on hot-plug
  swaps where the new topology has the same monitor count.
- Visualizer rAF loop paused on document.hidden, resumed on visible.

Reliability / bug fixes
- Lifespan rewritten as try/yield/finally so a partial-startup failure
  cannot orphan background tasks or executors.
- _run_callback in routes/media.py keeps a strong task ref (GC-safe) and
  uses the dedicated callback executor instead of the default pool.
- macos_media.set_volume() no longer always returns True.
- TrayManager._restart_requested initialised in __init__; set before
  signalling exit so the main thread observes it correctly.
- Missing static_dir now logs a WARNING instead of silent UI disable.

UX / accessibility / PWA
- manifest.json theme_color and background_color match the Studio Reference
  base (#0E0D0B); added id and scope for PWA installability.
- ARIA on mini-player icon buttons; inner SVGs marked aria-hidden.
- OS mediaSession API wired so headset / lockscreen / Bluetooth buttons
  drive play/pause/next/prev/seek and show track metadata + artwork.

Observability
- X-Request-ID middleware (accept upstream id if it matches a safe regex,
  otherwise UUID4); request_id_var added to ContextVars and included in
  every log line alongside the token label.
- Audit log (append-only JSONL) for every script + callback execution,
  including the on_play/on_pause/etc. event callbacks. Background-thread
  writer; queue capped; flushed in lifespan teardown.

Deployment
- proxy_headers + forwarded_allow_ips plumbed through Settings →
  uvicorn.Config for reverse-proxy installs.
- HTTPS support via ssl_certfile + ssl_keyfile (+ optional password);
  startup refuses to launch with only one of the pair set.
- Thumbnail cache moved from project-root .cache to
  %LOCALAPPDATA%/media-server/cache (Windows) and
  $XDG_CACHE_HOME/media-server/thumbnails (POSIX).

Tests
- 35 new tests across auth scopes, rate limiter, browser path traversal
  (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag
  whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
2026-05-22 22:25:54 +03:00

154 lines
4.4 KiB
Python

"""System tray icon for Media Server."""
import ctypes
import io
import logging
import webbrowser
from pathlib import Path
from typing import Callable
from PIL import Image, ImageDraw
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
# 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 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:
"""Create a tray icon: green circle with white play triangle."""
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Green circle background
padding = 2
draw.ellipse(
[padding, padding, size - padding, size - padding],
fill=(29, 185, 84, 255),
)
# White play triangle
cx, cy = size // 2, size // 2
r = size * 0.28
triangle = [
(cx - r * 0.6, cy - r),
(cx - r * 0.6, cy + r),
(cx + r * 0.9, cy),
]
draw.polygon(triangle, fill=(255, 255, 255, 255))
return img
def _load_icon_image() -> Image.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 = 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
return _create_icon_image()
class TrayManager:
"""Manages the system tray icon and its context menu.
Call ``run()`` on the **main thread** — it blocks until ``stop()``
is called (from any thread) or the user picks *Shutdown* from the menu.
"""
def __init__(self, port: int, on_exit: Callable[[], None]) -> None:
if not PYSTRAY_AVAILABLE:
raise ImportError("pystray is required for system tray support")
self._port = port
self._on_exit = on_exit
# Initialize so the property and any cross-thread reader cannot ever
# observe an uninitialized attribute. Set before _on_exit() fires.
self._restart_requested = False
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")
# Set the flag BEFORE signalling exit so the main thread observes it
# when it wakes from server_thread.join() — order matters across the
# tray/uvicorn handoff.
self._restart_requested = True
self._on_exit()
self._icon.stop()
@property
def restart_requested(self) -> bool:
return self._restart_requested
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
if not _confirm("Media Server", "Shut down the server?"):
return
logger.info("Shutdown requested from tray")
self._on_exit()
self._icon.stop()
def run(self) -> None:
"""Block the calling thread running the tray message loop."""
self._icon.run()
def stop(self) -> None:
"""Stop the tray icon from any thread."""
self._icon.stop()