fix: comprehensive security, bug, performance, and UI/UX audit
Lint & Test / test (push) Successful in 20s

Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
  and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
  paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
  thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, X-Frame-Options DENY, Referrer-Policy no-referrer,
  X-Content-Type-Options nosniff
- CORS locked to localhost:<port> + 127.0.0.1:<port> by default; configurable
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
  kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
  start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
  delegated data-action handler; remote MDI SVGs parsed and sanitized
  (strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent

Bugs
- WebSocket reconnect: close previous socket before opening new, clear
  ping interval per-socket, clear reconnectTimeout up-front, retry on
  online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
  background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
  spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
  checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip

Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
  asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
  threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
  — cache grew unbounded)
- Progress drag listeners attached only while dragging

Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
  _upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
  clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
  pip install -e . users see the source-of-truth version, not the stale
  package-metadata version baked in at install time

UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
  copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
  chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
  callbacks.empty in both en + ru
This commit is contained in:
2026-05-16 13:22:46 +03:00
parent 770bba7e60
commit bcc6d40ed7
28 changed files with 1063 additions and 876 deletions
+67 -79
View File
@@ -15,6 +15,22 @@ logger = logging.getLogger(__name__)
# Thread pool for WinRT operations (they don't play well with asyncio)
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
# Cache an asyncio event loop per worker thread so the 500ms status poll
# doesn't allocate + tear down a new loop on every tick. Creating a loop
# every 0.5s churns CPU and leaks finalized loop references that linger in
# WinRT callbacks. With this helper a thread reuses one loop forever and
# we only pay the setup cost once per worker.
_thread_local = threading.local()
def _thread_loop() -> asyncio.AbstractEventLoop:
loop = getattr(_thread_local, "loop", None)
if loop is None or loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
_thread_local.loop = loop
return loop
# Global storage for current album art (as bytes)
_current_album_art_bytes: bytes | None = None
@@ -161,8 +177,6 @@ WINDOWS_AVAILABLE = WINSDK_AVAILABLE
def _sync_get_media_status() -> dict[str, Any]:
"""Synchronously get media status (runs in thread pool)."""
import asyncio
result = {
"state": "idle",
"title": None,
@@ -174,9 +188,7 @@ def _sync_get_media_status() -> dict[str, Any]:
}
try:
# Create a new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop = _thread_loop()
try:
# Get media session manager
@@ -393,7 +405,8 @@ def _sync_get_media_status() -> dict[str, Any]:
result["source"] = session.source_app_user_model_id
finally:
loop.close()
# Reuse the loop across calls — see _thread_loop above.
pass
except Exception as e:
logger.error(f"Error getting media status: {e}")
@@ -439,35 +452,28 @@ def _find_best_session(manager, loop):
def _sync_media_command(command: str) -> bool:
"""Synchronously execute a media command (runs in thread pool)."""
import asyncio
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return False
session = _find_best_session(manager, loop)
if session is None:
return False
if command == "play":
return loop.run_until_complete(session.try_play_async())
elif command == "pause":
return loop.run_until_complete(session.try_pause_async())
elif command == "stop":
return loop.run_until_complete(session.try_stop_async())
elif command == "next":
return loop.run_until_complete(session.try_skip_next_async())
elif command == "previous":
return loop.run_until_complete(session.try_skip_previous_async())
loop = _thread_loop()
manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return False
finally:
loop.close()
session = _find_best_session(manager, loop)
if session is None:
return False
if command == "play":
return loop.run_until_complete(session.try_play_async())
elif command == "pause":
return loop.run_until_complete(session.try_pause_async())
elif command == "stop":
return loop.run_until_complete(session.try_stop_async())
elif command == "next":
return loop.run_until_complete(session.try_skip_next_async())
elif command == "previous":
return loop.run_until_complete(session.try_skip_previous_async())
return False
except Exception as e:
logger.error(f"Error executing media command {command}: {e}")
@@ -476,27 +482,20 @@ def _sync_media_command(command: str) -> bool:
def _sync_seek(position: float) -> bool:
"""Synchronously seek to position."""
import asyncio
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop = _thread_loop()
manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return False
try:
manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return False
session = _find_best_session(manager, loop)
if session is None:
return False
session = _find_best_session(manager, loop)
if session is None:
return False
position_ticks = int(position * 10_000_000)
return loop.run_until_complete(
session.try_change_playback_position_async(position_ticks)
)
finally:
loop.close()
position_ticks = int(position * 10_000_000)
return loop.run_until_complete(
session.try_change_playback_position_async(position_ticks)
)
except Exception as e:
logger.error(f"Error seeking: {e}")
@@ -559,7 +558,7 @@ class WindowsMediaController(MediaController):
# Get media info in thread pool (avoids asyncio/WinRT issues)
try:
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
media_info = await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_get_media_status),
timeout=5.0
@@ -592,7 +591,7 @@ class WindowsMediaController(MediaController):
async def _run_command(self, command: str) -> bool:
"""Run a media command in the thread pool."""
try:
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_media_command, command),
timeout=5.0
@@ -616,16 +615,15 @@ class WindowsMediaController(MediaController):
"""Stop playback."""
return await self._run_command("stop")
async def next_track(self) -> bool:
"""Skip to next track."""
# Get current title before skipping
try:
status = await self.get_status()
old_title = status.title or ""
except Exception:
old_title = ""
async def _skip_track(self, command: str) -> bool:
# Read the current title from the position cache instead of doing a
# full WinRT round-trip (which can take up to 5s) just for one field.
with _position_lock:
track_id = _position_cache.get("track_id") or ""
# track_id is "title:artist:duration" — extract just the title.
old_title = track_id.split(":", 1)[0] if track_id else ""
result = await self._run_command("next")
result = await self._run_command(command)
if result:
with _position_lock:
_track_skip_pending["active"] = True
@@ -634,23 +632,13 @@ class WindowsMediaController(MediaController):
logger.debug(f"Track skip initiated, old title: {old_title}")
return result
async def next_track(self) -> bool:
"""Skip to next track."""
return await self._skip_track("next")
async def previous_track(self) -> bool:
"""Go to previous track."""
# Get current title before skipping
try:
status = await self.get_status()
old_title = status.title or ""
except Exception:
old_title = ""
result = await self._run_command("previous")
if result:
with _position_lock:
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()
logger.debug(f"Track skip initiated, old title: {old_title}")
return result
return await self._skip_track("previous")
async def set_volume(self, volume: int) -> bool:
"""Set system volume."""
@@ -680,7 +668,7 @@ class WindowsMediaController(MediaController):
async def seek(self, position: float) -> bool:
"""Seek to position in seconds."""
try:
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_seek, position),
timeout=5.0
@@ -705,7 +693,7 @@ class WindowsMediaController(MediaController):
"""
try:
import os
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: os.startfile(file_path))
logger.info(f"Opened file with default player: {file_path}")
return True