Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82710c6457 | |||
| 9b9a2b5c9f | |||
| b023d72165 | |||
| d131ba461c | |||
| 450f9fe1ee | |||
| e1c8474271 | |||
| fe82836f4d | |||
| eeab9b2a26 | |||
| 61cdce9b60 | |||
| 0cf49deac0 |
+4
-51
@@ -1,55 +1,10 @@
|
|||||||
## v0.2.5 (2026-05-16)
|
## v0.3.1 (2026-05-25)
|
||||||
|
|
||||||
### Security
|
Hotfix for the v0.3.0 production-readiness release: the new WebSocket Origin allow-list rejected same-origin connections from any LAN IP, breaking the Web UI on `host: 0.0.0.0` deployments unless `cors_origins` was explicitly configured.
|
||||||
|
|
||||||
- **Loopback-by-default + auto-generated token:** Server now binds `127.0.0.1` by default; first-run bootstrap generates a random `api_token` and refuses to bind a non-loopback interface without auth unless explicitly opted in. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Browser path-traversal hardening:** `BrowserService.validate_path` now rejects absolute paths, drive letters, UNC paths, and NUL bytes. `/api/browser/{play,metadata,thumbnail}` require a `folder_id` plus a folder-relative path — arbitrary filesystem reads via the browser API are no longer possible. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Strict input validation on links/scripts:** Pydantic validators reject non-http(s) URLs and any icon outside the `mdi:<slug>` namespace. Create/update/delete on scripts, callbacks, and links is gated by the corresponding `*_management` flags. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Hardened response headers + CORS:** Strict `Content-Security-Policy`, `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 for trusted origins. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Atomic config writes with restrictive permissions:** `config.yaml` writes go through a temp file + `os.replace` and land with `0o600` on POSIX, so a crash mid-write can never leave a half-written token on disk readable to other users. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Subprocess process-group isolation:** Spawned scripts/callbacks now get their own process group (`CREATE_NEW_PROCESS_GROUP` on Windows, `start_new_session=True` on POSIX), so a timeout actually kills the whole tree instead of orphaning child processes. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Frontend XSS hardening:** Monitor name + details are `escapeHtml`'d, the power button moved to a delegated `data-action` handler, and remote MDI SVGs are parsed and sanitized (strip `<script>`, `<foreignObject>`, `on*` handlers, `javascript:` hrefs) before they touch `innerHTML`. All dynamic URL segments now go through `encodeURIComponent`. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **CSP-compliant event wiring:** Strict `script-src 'self'` was blocking every inline `onclick`/`onchange`/`oninput`/`onsubmit` in the UI, leaving buttons and forms silently dead. All 53 inline handler attributes in `index.html` were renamed to `data-on*` and a new `wireInlineHandlers()` in `app.js` parses each expression on `DOMContentLoaded` and attaches a real `addEventListener` — supports no-arg calls, string/number/bool/null literals, and the `event` token. No `unsafe-inline` or `unsafe-hashes` needed. ([eaeebb6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/eaeebb6))
|
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- **WebSocket reconnect robustness:** Close the previous socket before opening a new one, clear the ping interval per-socket, clear `reconnectTimeout` up-front, retry on `online`/`visibilitychange`, and wrap `JSON.parse` in try/catch — eliminates the stale-socket leaks and "stuck offline after sleep" cases. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
- **WebSocket Origin check now accepts same-origin connections.** When `cors_origins` is unset, the default allow-list was hard-coded to `http://localhost:<port>` + `http://127.0.0.1:<port>`, so a browser opening the UI via the LAN IP (e.g. `http://192.168.2.100:8765`) had its WebSocket closed with code 4003 ("Origin not allowed") and never recovered. The endpoint now also accepts any `Origin` whose authority matches the request's `Host` header (with either `http://` or `https://` scheme) — same-origin connections are by definition not CSWSH, so the cross-origin defence introduced in v0.3.0 is preserved. ([9b9a2b5](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9b9a2b5))
|
||||||
- **Artwork fetch race:** `AbortController` + generation guard so a rapid track change can no longer paint the previous track's artwork over the current one. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Audio analyzer no longer spins infinitely without a loopback device:** A sticky `_unavailable` flag short-circuits start/stop; cleared by `set_device()` so the user can recover once a device appears. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Volume short-circuit cache invalidation:** Cache is now busted when the server reports a remote volume change, so the UI no longer ignores volume updates that happened outside the app. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Browser thumbnail race:** Per-folder generation counter + `isConnected` checks; in-flight fetches are aborted on navigation, so thumbnails from a folder you already left can't paint into the current view. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Track-skip uses cached title** instead of a full WinRT status round-trip — skip feedback is now instant. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Browser list column alignment:** `.browser-list` switched to CSS grid + subgrid so header and rows share column tracks, eliminating the misaligned columns when content widths differed between rows. Matching responsive column overrides applied at the parent. Root-folder SVG sizing (hardcoded 24×24 in `browser.js`) now fills the 56px icon box instead of rendering at ~43%. Compact-grid icon fills its thumb wrapper so the emoji centers instead of being stranded top-left. Premature `isConnected` bail removed from `loadThumbnail` — the img element is intentionally detached when called from `renderBrowserGrid/List`, and the post-await checks already handle navigation-away correctly. ([982dda4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/982dda4))
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- **Blocking IO off the event loop:** Linux MPRIS/`pactl` calls, `/api/display` DDC/CI handlers, and `browse_directory` are all wrapped in `asyncio.to_thread` — slow SMB shares or laggy monitors can no longer stall the entire async runtime. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Windows status poll loop reuse:** The 0.5s status poll now caches one asyncio loop per worker thread via `threading.local` instead of `new_event_loop`/`close` on every tick. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **WebSocket broadcast: serialize once:** `broadcast()` serializes JSON a single time and uses `send_text` to fan out to all clients. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Thumbnail cache cleanup actually runs:** The hourly cleanup task was defined but never scheduled — it is now wired into the lifespan handler so the cache no longer grows unbounded. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Progress drag listeners attached only while dragging** — no more global `mousemove` handler firing on every cursor twitch. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
|
|
||||||
### UI/UX
|
|
||||||
|
|
||||||
- **Copper accent consistency:** Green leftover focus rings (`rgba(29,185,84,…)`) replaced with copper (`rgba(var(--copper-rgb),…)`) across the UI. Dialogs now have square corners and a copper top hairline so they read as part of the editorial chrome. `.browser-item` is transparent with a copper hover border (was a filled card). ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Audio device select** uses `var(--sans)` instead of the generic system font so it matches surrounding controls. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Mobile padding tuned for ≤480px screens.** ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **Accessible breadcrumb home:** Now a real `<button>` with `aria-label`, and `aria-current` is set on the root. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- **i18n gaps filled:** `display.msg.power_*`, `execution.*`, `scripts.params.execute`, `callbacks.empty` now have proper en + ru strings. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Development / Internal
|
|
||||||
|
|
||||||
#### Quality
|
|
||||||
|
|
||||||
- All `asyncio.get_event_loop()` in coroutines migrated to `get_running_loop()` (the former is deprecated in Python 3.12+). ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- `ThreadPoolExecutor`s now shut down cleanly during lifespan teardown. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- `config_manager` dedup: 12 near-identical CRUD methods collapsed onto generic `_upsert`/`_delete` helpers — about **290 lines removed** with no behavior change. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- Service worker no longer pass-throughs every fetch. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- M3U playlist written via `NamedTemporaryFile` so a fixed-path symlink can no longer clobber it. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- `__version__` prefers live `pyproject.toml` in dev checkouts so `pip install -e .` users see the source-of-truth version, not the stale metadata baked in at install time. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
- `_broadcast_after_open` hardening: initialize status, swallow per-poll errors, and track background tasks in a strong-ref set with done-callback cleanup so they aren't garbage-collected mid-flight. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -58,8 +13,6 @@
|
|||||||
|
|
||||||
| Hash | Message | Author |
|
| Hash | Message | Author |
|
||||||
|------|---------|--------|
|
|------|---------|--------|
|
||||||
| [982dda4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/982dda4) | fix(browser): align list columns via subgrid and fix icon sizing | alexei.dolgolyov |
|
| [9b9a2b5](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9b9a2b5) | fix(ws): accept same-origin WebSocket connections in default Origin allow-list | alexei.dolgolyov |
|
||||||
| [eaeebb6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/eaeebb6) | fix(csp): replace inline on* handlers with data-on* + JS wiring | alexei.dolgolyov |
|
|
||||||
| [bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40) | fix: comprehensive security, bug, performance, and UI/UX audit | alexei.dolgolyov |
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
+10
-3
@@ -1,7 +1,13 @@
|
|||||||
# Media Server Configuration
|
# Media Server Configuration
|
||||||
# Copy this file to config.yaml and customize as needed.
|
# Copy this file to config.yaml and customize as needed.
|
||||||
# By default, authentication is DISABLED (no tokens = open access).
|
#
|
||||||
# To enable auth, uncomment and configure the api_tokens section below.
|
# Secure-by-default: the server binds to loopback (127.0.0.1) only and refuses
|
||||||
|
# to bind a non-loopback address with no tokens configured.
|
||||||
|
#
|
||||||
|
# To expose on the LAN you must do ONE of:
|
||||||
|
# 1. Configure api_tokens below AND change host to "0.0.0.0", OR
|
||||||
|
# 2. Set `allow_lan_without_auth: true` (LAN-open, no auth — insecure on
|
||||||
|
# hostile networks, only acceptable on a trusted home LAN).
|
||||||
|
|
||||||
# API Tokens - Multiple tokens with friendly labels
|
# API Tokens - Multiple tokens with friendly labels
|
||||||
# This allows you to identify which client is making requests in the logs
|
# This allows you to identify which client is making requests in the logs
|
||||||
@@ -11,8 +17,9 @@
|
|||||||
# web_ui: "your-web-ui-token-here"
|
# web_ui: "your-web-ui-token-here"
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
host: "0.0.0.0"
|
host: "127.0.0.1"
|
||||||
port: 8765
|
port: 8765
|
||||||
|
# allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode
|
||||||
|
|
||||||
# Custom scripts
|
# Custom scripts
|
||||||
scripts:
|
scripts:
|
||||||
|
|||||||
+34
-2
@@ -13,6 +13,8 @@ security = HTTPBearer(auto_error=False)
|
|||||||
|
|
||||||
# Context variable to store current request's token label
|
# Context variable to store current request's token label
|
||||||
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
|
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
|
||||||
|
# Per-request correlation ID — generated in middleware if upstream didn't send one.
|
||||||
|
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
|
||||||
|
|
||||||
|
|
||||||
def auth_enabled() -> bool:
|
def auth_enabled() -> bool:
|
||||||
@@ -29,12 +31,42 @@ def get_token_label(token: str) -> Optional[str]:
|
|||||||
Returns:
|
Returns:
|
||||||
The label for the token, or None if invalid
|
The label for the token, or None if invalid
|
||||||
"""
|
"""
|
||||||
for label, stored_token in settings.api_tokens.items():
|
for label, spec in settings.api_tokens.items():
|
||||||
if secrets.compare_digest(stored_token, token):
|
if secrets.compare_digest(spec.token, token):
|
||||||
return label
|
return label
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def token_has_scope(label: str, required: str) -> bool:
|
||||||
|
"""Whether the token identified by `label` grants `required` scope."""
|
||||||
|
spec = settings.api_tokens.get(label)
|
||||||
|
if spec is None:
|
||||||
|
# Unknown label = no auth or anonymous; treat as full access only
|
||||||
|
# when auth is disabled entirely (matches existing behaviour).
|
||||||
|
return not auth_enabled()
|
||||||
|
return spec.grants(required)
|
||||||
|
|
||||||
|
|
||||||
|
def require_scope(scope: str):
|
||||||
|
"""Build a FastAPI dependency that enforces the given scope.
|
||||||
|
|
||||||
|
Use as ``Depends(require_scope("admin"))`` on management endpoints. When
|
||||||
|
auth is disabled the dependency is a no-op (anonymous access).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _checker(label: str = Depends(verify_token)) -> str:
|
||||||
|
if not auth_enabled():
|
||||||
|
return label
|
||||||
|
if not token_has_scope(label, scope):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Token '{label}' lacks required scope: {scope}",
|
||||||
|
)
|
||||||
|
return label
|
||||||
|
|
||||||
|
return _checker
|
||||||
|
|
||||||
|
|
||||||
async def verify_token(
|
async def verify_token(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
|||||||
+157
-11
@@ -7,12 +7,49 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Token scopes form a strict hierarchy: admin > control > read. Helper utility
|
||||||
|
# used by both auth.py and the validator below.
|
||||||
|
SCOPE_HIERARCHY: dict[str, frozenset[str]] = {
|
||||||
|
"read": frozenset({"read"}),
|
||||||
|
"control": frozenset({"read", "control"}),
|
||||||
|
"admin": frozenset({"read", "control", "admin"}),
|
||||||
|
}
|
||||||
|
ALL_SCOPES: frozenset[str] = frozenset(SCOPE_HIERARCHY.keys())
|
||||||
|
|
||||||
|
|
||||||
|
class TokenSpec(BaseModel):
|
||||||
|
"""Per-token authentication entry with explicit scopes."""
|
||||||
|
|
||||||
|
token: str = Field(..., min_length=8, description="Secret token value")
|
||||||
|
scopes: list[str] = Field(
|
||||||
|
default_factory=lambda: ["admin"],
|
||||||
|
description="Granted scopes (subset of read|control|admin).",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("scopes")
|
||||||
|
@classmethod
|
||||||
|
def _validate_scopes(cls, v: list[str]) -> list[str]:
|
||||||
|
if not v:
|
||||||
|
raise ValueError("scopes must list at least one of read|control|admin")
|
||||||
|
unknown = set(v) - ALL_SCOPES
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"unknown scopes: {sorted(unknown)}; valid={sorted(ALL_SCOPES)}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
def grants(self, required: str) -> bool:
|
||||||
|
"""Whether this token grants the requested scope (with hierarchy expansion)."""
|
||||||
|
granted: set[str] = set()
|
||||||
|
for s in self.scopes:
|
||||||
|
granted |= SCOPE_HIERARCHY.get(s, frozenset({s}))
|
||||||
|
return required in granted
|
||||||
|
|
||||||
|
|
||||||
class MediaFolderConfig(BaseModel):
|
class MediaFolderConfig(BaseModel):
|
||||||
"""Configuration for a media folder."""
|
"""Configuration for a media folder."""
|
||||||
|
|
||||||
@@ -48,6 +85,13 @@ class ScriptParameterConfig(BaseModel):
|
|||||||
options: Optional[list[str]] = Field(
|
options: Optional[list[str]] = Field(
|
||||||
default=None, description="Allowed values (select type only)"
|
default=None, description="Allowed values (select type only)"
|
||||||
)
|
)
|
||||||
|
pattern: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Optional regex (Python flavour) that string-typed values must match."
|
||||||
|
" Use to harden parameters that flow into shell=true scripts."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ScriptConfig(BaseModel):
|
class ScriptConfig(BaseModel):
|
||||||
@@ -108,19 +152,84 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Reverse-proxy deployment: when serving the API behind nginx/Caddy/Traefik,
|
||||||
|
# uvicorn must trust the X-Forwarded-* headers from the proxy so that the
|
||||||
|
# `Origin` allow-list, request URLs, and logs reflect the public-facing
|
||||||
|
# values. Off by default — only enable when there's a real proxy in front
|
||||||
|
# (otherwise clients can spoof their own IP).
|
||||||
|
proxy_headers: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Honor X-Forwarded-For / X-Forwarded-Proto from upstream proxy.",
|
||||||
|
)
|
||||||
|
forwarded_allow_ips: str = Field(
|
||||||
|
default="127.0.0.1",
|
||||||
|
description=(
|
||||||
|
"Comma-separated IPs / CIDRs that uvicorn should trust X-Forwarded-* from."
|
||||||
|
" Use '*' to trust all (only safe when bound to a private interface)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# HTTPS / TLS. Both must be set together to enable TLS; if only one is set
|
||||||
|
# the server refuses to start. Use `mkcert` or letsencrypt to generate the
|
||||||
|
# pair; the server reads them at startup.
|
||||||
|
ssl_certfile: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Path to TLS certificate (PEM). Pair with ssl_keyfile.",
|
||||||
|
)
|
||||||
|
ssl_keyfile: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Path to TLS private key (PEM). Pair with ssl_certfile.",
|
||||||
|
)
|
||||||
|
ssl_keyfile_password: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional password for the private key if encrypted.",
|
||||||
|
)
|
||||||
|
|
||||||
# Admin-grade operations (script / callback / link / folder create/update/delete).
|
# Admin-grade operations (script / callback / link / folder create/update/delete).
|
||||||
# When True the same token used for read/play can also persist arbitrary shell
|
# When True the same token used for read/play can also persist arbitrary shell
|
||||||
# commands. Disable to make the API read+execute only.
|
# commands. Default False so a single leaked token cannot escalate to RCE; opt
|
||||||
scripts_management: bool = Field(default=True, description="Allow scripts CRUD via API")
|
# in explicitly to manage scripts/callbacks/links via the Web UI.
|
||||||
callbacks_management: bool = Field(default=True, description="Allow callbacks CRUD via API")
|
scripts_management: bool = Field(default=False, description="Allow scripts CRUD via API")
|
||||||
links_management: bool = Field(default=True, description="Allow links CRUD via API")
|
callbacks_management: bool = Field(default=False, description="Allow callbacks CRUD via API")
|
||||||
|
links_management: bool = Field(default=False, description="Allow links CRUD via API")
|
||||||
|
|
||||||
# Authentication (empty = auth disabled, anyone can access the API)
|
# Authentication (empty = auth disabled, anyone can access the API).
|
||||||
api_tokens: dict[str, str] = Field(
|
#
|
||||||
|
# Each entry can be either:
|
||||||
|
# • a bare string (legacy form, treated as scopes = ["admin"] for back-compat), OR
|
||||||
|
# • a mapping with explicit scopes, e.g.
|
||||||
|
# "ha": {token: "<token>", scopes: ["read", "control"]}
|
||||||
|
# "kiosk": {token: "<token>", scopes: ["read"]}
|
||||||
|
# "ops": {token: "<token>", scopes: ["admin"]}
|
||||||
|
#
|
||||||
|
# Available scopes:
|
||||||
|
# read — GET /api/* (status, list, browse) but no state-changing calls.
|
||||||
|
# control — read + media transport, display/audio, script EXECUTE, callback EXECUTE.
|
||||||
|
# admin — control + CRUD on scripts/callbacks/links/folders.
|
||||||
|
#
|
||||||
|
# Validation normalises both forms to TokenSpec at load time.
|
||||||
|
api_tokens: dict[str, TokenSpec] = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Named API tokens for access control (label: token pairs). Empty = no auth.",
|
description=(
|
||||||
|
"Named API tokens. Value can be a bare token string (= admin scope) or"
|
||||||
|
" a {token, scopes} mapping. See TokenSpec for scope definitions."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator("api_tokens", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalise_tokens(cls, v):
|
||||||
|
"""Accept legacy `label: <bare-token>` form and promote to TokenSpec."""
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
return v
|
||||||
|
out: dict[str, dict | TokenSpec] = {}
|
||||||
|
for label, entry in v.items():
|
||||||
|
if isinstance(entry, str):
|
||||||
|
out[label] = {"token": entry, "scopes": ["admin"]}
|
||||||
|
else:
|
||||||
|
out[label] = entry
|
||||||
|
return out
|
||||||
|
|
||||||
# Media controller settings
|
# Media controller settings
|
||||||
poll_interval: float = Field(
|
poll_interval: float = Field(
|
||||||
default=1.0, description="Media status poll interval in seconds"
|
default=1.0, description="Media status poll interval in seconds"
|
||||||
@@ -156,7 +265,7 @@ class Settings(BaseSettings):
|
|||||||
description="Media folders available for browsing in the media browser",
|
description="Media folders available for browsing in the media browser",
|
||||||
)
|
)
|
||||||
media_folders_management: bool = Field(
|
media_folders_management: bool = Field(
|
||||||
default=True,
|
default=False,
|
||||||
description="Allow adding, editing, and deleting media folders from the Web UI",
|
description="Allow adding, editing, and deleting media folders from the Web UI",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -263,8 +372,11 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
|
|||||||
config = {
|
config = {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8765,
|
"port": 8765,
|
||||||
|
# Default token grants "admin" scope (full access). To create a
|
||||||
|
# read-only or control-only token, add a second entry:
|
||||||
|
# ha_readonly: {token: "<token>", scopes: ["read"]}
|
||||||
"api_tokens": {
|
"api_tokens": {
|
||||||
"default": default_token,
|
"default": {"token": default_token, "scopes": ["admin"]},
|
||||||
},
|
},
|
||||||
"poll_interval": 1.0,
|
"poll_interval": 1.0,
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
@@ -298,8 +410,16 @@ def _write_yaml_atomic(path: Path, data: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _restrict_config_perms(path: Path) -> None:
|
def _restrict_config_perms(path: Path) -> None:
|
||||||
"""On POSIX, ensure config file is readable only by owner (0600)."""
|
"""Ensure config file is readable only by its owner.
|
||||||
|
|
||||||
|
POSIX → ``chmod 0600``. On Windows the default NTFS ACL leaves the file
|
||||||
|
readable by every interactive user on the machine (Users group has Read),
|
||||||
|
which is bad given the file stores plaintext API tokens. Use ``icacls`` to
|
||||||
|
grant exclusive access to the current user + SYSTEM + Administrators and
|
||||||
|
strip inheritance.
|
||||||
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
|
_restrict_config_perms_windows(path)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
os.chmod(path, 0o600)
|
os.chmod(path, 0o600)
|
||||||
@@ -308,5 +428,31 @@ def _restrict_config_perms(path: Path) -> None:
|
|||||||
logger.debug("Could not chmod %s", path, exc_info=True)
|
logger.debug("Could not chmod %s", path, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _restrict_config_perms_windows(path: Path) -> None:
|
||||||
|
"""Apply restrictive NTFS ACL to a config file (Windows only)."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
username = os.environ.get("USERNAME") or os.environ.get("USER")
|
||||||
|
if not username:
|
||||||
|
logger.debug("Cannot detect current user; skipping icacls hardening")
|
||||||
|
return
|
||||||
|
# Disable inheritance and remove every existing ACE, then grant access
|
||||||
|
# only to current user, SYSTEM, and Administrators. /Q suppresses
|
||||||
|
# progress output; /C lets per-file errors not abort the batch.
|
||||||
|
subprocess.run(
|
||||||
|
["icacls", str(path), "/inheritance:r"],
|
||||||
|
check=False, capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
for principal in (username, "SYSTEM", "Administrators"):
|
||||||
|
subprocess.run(
|
||||||
|
["icacls", str(path), "/grant:r", f"{principal}:(R,W)"],
|
||||||
|
check=False, capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||||
|
# `icacls` missing or sandboxed — leave the default ACL in place.
|
||||||
|
logger.debug("icacls hardening failed for %s", path, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
# Global settings instance
|
# Global settings instance
|
||||||
settings = Settings.load_from_yaml()
|
settings = Settings.load_from_yaml()
|
||||||
|
|||||||
+197
-29
@@ -15,13 +15,14 @@ from fastapi.responses import FileResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .auth import get_token_label, token_label_var
|
from .auth import get_token_label, request_id_var, token_label_var
|
||||||
from .config import generate_default_config, get_config_dir, settings
|
from .config import generate_default_config, get_config_dir, settings
|
||||||
from .routes import (
|
from .routes import (
|
||||||
audio_router,
|
audio_router,
|
||||||
browser_router,
|
browser_router,
|
||||||
callbacks_router,
|
callbacks_router,
|
||||||
display_router,
|
display_router,
|
||||||
|
foreground_router,
|
||||||
health_router,
|
health_router,
|
||||||
links_router,
|
links_router,
|
||||||
media_router,
|
media_router,
|
||||||
@@ -32,10 +33,34 @@ from .services.websocket_manager import ws_manager
|
|||||||
|
|
||||||
|
|
||||||
class TokenLabelFilter(logging.Filter):
|
class TokenLabelFilter(logging.Filter):
|
||||||
"""Add token label to log records."""
|
"""Add token label + request_id to log records."""
|
||||||
|
|
||||||
def filter(self, record):
|
def filter(self, record):
|
||||||
record.token_label = token_label_var.get("unknown")
|
record.token_label = token_label_var.get("unknown")
|
||||||
|
record.request_id = request_id_var.get("-")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _StripTokenQueryFilter(logging.Filter):
|
||||||
|
"""Strip `token=...` from query strings before they hit the access log.
|
||||||
|
|
||||||
|
uvicorn's default access log format includes the full request line, so
|
||||||
|
`/api/media/artwork?token=SECRET` would otherwise be persisted verbatim
|
||||||
|
in stdout/journald/file sinks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
_TOKEN_RE = _re.compile(r"([?&])token=[^&\s\"']+")
|
||||||
|
|
||||||
|
def filter(self, record): # type: ignore[override]
|
||||||
|
if isinstance(record.args, tuple):
|
||||||
|
record.args = tuple(
|
||||||
|
self._TOKEN_RE.sub(r"\1token=REDACTED", a) if isinstance(a, str) else a
|
||||||
|
for a in record.args
|
||||||
|
)
|
||||||
|
if isinstance(record.msg, str) and "token=" in record.msg:
|
||||||
|
record.msg = self._TOKEN_RE.sub(r"\1token=REDACTED", record.msg)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -48,17 +73,34 @@ def setup_logging():
|
|||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, settings.log_level.upper()),
|
level=getattr(logging, settings.log_level.upper()),
|
||||||
format="%(asctime)s - %(name)s - [%(token_label)s] - %(levelname)s - %(message)s",
|
format=(
|
||||||
|
"%(asctime)s - %(name)s - [%(token_label)s] [%(request_id)s]"
|
||||||
|
" - %(levelname)s - %(message)s"
|
||||||
|
),
|
||||||
handlers=[handler],
|
handlers=[handler],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Suppress noisy third-party loggers
|
# Suppress noisy third-party loggers
|
||||||
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
|
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
# Make sure the uvicorn access log never persists tokens leaked into the
|
||||||
|
# query string (the artwork + WS endpoints accept `?token=` for browser
|
||||||
|
# compatibility — see verify_token_or_query).
|
||||||
|
strip_filter = _StripTokenQueryFilter()
|
||||||
|
for name in ("uvicorn.access", "uvicorn"):
|
||||||
|
logging.getLogger(name).addFilter(strip_filter)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan handler."""
|
"""Application lifespan handler.
|
||||||
|
|
||||||
|
All long-lived resources started during startup are kept in local refs and
|
||||||
|
torn down in a `finally:` so a partial-startup failure cannot orphan tasks
|
||||||
|
or thread pools.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
||||||
@@ -70,13 +112,19 @@ async def lifespan(app: FastAPI):
|
|||||||
else:
|
else:
|
||||||
logger.warning("No API tokens configured — authentication is DISABLED")
|
logger.warning("No API tokens configured — authentication is DISABLED")
|
||||||
|
|
||||||
|
update_checker = None
|
||||||
|
cleanup_task: asyncio.Task | None = None
|
||||||
|
analyzer = None
|
||||||
|
status_monitor_started = False
|
||||||
|
|
||||||
|
try:
|
||||||
# Start WebSocket status monitor
|
# Start WebSocket status monitor
|
||||||
controller = get_media_controller()
|
controller = get_media_controller()
|
||||||
await ws_manager.start_status_monitor(controller.get_status)
|
await ws_manager.start_status_monitor(controller.get_status)
|
||||||
|
status_monitor_started = True
|
||||||
logger.info("WebSocket status monitor started")
|
logger.info("WebSocket status monitor started")
|
||||||
|
|
||||||
# Start update checker
|
# Start update checker
|
||||||
update_checker = None
|
|
||||||
if settings.update_check_enabled:
|
if settings.update_check_enabled:
|
||||||
from .services.gitea_release_provider import GiteaReleaseProvider
|
from .services.gitea_release_provider import GiteaReleaseProvider
|
||||||
from .services.update_checker import UpdateChecker
|
from .services.update_checker import UpdateChecker
|
||||||
@@ -102,11 +150,9 @@ async def lifespan(app: FastAPI):
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
|
|
||||||
import asyncio
|
|
||||||
cleanup_task = asyncio.create_task(_thumbnail_cleanup_loop())
|
cleanup_task = asyncio.create_task(_thumbnail_cleanup_loop())
|
||||||
|
|
||||||
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
||||||
analyzer = None
|
|
||||||
if settings.visualizer_enabled:
|
if settings.visualizer_enabled:
|
||||||
from .services.audio_analyzer import get_audio_analyzer
|
from .services.audio_analyzer import get_audio_analyzer
|
||||||
|
|
||||||
@@ -122,38 +168,67 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
|
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
finally:
|
||||||
# Stop update checker
|
# Stop update checker
|
||||||
if update_checker is not None:
|
if update_checker is not None:
|
||||||
|
try:
|
||||||
await update_checker.stop()
|
await update_checker.stop()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error stopping update checker")
|
||||||
|
|
||||||
# Cancel periodic thumbnail cleanup
|
# Cancel periodic thumbnail cleanup
|
||||||
|
if cleanup_task is not None:
|
||||||
cleanup_task.cancel()
|
cleanup_task.cancel()
|
||||||
try:
|
try:
|
||||||
await cleanup_task
|
await cleanup_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error awaiting thumbnail cleanup task")
|
||||||
|
|
||||||
# Stop audio visualizer
|
# Stop audio visualizer
|
||||||
|
try:
|
||||||
await ws_manager.stop_audio_monitor()
|
await ws_manager.stop_audio_monitor()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error stopping audio monitor")
|
||||||
if analyzer and analyzer.running:
|
if analyzer and analyzer.running:
|
||||||
|
try:
|
||||||
analyzer.stop()
|
analyzer.stop()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error stopping audio analyzer")
|
||||||
|
|
||||||
# Stop WebSocket status monitor
|
# Stop WebSocket status monitor
|
||||||
|
if status_monitor_started:
|
||||||
|
try:
|
||||||
await ws_manager.stop_status_monitor()
|
await ws_manager.stop_status_monitor()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error stopping status monitor")
|
||||||
|
|
||||||
# Shut down dedicated thread pools so pending scripts don't leak threads
|
# Shut down dedicated thread pools so pending scripts don't leak threads
|
||||||
|
try:
|
||||||
from .routes.callbacks import shutdown_callback_executor
|
from .routes.callbacks import shutdown_callback_executor
|
||||||
from .routes.scripts import shutdown_script_executor
|
from .routes.scripts import shutdown_script_executor
|
||||||
|
|
||||||
shutdown_script_executor()
|
shutdown_script_executor()
|
||||||
shutdown_callback_executor()
|
shutdown_callback_executor()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error shutting down script/callback executors")
|
||||||
|
|
||||||
|
# Flush audit log writer
|
||||||
|
try:
|
||||||
|
from .services.audit_log import shutdown_audit_log
|
||||||
|
shutdown_audit_log()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error flushing audit log")
|
||||||
|
|
||||||
# Clean up platform-specific resources
|
# Clean up platform-specific resources
|
||||||
import platform as _platform
|
import platform as _platform
|
||||||
if _platform.system() == "Windows":
|
if _platform.system() == "Windows":
|
||||||
|
try:
|
||||||
from .services.windows_media import shutdown_executor
|
from .services.windows_media import shutdown_executor
|
||||||
shutdown_executor()
|
shutdown_executor()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error shutting down windows_media executor")
|
||||||
|
|
||||||
logger.info("Media Server shutting down")
|
logger.info("Media Server shutting down")
|
||||||
|
|
||||||
@@ -172,7 +247,15 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
# CORS — restrict to same-origin by default; users that integrate the API
|
# CORS — restrict to same-origin by default; users that integrate the API
|
||||||
# from another origin (e.g. Home Assistant on a different host) can set
|
# from another origin (e.g. Home Assistant on a different host) can set
|
||||||
# cors_origins in config.yaml.
|
# cors_origins in config.yaml. Refuse "*" outright: combined with the
|
||||||
|
# admin endpoints this would let any origin in the universe run
|
||||||
|
# arbitrary shell. If users genuinely need every origin, they can list
|
||||||
|
# them explicitly.
|
||||||
|
if any(o.strip() == "*" for o in settings.cors_origins):
|
||||||
|
raise RuntimeError(
|
||||||
|
"cors_origins must not contain '*' — list exact origins instead. "
|
||||||
|
"This protects the script-execution endpoints from any-origin abuse."
|
||||||
|
)
|
||||||
cors_origins = settings.cors_origins or [
|
cors_origins = settings.cors_origins or [
|
||||||
f"http://localhost:{settings.port}",
|
f"http://localhost:{settings.port}",
|
||||||
f"http://127.0.0.1:{settings.port}",
|
f"http://127.0.0.1:{settings.port}",
|
||||||
@@ -185,6 +268,23 @@ def create_app() -> FastAPI:
|
|||||||
allow_headers=["Authorization", "Content-Type"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Request correlation ID — accept upstream X-Request-ID if it's a sane
|
||||||
|
# ASCII id, otherwise mint a fresh UUID4. Emitted on the response so
|
||||||
|
# clients can quote it back in bug reports.
|
||||||
|
import re
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
_REQ_ID_RE = re.compile(r"^[A-Za-z0-9._\-]{1,128}$")
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def request_id_middleware(request: Request, call_next):
|
||||||
|
incoming = request.headers.get("x-request-id", "")
|
||||||
|
req_id = incoming if _REQ_ID_RE.match(incoming) else _uuid.uuid4().hex[:16]
|
||||||
|
request_id_var.set(req_id)
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = req_id
|
||||||
|
return response
|
||||||
|
|
||||||
# Security headers — strict CSP for the bundled UI, disallow framing, hide referrer.
|
# Security headers — strict CSP for the bundled UI, disallow framing, hide referrer.
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def security_headers_middleware(request: Request, call_next):
|
async def security_headers_middleware(request: Request, call_next):
|
||||||
@@ -199,6 +299,9 @@ def create_app() -> FastAPI:
|
|||||||
"style-src 'self' 'unsafe-inline'; "
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
"font-src 'self' data:; "
|
"font-src 'self' data:; "
|
||||||
"frame-ancestors 'none'; "
|
"frame-ancestors 'none'; "
|
||||||
|
"form-action 'self'; "
|
||||||
|
"worker-src 'self'; "
|
||||||
|
"manifest-src 'self'; "
|
||||||
"base-uri 'self'"
|
"base-uri 'self'"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -207,32 +310,63 @@ def create_app() -> FastAPI:
|
|||||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# Add token logging middleware
|
# Add token logging middleware + auth-failure rate limit
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from .services.rate_limit import check as ratelimit_check
|
||||||
|
from .services.rate_limit import get_peer
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def token_logging_middleware(request: Request, call_next):
|
async def token_logging_middleware(request: Request, call_next):
|
||||||
"""Extract token label and set in context for logging."""
|
"""Extract token label, set in context, and rate-limit failed auths."""
|
||||||
if not settings.api_tokens:
|
if not settings.api_tokens:
|
||||||
token_label_var.set("anonymous")
|
token_label_var.set("anonymous")
|
||||||
else:
|
else:
|
||||||
token_label = "unknown"
|
token_label = "unknown"
|
||||||
|
token_present = False
|
||||||
|
token_valid = False
|
||||||
|
|
||||||
# Try Authorization header
|
# Try Authorization header
|
||||||
auth_header = request.headers.get("authorization", "")
|
auth_header = request.headers.get("authorization", "")
|
||||||
if auth_header.startswith("Bearer "):
|
if auth_header.startswith("Bearer "):
|
||||||
|
token_present = True
|
||||||
token = auth_header[7:]
|
token = auth_header[7:]
|
||||||
label = get_token_label(token)
|
label = get_token_label(token)
|
||||||
if label:
|
if label:
|
||||||
token_label = label
|
token_label = label
|
||||||
|
token_valid = True
|
||||||
|
|
||||||
# Try query parameter (for artwork endpoint)
|
# Try query parameter (for artwork endpoint)
|
||||||
elif "token" in request.query_params:
|
elif "token" in request.query_params:
|
||||||
|
token_present = True
|
||||||
token = request.query_params["token"]
|
token = request.query_params["token"]
|
||||||
label = get_token_label(token)
|
label = get_token_label(token)
|
||||||
if label:
|
if label:
|
||||||
token_label = label
|
token_label = label
|
||||||
|
token_valid = True
|
||||||
|
|
||||||
token_label_var.set(token_label)
|
token_label_var.set(token_label)
|
||||||
|
|
||||||
|
# Brute-force gate: a peer that produces a wrong/missing token gets
|
||||||
|
# 5 failures per minute before being throttled. Static-asset
|
||||||
|
# requests (GET /static/*, /, /sw.js) and the docs endpoint are
|
||||||
|
# exempt — they're served unauthenticated by design.
|
||||||
|
if token_present and not token_valid:
|
||||||
|
path = request.url.path
|
||||||
|
if not (
|
||||||
|
path == "/" or path == "/sw.js"
|
||||||
|
or path.startswith("/static/")
|
||||||
|
or path.startswith("/docs") or path.startswith("/openapi")
|
||||||
|
or path.startswith("/redoc")
|
||||||
|
):
|
||||||
|
allowed, retry_after = ratelimit_check("auth", get_peer(request))
|
||||||
|
if not allowed:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={"detail": "Too many authentication failures"},
|
||||||
|
headers={"Retry-After": str(int(retry_after or 60))},
|
||||||
|
)
|
||||||
|
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -241,6 +375,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(browser_router)
|
app.include_router(browser_router)
|
||||||
app.include_router(callbacks_router)
|
app.include_router(callbacks_router)
|
||||||
app.include_router(display_router)
|
app.include_router(display_router)
|
||||||
|
app.include_router(foreground_router)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(links_router)
|
app.include_router(links_router)
|
||||||
app.include_router(media_router)
|
app.include_router(media_router)
|
||||||
@@ -264,6 +399,11 @@ def create_app() -> FastAPI:
|
|||||||
async def serve_ui():
|
async def serve_ui():
|
||||||
"""Serve the Web UI."""
|
"""Serve the Web UI."""
|
||||||
return FileResponse(static_dir / "index.html")
|
return FileResponse(static_dir / "index.html")
|
||||||
|
else:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"static_dir not found at %s — Web UI disabled (API only)",
|
||||||
|
static_dir,
|
||||||
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -314,55 +454,86 @@ def main():
|
|||||||
print(f"Config directory: {get_config_dir()}")
|
print(f"Config directory: {get_config_dir()}")
|
||||||
if settings.api_tokens:
|
if settings.api_tokens:
|
||||||
print("\nAPI Tokens:")
|
print("\nAPI Tokens:")
|
||||||
for label, token in settings.api_tokens.items():
|
for label, spec in settings.api_tokens.items():
|
||||||
print(f" {label:20} {token}")
|
scope_str = ",".join(spec.scopes)
|
||||||
|
print(f" {label:20} {spec.token} [scopes: {scope_str}]")
|
||||||
else:
|
else:
|
||||||
print("\nAuthentication is DISABLED (no tokens configured)")
|
print("\nAuthentication is DISABLED (no tokens configured)")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Stderr is invisible when launched via wscript / pythonw (Start Menu shortcut,
|
||||||
|
# autostart). Mirror pre-uvicorn failures to a file in the config dir so the
|
||||||
|
# next silent boot failure is diagnosable.
|
||||||
|
def _fatal(msg: str, exit_code: int = 1) -> None:
|
||||||
|
print(msg, file=sys.stderr)
|
||||||
|
try:
|
||||||
|
log_path = get_config_dir() / "startup-errors.log"
|
||||||
|
from datetime import datetime
|
||||||
|
with open(log_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
sys.exit(exit_code)
|
||||||
|
|
||||||
# First-run bootstrap: if no config has ever been written, generate one
|
# First-run bootstrap: if no config has ever been written, generate one
|
||||||
# with a random token instead of starting in the insecure "no-auth" mode.
|
# with a random token instead of starting in the insecure "no-auth" mode.
|
||||||
config_path = get_config_dir() / "config.yaml"
|
config_path = get_config_dir() / "config.yaml"
|
||||||
if not config_path.exists() and not settings.api_tokens:
|
if not config_path.exists() and not settings.api_tokens:
|
||||||
try:
|
try:
|
||||||
generate_default_config(config_path)
|
generate_default_config(config_path)
|
||||||
print(
|
_fatal(
|
||||||
f"\nFirst run: generated default config at {config_path}.\n"
|
f"\nFirst run: generated default config at {config_path}.\n"
|
||||||
"Run --show-token to retrieve the API token, then restart.",
|
"Run --show-token to retrieve the API token, then restart.",
|
||||||
file=sys.stderr,
|
exit_code=0,
|
||||||
)
|
)
|
||||||
sys.exit(0)
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f"WARNING: could not bootstrap config: {e}", file=sys.stderr)
|
print(f"WARNING: could not bootstrap config: {e}", file=sys.stderr)
|
||||||
|
|
||||||
# Refuse to bind a non-loopback address with no tokens, unless explicitly opted in.
|
# Refuse to bind a non-loopback address with no tokens, unless explicitly opted in.
|
||||||
non_loopback = args.host not in ("127.0.0.1", "localhost", "::1")
|
non_loopback = args.host not in ("127.0.0.1", "localhost", "::1")
|
||||||
if non_loopback and not settings.api_tokens and not settings.allow_lan_without_auth:
|
if non_loopback and not settings.api_tokens and not settings.allow_lan_without_auth:
|
||||||
print(
|
_fatal(
|
||||||
"ERROR: refusing to bind a non-loopback address with no api_tokens configured.\n"
|
"ERROR: refusing to bind a non-loopback address with no api_tokens configured.\n"
|
||||||
"Either set api_tokens in config.yaml, bind to 127.0.0.1,"
|
"Either set api_tokens in config.yaml, bind to 127.0.0.1,"
|
||||||
" or set allow_lan_without_auth: true in config.yaml to override.",
|
" or set allow_lan_without_auth: true in config.yaml to override."
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Check if port is available before starting
|
# Check if port is available before starting
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
try:
|
try:
|
||||||
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
|
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
|
||||||
except OSError:
|
except OSError:
|
||||||
print(
|
_fatal(
|
||||||
f"ERROR: Port {args.port} is already in use. "
|
f"ERROR: Port {args.port} is already in use. "
|
||||||
f"Another instance of Media Server may be running.\n"
|
f"Another instance of Media Server may be running.\n"
|
||||||
f"Stop the other process or use --port to pick a different port.",
|
f"Stop the other process or use --port to pick a different port."
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
from .tray import PYSTRAY_AVAILABLE, TrayManager
|
from .tray import PYSTRAY_AVAILABLE, TrayManager
|
||||||
|
|
||||||
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
|
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
|
||||||
|
|
||||||
|
# Validate TLS pair consistency before either path so we don't fail late.
|
||||||
|
if bool(settings.ssl_certfile) ^ bool(settings.ssl_keyfile):
|
||||||
|
_fatal(
|
||||||
|
"ERROR: ssl_certfile and ssl_keyfile must both be set, or both unset."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _uvicorn_kwargs() -> dict:
|
||||||
|
kw: dict = {
|
||||||
|
"host": args.host,
|
||||||
|
"port": args.port,
|
||||||
|
"log_level": settings.log_level.lower(),
|
||||||
|
"proxy_headers": settings.proxy_headers,
|
||||||
|
"forwarded_allow_ips": settings.forwarded_allow_ips,
|
||||||
|
}
|
||||||
|
if settings.ssl_certfile and settings.ssl_keyfile:
|
||||||
|
kw["ssl_certfile"] = settings.ssl_certfile
|
||||||
|
kw["ssl_keyfile"] = settings.ssl_keyfile
|
||||||
|
if settings.ssl_keyfile_password:
|
||||||
|
kw["ssl_keyfile_password"] = settings.ssl_keyfile_password
|
||||||
|
return kw
|
||||||
|
|
||||||
if use_tray:
|
if use_tray:
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
@@ -370,9 +541,7 @@ def main():
|
|||||||
# Run uvicorn in a background thread so tray owns the main thread message loop
|
# Run uvicorn in a background thread so tray owns the main thread message loop
|
||||||
uv_config = uvicorn.Config(
|
uv_config = uvicorn.Config(
|
||||||
"media_server.main:app",
|
"media_server.main:app",
|
||||||
host=args.host,
|
**_uvicorn_kwargs(),
|
||||||
port=args.port,
|
|
||||||
log_level=settings.log_level.lower(),
|
|
||||||
)
|
)
|
||||||
server = uvicorn.Server(uv_config)
|
server = uvicorn.Server(uv_config)
|
||||||
|
|
||||||
@@ -410,9 +579,8 @@ def main():
|
|||||||
else:
|
else:
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"media_server.main:app",
|
"media_server.main:app",
|
||||||
host=args.host,
|
|
||||||
port=args.port,
|
|
||||||
reload=False,
|
reload=False,
|
||||||
|
**_uvicorn_kwargs(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .audio import router as audio_router
|
|||||||
from .browser import router as browser_router
|
from .browser import router as browser_router
|
||||||
from .callbacks import router as callbacks_router
|
from .callbacks import router as callbacks_router
|
||||||
from .display import router as display_router
|
from .display import router as display_router
|
||||||
|
from .foreground import router as foreground_router
|
||||||
from .health import router as health_router
|
from .health import router as health_router
|
||||||
from .links import router as links_router
|
from .links import router as links_router
|
||||||
from .media import router as media_router
|
from .media import router as media_router
|
||||||
@@ -14,6 +15,7 @@ __all__ = [
|
|||||||
"browser_router",
|
"browser_router",
|
||||||
"callbacks_router",
|
"callbacks_router",
|
||||||
"display_router",
|
"display_router",
|
||||||
|
"foreground_router",
|
||||||
"health_router",
|
"health_router",
|
||||||
"links_router",
|
"links_router",
|
||||||
"media_router",
|
"media_router",
|
||||||
|
|||||||
@@ -36,12 +36,20 @@ def _spawn_background(coro) -> asyncio.Task:
|
|||||||
|
|
||||||
|
|
||||||
def _require_folder_management() -> None:
|
def _require_folder_management() -> None:
|
||||||
"""Raise 403 if media folder management is disabled in config."""
|
"""Raise 403 if media folder management is disabled OR caller lacks admin scope."""
|
||||||
if not settings.media_folders_management:
|
if not settings.media_folders_management:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
|
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
|
||||||
)
|
)
|
||||||
|
from ..auth import auth_enabled, token_has_scope, token_label_var
|
||||||
|
if auth_enabled():
|
||||||
|
label = token_label_var.get("unknown")
|
||||||
|
if not token_has_scope(label, "admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Token '{label}' lacks required scope: admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
|
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import time
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import verify_token
|
from ..auth import verify_token
|
||||||
from ..config import CallbackConfig, settings
|
from ..config import CallbackConfig, settings
|
||||||
from ..config_manager import config_manager
|
from ..config_manager import config_manager
|
||||||
|
from ..services.rate_limit import check as ratelimit_check
|
||||||
|
from ..services.rate_limit import get_peer
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
|
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -28,6 +30,7 @@ def shutdown_callback_executor() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _require_callbacks_management() -> None:
|
def _require_callbacks_management() -> None:
|
||||||
|
"""Authorise a callbacks-CRUD operation. Operator flag + per-token admin scope."""
|
||||||
if not settings.callbacks_management:
|
if not settings.callbacks_management:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
@@ -36,6 +39,14 @@ def _require_callbacks_management() -> None:
|
|||||||
" in config.yaml to enable."
|
" in config.yaml to enable."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
from ..auth import auth_enabled, token_has_scope, token_label_var
|
||||||
|
if auth_enabled():
|
||||||
|
label = token_label_var.get("unknown")
|
||||||
|
if not token_has_scope(label, "admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Token '{label}' lacks required scope: admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CallbackInfo(BaseModel):
|
class CallbackInfo(BaseModel):
|
||||||
@@ -122,6 +133,7 @@ async def list_callbacks(_: str = Depends(verify_token)) -> list[CallbackInfo]:
|
|||||||
@router.post("/execute/{callback_name}")
|
@router.post("/execute/{callback_name}")
|
||||||
async def execute_callback(
|
async def execute_callback(
|
||||||
callback_name: str,
|
callback_name: str,
|
||||||
|
http_request: Request,
|
||||||
_: str = Depends(verify_token),
|
_: str = Depends(verify_token),
|
||||||
) -> CallbackExecuteResponse:
|
) -> CallbackExecuteResponse:
|
||||||
"""Execute a callback for debugging purposes.
|
"""Execute a callback for debugging purposes.
|
||||||
@@ -132,6 +144,16 @@ async def execute_callback(
|
|||||||
Returns:
|
Returns:
|
||||||
Execution result including stdout, stderr, and exit code
|
Execution result including stdout, stderr, and exit code
|
||||||
"""
|
"""
|
||||||
|
# Rate-limit callback execution per peer (10/min) — callbacks also run
|
||||||
|
# subprocesses and need the same protection as scripts.
|
||||||
|
allowed, retry_after = ratelimit_check("execute", get_peer(http_request))
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many callback executions, slow down",
|
||||||
|
headers={"Retry-After": str(int(retry_after or 60))},
|
||||||
|
)
|
||||||
|
|
||||||
# Validate callback name
|
# Validate callback name
|
||||||
_validate_callback_name(callback_name)
|
_validate_callback_name(callback_name)
|
||||||
|
|
||||||
@@ -146,6 +168,8 @@ async def execute_callback(
|
|||||||
|
|
||||||
logger.info(f"Executing callback for debugging: {callback_name}")
|
logger.info(f"Executing callback for debugging: {callback_name}")
|
||||||
|
|
||||||
|
from ..services.audit_log import record_script_execution
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Execute in dedicated thread pool to not block the default executor
|
# Execute in dedicated thread pool to not block the default executor
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -159,6 +183,15 @@ async def execute_callback(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
record_script_execution(
|
||||||
|
kind="callback",
|
||||||
|
name=callback_name,
|
||||||
|
exit_code=result["exit_code"],
|
||||||
|
duration=result.get("execution_time"),
|
||||||
|
stdout=result.get("stdout"),
|
||||||
|
stderr=result.get("stderr"),
|
||||||
|
)
|
||||||
|
|
||||||
return CallbackExecuteResponse(
|
return CallbackExecuteResponse(
|
||||||
success=result["exit_code"] == 0,
|
success=result["exit_code"] == 0,
|
||||||
callback=callback_name,
|
callback=callback_name,
|
||||||
@@ -170,6 +203,13 @@ async def execute_callback(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Callback execution error: {e}")
|
logger.error(f"Callback execution error: {e}")
|
||||||
|
record_script_execution(
|
||||||
|
kind="callback",
|
||||||
|
name=callback_name,
|
||||||
|
exit_code=None,
|
||||||
|
duration=None,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
return CallbackExecuteResponse(
|
return CallbackExecuteResponse(
|
||||||
success=False,
|
success=False,
|
||||||
callback=callback_name,
|
callback=callback_name,
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Foreground (topmost) window/process API."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from ..auth import verify_token
|
||||||
|
from ..services.foreground_service import get_foreground_info
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/foreground", tags=["foreground"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_foreground(
|
||||||
|
refresh: bool = False, _: str = Depends(verify_token)
|
||||||
|
) -> dict:
|
||||||
|
"""Return metadata about the foreground window and owning process.
|
||||||
|
|
||||||
|
The probe is cached for ~500ms server-side; pass ``?refresh=1`` to bypass
|
||||||
|
the cache for one-shot queries.
|
||||||
|
"""
|
||||||
|
info = await asyncio.to_thread(get_foreground_info, refresh)
|
||||||
|
return info.to_dict()
|
||||||
@@ -39,11 +39,20 @@ def _validate_icon(icon: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _require_links_management() -> None:
|
def _require_links_management() -> None:
|
||||||
|
"""Authorise a links-CRUD operation. Operator flag + per-token admin scope."""
|
||||||
if not settings.links_management:
|
if not settings.links_management:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Links management is disabled. Set links_management: true in config.yaml to enable.",
|
detail="Links management is disabled. Set links_management: true in config.yaml to enable.",
|
||||||
)
|
)
|
||||||
|
from ..auth import auth_enabled, token_has_scope, token_label_var
|
||||||
|
if auth_enabled():
|
||||||
|
label = token_label_var.get("unknown")
|
||||||
|
if not token_has_scope(label, "admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Token '{label}' lacks required scope: admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LinkInfo(BaseModel):
|
class LinkInfo(BaseModel):
|
||||||
|
|||||||
+142
-23
@@ -3,7 +3,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
HTTPException,
|
||||||
|
Query,
|
||||||
|
Request,
|
||||||
|
WebSocket,
|
||||||
|
WebSocketDisconnect,
|
||||||
|
status,
|
||||||
|
)
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from ..auth import verify_token, verify_token_or_query
|
from ..auth import verify_token, verify_token_or_query
|
||||||
@@ -17,19 +26,28 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/media", tags=["media"])
|
router = APIRouter(prefix="/api/media", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
# Strong refs to background tasks so the asyncio GC can't drop them before
|
||||||
|
# they run. Mirrors the pattern used in routes/browser.py.
|
||||||
|
_background_callback_tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
|
||||||
def _run_callback(callback_name: str) -> None:
|
def _run_callback(callback_name: str) -> None:
|
||||||
"""Fire-and-forget a callback if configured. Failures are logged but don't block."""
|
"""Fire-and-forget a callback if configured. Failures are logged but don't block."""
|
||||||
if not settings.callbacks or callback_name not in settings.callbacks:
|
if not settings.callbacks or callback_name not in settings.callbacks:
|
||||||
return
|
return
|
||||||
|
|
||||||
async def _execute():
|
async def _execute():
|
||||||
|
# Use the dedicated callback executor (not the default loop pool) so a
|
||||||
|
# misbehaving callback can't starve the rest of the app's sync tasks.
|
||||||
|
from ..services.audit_log import record_script_execution
|
||||||
|
from .callbacks import _callback_executor
|
||||||
from .scripts import _run_script
|
from .scripts import _run_script
|
||||||
|
|
||||||
try:
|
try:
|
||||||
callback = settings.callbacks[callback_name]
|
callback = settings.callbacks[callback_name]
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
result = await loop.run_in_executor(
|
result = await loop.run_in_executor(
|
||||||
None,
|
_callback_executor,
|
||||||
lambda: _run_script(
|
lambda: _run_script(
|
||||||
command=callback.command,
|
command=callback.command,
|
||||||
timeout=callback.timeout,
|
timeout=callback.timeout,
|
||||||
@@ -37,6 +55,14 @@ def _run_callback(callback_name: str) -> None:
|
|||||||
working_dir=callback.working_dir,
|
working_dir=callback.working_dir,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
record_script_execution(
|
||||||
|
kind="event-callback",
|
||||||
|
name=callback_name,
|
||||||
|
exit_code=result["exit_code"],
|
||||||
|
duration=result.get("execution_time"),
|
||||||
|
stdout=result.get("stdout"),
|
||||||
|
stderr=result.get("stderr"),
|
||||||
|
)
|
||||||
if result["exit_code"] != 0:
|
if result["exit_code"] != 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Callback %s failed with exit code %s: %s",
|
"Callback %s failed with exit code %s: %s",
|
||||||
@@ -46,8 +72,18 @@ def _run_callback(callback_name: str) -> None:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Callback %s error: %s", callback_name, e)
|
logger.error("Callback %s error: %s", callback_name, e)
|
||||||
|
from ..services.audit_log import record_script_execution as _rec
|
||||||
|
_rec(
|
||||||
|
kind="event-callback",
|
||||||
|
name=callback_name,
|
||||||
|
exit_code=None,
|
||||||
|
duration=None,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.create_task(_execute())
|
task = asyncio.create_task(_execute())
|
||||||
|
_background_callback_tasks.add(task)
|
||||||
|
task.add_done_callback(_background_callback_tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=MediaStatus)
|
@router.get("/status", response_model=MediaStatus)
|
||||||
@@ -242,11 +278,14 @@ async def toggle(_: str = Depends(verify_token)) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/artwork")
|
@router.get("/artwork")
|
||||||
async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
|
async def get_artwork(
|
||||||
|
request: Request,
|
||||||
|
_: str = Depends(verify_token_or_query),
|
||||||
|
) -> Response:
|
||||||
"""Get the current album artwork.
|
"""Get the current album artwork.
|
||||||
|
|
||||||
Returns:
|
Returns the bytes with a content-derived ETag so the browser can serve a
|
||||||
The album art image as PNG/JPEG
|
304 when the same track is re-requested.
|
||||||
"""
|
"""
|
||||||
art_bytes = get_current_album_art()
|
art_bytes = get_current_album_art()
|
||||||
if art_bytes is None:
|
if art_bytes is None:
|
||||||
@@ -255,16 +294,34 @@ async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
|
|||||||
detail="No album artwork available",
|
detail="No album artwork available",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to detect image type from magic bytes
|
# Detect image type from magic bytes
|
||||||
content_type = "image/png" # Default
|
|
||||||
if art_bytes[:3] == b"\xff\xd8\xff":
|
if art_bytes[:3] == b"\xff\xd8\xff":
|
||||||
content_type = "image/jpeg"
|
content_type = "image/jpeg"
|
||||||
elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n":
|
elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
content_type = "image/png"
|
content_type = "image/png"
|
||||||
elif art_bytes[:4] == b"RIFF" and art_bytes[8:12] == b"WEBP":
|
elif art_bytes[:4] == b"RIFF" and len(art_bytes) > 12 and art_bytes[8:12] == b"WEBP":
|
||||||
content_type = "image/webp"
|
content_type = "image/webp"
|
||||||
|
elif art_bytes[:2] == b"BM":
|
||||||
|
content_type = "image/bmp"
|
||||||
|
else:
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
|
||||||
return Response(content=art_bytes, media_type=content_type)
|
# Content-derived ETag (blake2b-128 — non-crypto cache key, ruff S324-safe)
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
etag = '"' + hashlib.blake2b(art_bytes, digest_size=16).hexdigest() + '"'
|
||||||
|
|
||||||
|
if request.headers.get("if-none-match") == etag:
|
||||||
|
return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": etag})
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=art_bytes,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"ETag": etag,
|
||||||
|
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/visualizer/status")
|
@router.get("/visualizer/status")
|
||||||
@@ -323,12 +380,17 @@ async def set_visualizer_device(
|
|||||||
@router.websocket("/ws")
|
@router.websocket("/ws")
|
||||||
async def websocket_endpoint(
|
async def websocket_endpoint(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
token: str | None = Query(None, description="API authentication token"),
|
token: str | None = Query(None, description="API authentication token (legacy)"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""WebSocket endpoint for real-time media status updates.
|
"""WebSocket endpoint for real-time media status updates.
|
||||||
|
|
||||||
Authentication is done via query parameter since WebSocket
|
Authentication is accepted from two sources, in priority order:
|
||||||
doesn't support custom headers in the browser.
|
1. ``Sec-WebSocket-Protocol`` subprotocol of the form
|
||||||
|
``media-server.token.<TOKEN>``. This is the preferred path because
|
||||||
|
the token never lands in the URL, request logs, or browser history.
|
||||||
|
The browser WebSocket API supports custom subprotocols natively.
|
||||||
|
2. ``?token=<TOKEN>`` query parameter (legacy, kept for back-compat
|
||||||
|
with older clients and the HA integration).
|
||||||
|
|
||||||
Messages sent to client:
|
Messages sent to client:
|
||||||
- {"type": "status", "data": {...}} - Initial status on connect
|
- {"type": "status", "data": {...}} - Initial status on connect
|
||||||
@@ -339,11 +401,54 @@ async def websocket_endpoint(
|
|||||||
- {"type": "ping"} - Keepalive, server responds with {"type": "pong"}
|
- {"type": "ping"} - Keepalive, server responds with {"type": "pong"}
|
||||||
- {"type": "get_status"} - Request current status
|
- {"type": "get_status"} - Request current status
|
||||||
"""
|
"""
|
||||||
|
# Pull token from subprotocol if present. WebSocket spec lets either side
|
||||||
|
# negotiate exactly one subprotocol back; we accept the token one and
|
||||||
|
# answer with the same string so browsers consider the negotiation
|
||||||
|
# successful.
|
||||||
|
subprotocol_token: str | None = None
|
||||||
|
accept_subprotocol: str | None = None
|
||||||
|
raw_protocols = websocket.headers.get("sec-websocket-protocol", "")
|
||||||
|
for proto in (p.strip() for p in raw_protocols.split(",") if p.strip()):
|
||||||
|
if proto.startswith("media-server.token."):
|
||||||
|
subprotocol_token = proto[len("media-server.token."):]
|
||||||
|
accept_subprotocol = proto
|
||||||
|
break
|
||||||
|
effective_token = subprotocol_token or token
|
||||||
|
# Origin check — block CSWSH from third-party LAN pages. Accept the same
|
||||||
|
# set of origins as CORS plus the default localhost loopback, AND any
|
||||||
|
# same-origin connection (where Origin matches the request's Host header).
|
||||||
|
# Same-origin is inherently safe from CSWSH because CSWSH is a *cross*-
|
||||||
|
# origin attack — without this, binding to 0.0.0.0 and accessing the UI
|
||||||
|
# via a LAN IP would have its WebSocket rejected by the browser-sent
|
||||||
|
# Origin, which the static allowlist can't anticipate.
|
||||||
|
allowed_origins = set(
|
||||||
|
settings.cors_origins
|
||||||
|
or [
|
||||||
|
f"http://localhost:{settings.port}",
|
||||||
|
f"http://127.0.0.1:{settings.port}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
origin = websocket.headers.get("origin")
|
||||||
|
# Same-origin connections from native apps may omit Origin entirely; only
|
||||||
|
# reject when an Origin is present AND not in the allow-list.
|
||||||
|
if origin is not None and origin not in allowed_origins:
|
||||||
|
host_header = websocket.headers.get("host", "")
|
||||||
|
# Origin uses http/https; match against both scheme variants of Host
|
||||||
|
# so HTTPS deployments without an explicit cors_origins still work.
|
||||||
|
same_origin_candidates = (
|
||||||
|
{f"http://{host_header}", f"https://{host_header}"}
|
||||||
|
if host_header
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
if origin not in same_origin_candidates:
|
||||||
|
await websocket.close(code=4003, reason="Origin not allowed")
|
||||||
|
return
|
||||||
|
|
||||||
# Verify token
|
# Verify token
|
||||||
from ..auth import auth_enabled, get_token_label, token_label_var
|
from ..auth import auth_enabled, get_token_label, token_label_var
|
||||||
|
|
||||||
if auth_enabled():
|
if auth_enabled():
|
||||||
label = get_token_label(token) if token else None
|
label = get_token_label(effective_token) if effective_token else None
|
||||||
if label is None:
|
if label is None:
|
||||||
await websocket.close(code=4001, reason="Invalid authentication token")
|
await websocket.close(code=4001, reason="Invalid authentication token")
|
||||||
return
|
return
|
||||||
@@ -351,6 +456,13 @@ async def websocket_endpoint(
|
|||||||
else:
|
else:
|
||||||
token_label_var.set("anonymous")
|
token_label_var.set("anonymous")
|
||||||
|
|
||||||
|
# Accept with the negotiated subprotocol if one was used. Starlette's
|
||||||
|
# connect() calls accept() with no subprotocol — we need to accept first
|
||||||
|
# explicitly to echo the subprotocol back, then hand off to the manager.
|
||||||
|
if accept_subprotocol is not None:
|
||||||
|
await websocket.accept(subprotocol=accept_subprotocol)
|
||||||
|
await ws_manager.connect(websocket, already_accepted=True)
|
||||||
|
else:
|
||||||
await ws_manager.connect(websocket)
|
await ws_manager.connect(websocket)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -358,9 +470,11 @@ async def websocket_endpoint(
|
|||||||
# Wait for messages from client (for keepalive/ping)
|
# Wait for messages from client (for keepalive/ping)
|
||||||
data = await websocket.receive_json()
|
data = await websocket.receive_json()
|
||||||
|
|
||||||
if data.get("type") == "ping":
|
msg_type = data.get("type") if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
if msg_type == "ping":
|
||||||
await websocket.send_json({"type": "pong"})
|
await websocket.send_json({"type": "pong"})
|
||||||
elif data.get("type") == "get_status":
|
elif msg_type == "get_status":
|
||||||
# Allow manual status request
|
# Allow manual status request
|
||||||
controller = get_media_controller()
|
controller = get_media_controller()
|
||||||
status_data = await controller.get_status()
|
status_data = await controller.get_status()
|
||||||
@@ -368,15 +482,20 @@ async def websocket_endpoint(
|
|||||||
"type": "status",
|
"type": "status",
|
||||||
"data": status_data.model_dump(),
|
"data": status_data.model_dump(),
|
||||||
})
|
})
|
||||||
elif data.get("type") == "volume":
|
elif msg_type == "volume":
|
||||||
# Low-latency volume control via WebSocket
|
# Low-latency volume control via WebSocket. Coerce, clamp, and
|
||||||
volume = data.get("volume")
|
# never drop the socket on a single bad message — that would
|
||||||
if volume is not None:
|
# turn the WS into a one-shot DoS for any holder of a token.
|
||||||
|
try:
|
||||||
|
volume = int(data.get("volume"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
volume = max(0, min(100, volume))
|
||||||
controller = get_media_controller()
|
controller = get_media_controller()
|
||||||
await controller.set_volume(int(volume))
|
await controller.set_volume(volume)
|
||||||
elif data.get("type") == "enable_visualizer":
|
elif msg_type == "enable_visualizer":
|
||||||
await ws_manager.subscribe_visualizer(websocket)
|
await ws_manager.subscribe_visualizer(websocket)
|
||||||
elif data.get("type") == "disable_visualizer":
|
elif msg_type == "disable_visualizer":
|
||||||
await ws_manager.unsubscribe_visualizer(websocket)
|
await ws_manager.unsubscribe_visualizer(websocket)
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import time
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import verify_token
|
from ..auth import verify_token
|
||||||
from ..config import ScriptConfig, ScriptParameterConfig, settings
|
from ..config import ScriptConfig, ScriptParameterConfig, settings
|
||||||
from ..config_manager import config_manager
|
from ..config_manager import config_manager
|
||||||
|
from ..services.rate_limit import check as ratelimit_check
|
||||||
|
from ..services.rate_limit import get_peer
|
||||||
from ..services.websocket_manager import ws_manager
|
from ..services.websocket_manager import ws_manager
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
||||||
@@ -31,6 +33,12 @@ def shutdown_script_executor() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _require_scripts_management() -> None:
|
def _require_scripts_management() -> None:
|
||||||
|
"""Authorise a scripts-CRUD operation.
|
||||||
|
|
||||||
|
Two gates: the operator-level `scripts_management` flag in config.yaml,
|
||||||
|
AND the per-token `admin` scope check (read from request-context). Either
|
||||||
|
failure → 403.
|
||||||
|
"""
|
||||||
if not settings.scripts_management:
|
if not settings.scripts_management:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
@@ -39,6 +47,14 @@ def _require_scripts_management() -> None:
|
|||||||
" in config.yaml to enable."
|
" in config.yaml to enable."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
from ..auth import auth_enabled, token_has_scope, token_label_var
|
||||||
|
if auth_enabled():
|
||||||
|
label = token_label_var.get("unknown")
|
||||||
|
if not token_has_scope(label, "admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Token '{label}' lacks required scope: admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ScriptExecuteRequest(BaseModel):
|
class ScriptExecuteRequest(BaseModel):
|
||||||
@@ -215,6 +231,28 @@ def _validate_params(
|
|||||||
# string — just convert to str
|
# string — just convert to str
|
||||||
value = str(value)
|
value = str(value)
|
||||||
|
|
||||||
|
# Optional regex constraint, validated against the *string form* of the
|
||||||
|
# value. This is the only practical defence for string parameters that
|
||||||
|
# flow into shell=true scripts via env vars (Windows cmd.exe expands
|
||||||
|
# `%VAR%` after argument parsing, so embedded `&`/`|`/`%` would inject
|
||||||
|
# commands). Authors of shell scripts should ALWAYS define a pattern.
|
||||||
|
if pdef.pattern:
|
||||||
|
try:
|
||||||
|
if not re.fullmatch(pdef.pattern, str(value)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"Parameter '{pname}' value {value!r} does not match"
|
||||||
|
f" required pattern: {pdef.pattern}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except re.error as e:
|
||||||
|
# Bad pattern in config — fail closed.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Parameter '{pname}' has invalid pattern: {e}",
|
||||||
|
) from e
|
||||||
|
|
||||||
env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value)
|
env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value)
|
||||||
|
|
||||||
return env_vars
|
return env_vars
|
||||||
@@ -223,6 +261,7 @@ def _validate_params(
|
|||||||
@router.post("/execute/{script_name}")
|
@router.post("/execute/{script_name}")
|
||||||
async def execute_script(
|
async def execute_script(
|
||||||
script_name: str,
|
script_name: str,
|
||||||
|
http_request: Request,
|
||||||
request: ScriptExecuteRequest | None = None,
|
request: ScriptExecuteRequest | None = None,
|
||||||
_: str = Depends(verify_token),
|
_: str = Depends(verify_token),
|
||||||
) -> ScriptExecuteResponse:
|
) -> ScriptExecuteResponse:
|
||||||
@@ -235,6 +274,16 @@ async def execute_script(
|
|||||||
Returns:
|
Returns:
|
||||||
Execution result including stdout, stderr, and exit code
|
Execution result including stdout, stderr, and exit code
|
||||||
"""
|
"""
|
||||||
|
# Rate-limit script execution per peer so a leaked token can't be used to
|
||||||
|
# spam the shell-exec endpoint.
|
||||||
|
allowed, retry_after = ratelimit_check("execute", get_peer(http_request))
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many script executions, slow down",
|
||||||
|
headers={"Retry-After": str(int(retry_after or 60))},
|
||||||
|
)
|
||||||
|
|
||||||
# Check if script exists
|
# Check if script exists
|
||||||
if script_name not in settings.scripts:
|
if script_name not in settings.scripts:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -249,6 +298,8 @@ async def execute_script(
|
|||||||
|
|
||||||
logger.info(f"Executing script: {script_name}")
|
logger.info(f"Executing script: {script_name}")
|
||||||
|
|
||||||
|
from ..services.audit_log import record_script_execution
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Execute in dedicated thread pool to not block the default executor
|
# Execute in dedicated thread pool to not block the default executor
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -263,6 +314,15 @@ async def execute_script(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
record_script_execution(
|
||||||
|
kind="script",
|
||||||
|
name=script_name,
|
||||||
|
exit_code=result["exit_code"],
|
||||||
|
duration=result.get("execution_time"),
|
||||||
|
stdout=result.get("stdout"),
|
||||||
|
stderr=result.get("stderr"),
|
||||||
|
)
|
||||||
|
|
||||||
return ScriptExecuteResponse(
|
return ScriptExecuteResponse(
|
||||||
success=result["exit_code"] == 0,
|
success=result["exit_code"] == 0,
|
||||||
script=script_name,
|
script=script_name,
|
||||||
@@ -274,6 +334,13 @@ async def execute_script(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Script execution error: {e}")
|
logger.error(f"Script execution error: {e}")
|
||||||
|
record_script_execution(
|
||||||
|
kind="script",
|
||||||
|
name=script_name,
|
||||||
|
exit_code=None,
|
||||||
|
duration=None,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
return ScriptExecuteResponse(
|
return ScriptExecuteResponse(
|
||||||
success=False,
|
success=False,
|
||||||
script=script_name,
|
script=script_name,
|
||||||
@@ -313,9 +380,21 @@ def _run_script(
|
|||||||
else:
|
else:
|
||||||
popen_kwargs["start_new_session"] = True
|
popen_kwargs["start_new_session"] = True
|
||||||
|
|
||||||
|
# When shell=False, the user-provided command string is split via shlex
|
||||||
|
# (POSIX rules — also works for Windows args without backslashes). This
|
||||||
|
# disables shell metacharacter expansion entirely, so SCRIPT_PARAM_* env
|
||||||
|
# vars referenced as $FOO / %FOO% will be treated as literal text by the
|
||||||
|
# process, not interpreted by a shell. Use shell=false for any script
|
||||||
|
# whose params come from external input.
|
||||||
|
if shell:
|
||||||
|
run_command: str | list[str] = command
|
||||||
|
else:
|
||||||
|
import shlex
|
||||||
|
run_command = shlex.split(command, posix=(sys.platform != "win32"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
run_command,
|
||||||
shell=shell,
|
shell=shell,
|
||||||
cwd=working_dir,
|
cwd=working_dir,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Append-only audit log for sensitive actions (script + callback execution).
|
||||||
|
|
||||||
|
Writes a single JSONL line per event to ``<config_dir>/audit.log``. The log is
|
||||||
|
write-only from the app's perspective — it never reads back, and rotation is
|
||||||
|
left to the operator (the file size is dominated by stdout/stderr truncation,
|
||||||
|
which is already capped at 10 KB per stream in `_run_script`).
|
||||||
|
|
||||||
|
Designed to be cheap: the write goes through a small background thread so the
|
||||||
|
hot path never blocks on disk I/O, and a failure to write is logged at WARNING
|
||||||
|
but never raised to callers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..auth import token_label_var
|
||||||
|
from ..config import get_config_dir
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cap on stdout/stderr inside the audit record so a chatty script doesn't
|
||||||
|
# explode the log. Mirrors the 10k cap used by _run_script.
|
||||||
|
_OUTPUT_CAP = 2000
|
||||||
|
|
||||||
|
_audit_queue: "queue.Queue[dict[str, Any] | None]" = queue.Queue(maxsize=1000)
|
||||||
|
_audit_thread: threading.Thread | None = None
|
||||||
|
_audit_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_writer_started() -> None:
|
||||||
|
global _audit_thread
|
||||||
|
with _audit_lock:
|
||||||
|
if _audit_thread is not None and _audit_thread.is_alive():
|
||||||
|
return
|
||||||
|
_audit_thread = threading.Thread(
|
||||||
|
target=_audit_writer_loop,
|
||||||
|
name="audit-log",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
_audit_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_writer_loop() -> None:
|
||||||
|
log_path = get_config_dir() / "audit.log"
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
record = _audit_queue.get()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if record is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
line = json.dumps(record, ensure_ascii=False, default=str)
|
||||||
|
with open(log_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("Failed to write audit record: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if len(value) <= _OUTPUT_CAP:
|
||||||
|
return value
|
||||||
|
return value[:_OUTPUT_CAP] + f"\n…[truncated, {len(value) - _OUTPUT_CAP} chars]"
|
||||||
|
|
||||||
|
|
||||||
|
def record_script_execution(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
name: str,
|
||||||
|
exit_code: int | None,
|
||||||
|
duration: float | None,
|
||||||
|
stdout: str | None = None,
|
||||||
|
stderr: str | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Append a single audit record. Never raises."""
|
||||||
|
_ensure_writer_started()
|
||||||
|
try:
|
||||||
|
record = {
|
||||||
|
"ts": time.time(),
|
||||||
|
"iso": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()),
|
||||||
|
"token_label": token_label_var.get("unknown"),
|
||||||
|
"kind": kind,
|
||||||
|
"name": name,
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"duration_s": round(duration, 4) if duration is not None else None,
|
||||||
|
"success": exit_code == 0 if exit_code is not None else False,
|
||||||
|
"stdout": _truncate(stdout),
|
||||||
|
"stderr": _truncate(stderr),
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
_audit_queue.put_nowait(record)
|
||||||
|
except queue.Full:
|
||||||
|
# Backpressure: drop oldest record to make room. We'd rather lose an
|
||||||
|
# old entry than block the script that just ran.
|
||||||
|
try:
|
||||||
|
_audit_queue.get_nowait()
|
||||||
|
_audit_queue.put_nowait(record)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to enqueue audit record: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown_audit_log() -> None:
|
||||||
|
"""Flush the audit queue on app shutdown."""
|
||||||
|
try:
|
||||||
|
_audit_queue.put_nowait(None)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
if _audit_thread is not None:
|
||||||
|
_audit_thread.join(timeout=2)
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""Extract page-level metadata from a focused desktop web browser.
|
||||||
|
|
||||||
|
The browser's window title is the reliable signal — every major browser
|
||||||
|
formats it as ``"<page title> - <Browser Name>"``, so stripping the suffix
|
||||||
|
gives us the page title for free.
|
||||||
|
|
||||||
|
URL extraction was attempted via UI Automation (UIA), but Chromium-based
|
||||||
|
browsers (Chrome/Edge/Brave/Vivaldi) keep their accessibility tree dormant
|
||||||
|
unless a screen reader is active or ``--force-renderer-accessibility`` is
|
||||||
|
set — neither is something we want to require from end users. The UIA
|
||||||
|
machinery is still here behind a feature flag in case a future caller
|
||||||
|
opts into the accessibility-flag path; by default we just return the
|
||||||
|
page title and leave ``url=None``.
|
||||||
|
|
||||||
|
Other platforms (macOS via AppleScript, Linux via AT-SPI) are out of scope
|
||||||
|
for this iteration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# UIA URL extraction is opt-in because Chromium browsers keep their
|
||||||
|
# accessibility tree dormant unless the user starts the browser with
|
||||||
|
# ``--force-renderer-accessibility`` (or a screen reader is running).
|
||||||
|
# Without that, `FindAll` throws and we'd burn 5s per probe retrying.
|
||||||
|
# Set MEDIA_SERVER_BROWSER_UIA=1 to enable; default off.
|
||||||
|
_UIA_ENABLED = os.environ.get("MEDIA_SERVER_BROWSER_UIA", "").lower() in (
|
||||||
|
"1", "true", "yes", "on"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Known browser executables (lowercase, .exe-stripped). Used to decide
|
||||||
|
# whether to spend the UIA query budget on this foreground process.
|
||||||
|
BROWSER_PROCESS_HINTS: frozenset[str] = frozenset({
|
||||||
|
"chrome",
|
||||||
|
"msedge",
|
||||||
|
"firefox",
|
||||||
|
"brave",
|
||||||
|
"opera",
|
||||||
|
"vivaldi",
|
||||||
|
"yandex",
|
||||||
|
"browser", # Yandex Browser sometimes reports as browser.exe
|
||||||
|
"arc",
|
||||||
|
"thorium",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BrowserPageInfo:
|
||||||
|
url: str | None = None
|
||||||
|
page_title: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_EMPTY = BrowserPageInfo()
|
||||||
|
|
||||||
|
|
||||||
|
def is_browser_process(process_name: str | None) -> bool:
|
||||||
|
"""Return True when ``process_name`` looks like a supported browser."""
|
||||||
|
if not process_name:
|
||||||
|
return False
|
||||||
|
base = process_name.lower()
|
||||||
|
if base.endswith(".exe"):
|
||||||
|
base = base[:-4]
|
||||||
|
return base in BROWSER_PROCESS_HINTS
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_browser_suffix(title: str | None, process_name: str | None) -> str | None:
|
||||||
|
"""Pull the page title out of the browser's window title.
|
||||||
|
|
||||||
|
Most browsers format their window title as ``"<page> - <Browser Name>"``.
|
||||||
|
We strip the trailing suffix so consumers get the page title alone. If
|
||||||
|
the suffix can't be matched, return the raw title unchanged.
|
||||||
|
"""
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
suffixes = (
|
||||||
|
" - Google Chrome",
|
||||||
|
" — Google Chrome",
|
||||||
|
" - Microsoft Edge",
|
||||||
|
" - Microsoft Edge",
|
||||||
|
" — Mozilla Firefox",
|
||||||
|
" - Mozilla Firefox",
|
||||||
|
" - Brave",
|
||||||
|
" - Opera",
|
||||||
|
" - Vivaldi",
|
||||||
|
" - Yandex",
|
||||||
|
)
|
||||||
|
for s in suffixes:
|
||||||
|
if title.endswith(s):
|
||||||
|
return title[: -len(s)].strip() or None
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UIA lookup (Windows) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
# UIA control type / property constants we need. Avoiding the full
|
||||||
|
# UIAutomationClient typelib generation — those constants are stable.
|
||||||
|
_UIA_EditControlTypeId = 50004
|
||||||
|
_UIA_ControlTypePropertyId = 30003
|
||||||
|
_UIA_ValueValuePropertyId = 30045
|
||||||
|
_UIA_NamePropertyId = 30005
|
||||||
|
_UIA_ValuePatternId = 10002
|
||||||
|
_TreeScope_Descendants = 4
|
||||||
|
_PropertyConditionFlags_IgnoreCase = 1
|
||||||
|
|
||||||
|
|
||||||
|
# Lazy import + per-thread COM init.
|
||||||
|
_uia_lock = threading.Lock()
|
||||||
|
_uia_singleton = None
|
||||||
|
_uia_load_error: str | None = None
|
||||||
|
_uia_thread_local = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_com() -> None:
|
||||||
|
"""Initialise COM on the current thread (idempotent per thread)."""
|
||||||
|
if getattr(_uia_thread_local, "initialised", False):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import comtypes # type: ignore
|
||||||
|
|
||||||
|
# COINIT_APARTMENTTHREADED is required by UIA; comtypes' default
|
||||||
|
# CoInitializeEx already passes that flag.
|
||||||
|
comtypes.CoInitialize()
|
||||||
|
_uia_thread_local.initialised = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("CoInitialize failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_uia():
|
||||||
|
"""Return the IUIAutomation singleton, or None if unavailable."""
|
||||||
|
global _uia_singleton, _uia_load_error
|
||||||
|
if _uia_singleton is not None:
|
||||||
|
return _uia_singleton
|
||||||
|
if _uia_load_error is not None:
|
||||||
|
return None
|
||||||
|
with _uia_lock:
|
||||||
|
if _uia_singleton is not None:
|
||||||
|
return _uia_singleton
|
||||||
|
try:
|
||||||
|
import comtypes.client # type: ignore
|
||||||
|
|
||||||
|
# CLSID for CUIAutomation. Using GetActiveObject would fail,
|
||||||
|
# so we cocreate. comtypes.client.CreateObject keeps the COM
|
||||||
|
# plumbing tidy.
|
||||||
|
_uia_singleton = comtypes.client.CreateObject(
|
||||||
|
"{ff48dba4-60ef-4201-aa87-54103eef594e}",
|
||||||
|
interface=comtypes.client.GetModule(
|
||||||
|
"UIAutomationCore.dll"
|
||||||
|
).IUIAutomation,
|
||||||
|
)
|
||||||
|
return _uia_singleton
|
||||||
|
except Exception as e:
|
||||||
|
_uia_load_error = str(e)
|
||||||
|
logger.info("UIA unavailable; browser URL extraction disabled: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_address_bar_value(hwnd: int) -> str | None:
|
||||||
|
"""Walk the UIA tree under ``hwnd`` looking for the URL Edit control.
|
||||||
|
|
||||||
|
Strategy: find every descendant Edit control, then pick the first one
|
||||||
|
whose Name contains an address-bar hint, or — failing that — the first
|
||||||
|
one whose value parses as a URL-ish string. Browsers expose extra Edit
|
||||||
|
controls (search bars, find-in-page) so name matching is the reliable
|
||||||
|
signal; the URL-ish fallback covers locale variants we haven't seen.
|
||||||
|
"""
|
||||||
|
_ensure_com()
|
||||||
|
uia = _get_uia()
|
||||||
|
if uia is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
element = uia.ElementFromHandle(hwnd)
|
||||||
|
if not element:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build a condition matching ControlType=Edit, then enumerate.
|
||||||
|
edit_condition = uia.CreatePropertyCondition(
|
||||||
|
_UIA_ControlTypePropertyId, _UIA_EditControlTypeId
|
||||||
|
)
|
||||||
|
edits = element.FindAll(_TreeScope_Descendants, edit_condition)
|
||||||
|
count = edits.Length if edits else 0
|
||||||
|
if count == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Hints (lowercase) used to identify the address bar by its Name
|
||||||
|
# property. Covers en-US plus a few common locales / browsers.
|
||||||
|
name_hints = (
|
||||||
|
"address", # Chrome/Edge: "Address and search bar"
|
||||||
|
"адрес", # Chrome ru: "Адресная строка и строка поиска"
|
||||||
|
"адресная",
|
||||||
|
"search with", # Firefox: "Search with Google or enter address"
|
||||||
|
"поиск или ввод", # Firefox ru
|
||||||
|
"url",
|
||||||
|
"location",
|
||||||
|
)
|
||||||
|
|
||||||
|
# First pass: name-based match (high confidence).
|
||||||
|
candidates: list[tuple[int, str]] = []
|
||||||
|
for i in range(count):
|
||||||
|
edit = edits.GetElement(i)
|
||||||
|
try:
|
||||||
|
name = (edit.CurrentName or "").lower()
|
||||||
|
except Exception:
|
||||||
|
name = ""
|
||||||
|
try:
|
||||||
|
value = edit.GetCurrentPropertyValue(_UIA_ValueValuePropertyId)
|
||||||
|
except Exception:
|
||||||
|
value = None
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
value_str = str(value)
|
||||||
|
for h in name_hints:
|
||||||
|
if h in name:
|
||||||
|
return value_str
|
||||||
|
candidates.append((i, value_str))
|
||||||
|
|
||||||
|
# Second pass: URL-ish fallback. Pick the first candidate that
|
||||||
|
# looks like a URL; this catches browser/locale combos we haven't
|
||||||
|
# listed above.
|
||||||
|
for _i, v in candidates:
|
||||||
|
lv = v.lower()
|
||||||
|
if (
|
||||||
|
lv.startswith("http://")
|
||||||
|
or lv.startswith("https://")
|
||||||
|
or lv.startswith("about:")
|
||||||
|
or lv.startswith("chrome://")
|
||||||
|
or lv.startswith("edge://")
|
||||||
|
or lv.startswith("brave://")
|
||||||
|
or lv.startswith("file://")
|
||||||
|
or lv.startswith("ftp://")
|
||||||
|
):
|
||||||
|
return v
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("UIA address-bar lookup failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Per-(hwnd, title) cache ────────────────────────────────────────
|
||||||
|
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
_cache_key: tuple[int | None, str | None] = (None, None)
|
||||||
|
_cache_value: BrowserPageInfo = _EMPTY
|
||||||
|
|
||||||
|
|
||||||
|
def get_browser_page(
|
||||||
|
*,
|
||||||
|
hwnd: int | None,
|
||||||
|
process_name: str | None,
|
||||||
|
window_title: str | None,
|
||||||
|
) -> BrowserPageInfo:
|
||||||
|
"""Return the URL + page title for the foreground browser tab, if any.
|
||||||
|
|
||||||
|
Callers pass the already-resolved foreground HWND/title/process_name so
|
||||||
|
this service doesn't re-walk Win32 to find them. Returns ``_EMPTY`` for
|
||||||
|
non-browser processes or when UIA can't resolve the URL.
|
||||||
|
"""
|
||||||
|
if not is_browser_process(process_name):
|
||||||
|
return _EMPTY
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
# macOS/Linux paths not implemented in this iteration.
|
||||||
|
return _EMPTY
|
||||||
|
if not hwnd:
|
||||||
|
return _EMPTY
|
||||||
|
|
||||||
|
global _cache_key, _cache_value
|
||||||
|
key = (hwnd, window_title)
|
||||||
|
with _cache_lock:
|
||||||
|
if key == _cache_key and _cache_value is not _EMPTY:
|
||||||
|
return _cache_value
|
||||||
|
|
||||||
|
url = _find_address_bar_value(hwnd) if _UIA_ENABLED else None
|
||||||
|
page_title = _strip_browser_suffix(window_title, process_name)
|
||||||
|
info = BrowserPageInfo(url=url, page_title=page_title)
|
||||||
|
|
||||||
|
with _cache_lock:
|
||||||
|
_cache_key = key
|
||||||
|
_cache_value = info
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def reset_cache() -> None:
|
||||||
|
"""Reset the cache. Useful in tests."""
|
||||||
|
global _cache_key, _cache_value
|
||||||
|
with _cache_lock:
|
||||||
|
_cache_key = (None, None)
|
||||||
|
_cache_value = _EMPTY
|
||||||
@@ -192,10 +192,11 @@ _CACHE_TTL = 5.0 # seconds
|
|||||||
# Per-monitor cache of static capabilities (option lists + support flags).
|
# Per-monitor cache of static capabilities (option lists + support flags).
|
||||||
# DDC/CI capability discovery is the slow part — it only changes when a
|
# DDC/CI capability discovery is the slow part — it only changes when a
|
||||||
# monitor is replaced or rewired, so we probe it once per monitor and reuse
|
# monitor is replaced or rewired, so we probe it once per monitor and reuse
|
||||||
# it across refreshes. Cleared on explicit `rediscover` or when the monitor
|
# it across refreshes. Keyed by a stable identity tuple
|
||||||
# count changes (cheap stale-detection for hot-plug events).
|
# (manufacturer, model, edid_hash) so that hot-plug swaps where the new
|
||||||
_static_cache: dict[int, dict] = {}
|
# topology has the same number of monitors but different devices still
|
||||||
_static_cache_monitor_count: int = -1
|
# refresh the cache for the new monitor instead of serving stale capabilities.
|
||||||
|
_static_cache: dict[tuple, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def _enum_name(value, enum_cls=None) -> str | None:
|
def _enum_name(value, enum_cls=None) -> str | None:
|
||||||
@@ -353,7 +354,7 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
|
|||||||
next probe re-runs DDC/CI capability discovery. Use after hot-plug
|
next probe re-runs DDC/CI capability discovery. Use after hot-plug
|
||||||
or when a monitor's reported capabilities change.
|
or when a monitor's reported capabilities change.
|
||||||
"""
|
"""
|
||||||
global _monitor_cache, _cache_time, _static_cache_monitor_count
|
global _monitor_cache, _cache_time
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not force_refresh
|
not force_refresh
|
||||||
@@ -372,12 +373,11 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
|
|||||||
info_list = sbc.list_monitors_info()
|
info_list = sbc.list_monitors_info()
|
||||||
brightnesses = sbc.get_brightness()
|
brightnesses = sbc.get_brightness()
|
||||||
|
|
||||||
# Invalidate the static cache on explicit rediscover OR on topology
|
# Explicit rediscover wipes the whole cache; otherwise rely on stable
|
||||||
# change (hot-plug / disconnect). Both indicate the cached probe is
|
# per-monitor keys (manufacturer|model|edid_hash) so a hot-plug swap
|
||||||
# potentially stale.
|
# invalidates the entry for the missing monitor automatically.
|
||||||
if rediscover or len(info_list) != _static_cache_monitor_count:
|
if rediscover:
|
||||||
_static_cache.clear()
|
_static_cache.clear()
|
||||||
_static_cache_monitor_count = len(info_list)
|
|
||||||
|
|
||||||
mc = _load_monitorcontrol()
|
mc = _load_monitorcontrol()
|
||||||
ddc_monitors = []
|
ddc_monitors = []
|
||||||
@@ -387,6 +387,9 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
seen_keys: set[tuple] = set()
|
||||||
for i, info in enumerate(info_list):
|
for i, info in enumerate(info_list):
|
||||||
name = info.get("name", f"Monitor {i}")
|
name = info.get("name", f"Monitor {i}")
|
||||||
model = info.get("model", "")
|
model = info.get("model", "")
|
||||||
@@ -400,6 +403,21 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
|
|||||||
edid = info.get("edid", "")
|
edid = info.get("edid", "")
|
||||||
resolution = _parse_edid_resolution(edid) if edid else None
|
resolution = _parse_edid_resolution(edid) if edid else None
|
||||||
|
|
||||||
|
# Stable cache key — EDID hash is unique per physical monitor.
|
||||||
|
# Fall back to (manufacturer, model, serial-ish) when EDID is
|
||||||
|
# missing, then to the legacy index as a last resort.
|
||||||
|
if edid:
|
||||||
|
edid_hash = hashlib.blake2b(
|
||||||
|
edid.encode("utf-8") if isinstance(edid, str) else bytes(edid),
|
||||||
|
digest_size=8,
|
||||||
|
).hexdigest()
|
||||||
|
cache_key: tuple = ("edid", edid_hash)
|
||||||
|
elif manufacturer or model:
|
||||||
|
cache_key = ("mm", manufacturer, model, name)
|
||||||
|
else:
|
||||||
|
cache_key = ("idx", i)
|
||||||
|
seen_keys.add(cache_key)
|
||||||
|
|
||||||
static: dict = {}
|
static: dict = {}
|
||||||
dynamic: dict = {}
|
dynamic: dict = {}
|
||||||
|
|
||||||
@@ -409,13 +427,13 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
|
|||||||
if power_supported and i < len(ddc_monitors):
|
if power_supported and i < len(ddc_monitors):
|
||||||
try:
|
try:
|
||||||
with ddc_monitors[i] as mon:
|
with ddc_monitors[i] as mon:
|
||||||
if i not in _static_cache:
|
if cache_key not in _static_cache:
|
||||||
_static_cache[i] = _probe_static_open(mon, mc, i)
|
_static_cache[cache_key] = _probe_static_open(mon, mc, i)
|
||||||
static = _static_cache[i]
|
static = _static_cache[cache_key]
|
||||||
dynamic = _probe_dynamic_open(mon, mc, i, static)
|
dynamic = _probe_dynamic_open(mon, mc, i, static)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Monitor %d: DDC/CI session failed: %s", i, e)
|
logger.debug("Monitor %d: DDC/CI session failed: %s", i, e)
|
||||||
static = _static_cache.get(i, {})
|
static = _static_cache.get(cache_key, {})
|
||||||
|
|
||||||
monitors.append(MonitorInfo(
|
monitors.append(MonitorInfo(
|
||||||
id=i,
|
id=i,
|
||||||
@@ -439,6 +457,12 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
|
|||||||
available_picture_modes=static.get("available_picture_modes", []),
|
available_picture_modes=static.get("available_picture_modes", []),
|
||||||
picture_mode_supported=static.get("picture_mode_supported", False),
|
picture_mode_supported=static.get("picture_mode_supported", False),
|
||||||
))
|
))
|
||||||
|
# Evict cache entries for monitors that disappeared from this scan so
|
||||||
|
# the next hot-plug of a different monitor with the same identity
|
||||||
|
# tuple (e.g. same model) doesn't hit a stale entry first.
|
||||||
|
for stale_key in list(_static_cache.keys()):
|
||||||
|
if stale_key not in seen_keys:
|
||||||
|
_static_cache.pop(stale_key, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to enumerate monitors: %s", e)
|
logger.error("Failed to enumerate monitors: %s", e)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
"""Foreground (topmost) window/process tracking.
|
||||||
|
|
||||||
|
Reports the process that currently owns the foreground window, plus useful
|
||||||
|
metadata (window title, executable path, monitor index, whether the window
|
||||||
|
covers a full monitor, process start time).
|
||||||
|
|
||||||
|
All probes happen behind a short TTL cache so the WebSocket status poll and
|
||||||
|
per-entity HA polls don't pay the OS call cost on every tick.
|
||||||
|
|
||||||
|
Windows uses the Win32 API via ``ctypes`` (no extra dependency) and falls back
|
||||||
|
gracefully when individual probes fail. Linux/macOS implementations are
|
||||||
|
best-effort and return ``available=False`` when the required tooling is
|
||||||
|
missing, so the rest of the stack keeps working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CACHE_TTL = 0.5 # seconds — fast enough for WebSocket broadcast loop
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ForegroundInfo:
|
||||||
|
"""Snapshot of the foreground window/process."""
|
||||||
|
|
||||||
|
available: bool
|
||||||
|
pid: int | None = None
|
||||||
|
process_name: str | None = None
|
||||||
|
executable_path: str | None = None
|
||||||
|
window_title: str | None = None
|
||||||
|
window_handle: int | None = None
|
||||||
|
is_fullscreen: bool = False
|
||||||
|
is_minimized: bool = False
|
||||||
|
monitor_id: int | None = None
|
||||||
|
monitor_geometry: dict[str, int] | None = None
|
||||||
|
window_geometry: dict[str, int] | None = None
|
||||||
|
started_at: float | None = None
|
||||||
|
platform: str = field(default_factory=lambda: platform.system())
|
||||||
|
error: str | None = None
|
||||||
|
# Populated only when the foreground process is a recognised web
|
||||||
|
# browser. ``browser_page_title`` is derived from the window title
|
||||||
|
# (suffix stripped); ``browser_url`` requires UIA to succeed.
|
||||||
|
is_browser: bool = False
|
||||||
|
browser_url: str | None = None
|
||||||
|
browser_page_title: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
_UNAVAILABLE = ForegroundInfo(available=False)
|
||||||
|
|
||||||
|
|
||||||
|
class _Cache:
|
||||||
|
"""Single-slot TTL cache shared across callers."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._value: ForegroundInfo | None = None
|
||||||
|
self._fetched_at: float = 0.0
|
||||||
|
|
||||||
|
def get(self, ttl: float, fetch) -> ForegroundInfo:
|
||||||
|
with self._lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._value is not None and (now - self._fetched_at) < ttl:
|
||||||
|
return self._value
|
||||||
|
# Fetch outside the lock — OS calls can take tens of ms.
|
||||||
|
value = fetch()
|
||||||
|
with self._lock:
|
||||||
|
self._value = value
|
||||||
|
self._fetched_at = time.monotonic()
|
||||||
|
return value
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._value = None
|
||||||
|
self._fetched_at = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
_cache = _Cache()
|
||||||
|
|
||||||
|
# Win32 handles + signatures are declared once at module load (when running on
|
||||||
|
# Windows). The TTL cache fires this hundreds of times per minute; redoing the
|
||||||
|
# DLL load + ~10 argtype assignments per call was the largest chunk of probe
|
||||||
|
# cost. Keep these guarded behind a lazy init so non-Windows platforms don't
|
||||||
|
# pay the import.
|
||||||
|
_WIN32_INITIALIZED = False
|
||||||
|
_win32_user32 = None
|
||||||
|
_win32_kernel32 = None
|
||||||
|
_win32_psapi = None
|
||||||
|
|
||||||
|
|
||||||
|
def _init_win32_apis() -> None:
|
||||||
|
"""Declare ctypes argtypes/restype on every Win32 call we make.
|
||||||
|
|
||||||
|
CRITICAL: ctypes defaults to `c_int` (32-bit) for HANDLE/HWND/HMONITOR
|
||||||
|
which silently truncates 64-bit pointer values on x64 — that corrupts the
|
||||||
|
handle so `CloseHandle()` can either fail or close the wrong kernel
|
||||||
|
object, and pointer-equality comparisons (monitor index lookup) miss.
|
||||||
|
"""
|
||||||
|
global _WIN32_INITIALIZED, _win32_user32, _win32_kernel32, _win32_psapi
|
||||||
|
if _WIN32_INITIALIZED:
|
||||||
|
return
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import ctypes.wintypes as wt
|
||||||
|
|
||||||
|
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||||
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
psapi = ctypes.WinDLL("psapi", use_last_error=True)
|
||||||
|
|
||||||
|
user32.GetForegroundWindow.restype = wt.HWND
|
||||||
|
user32.GetWindowThreadProcessId.argtypes = [wt.HWND, ctypes.POINTER(wt.DWORD)]
|
||||||
|
user32.GetWindowThreadProcessId.restype = wt.DWORD
|
||||||
|
user32.GetWindowTextLengthW.argtypes = [wt.HWND]
|
||||||
|
user32.GetWindowTextLengthW.restype = ctypes.c_int
|
||||||
|
user32.GetWindowTextW.argtypes = [wt.HWND, wt.LPWSTR, ctypes.c_int]
|
||||||
|
user32.GetWindowTextW.restype = ctypes.c_int
|
||||||
|
user32.IsIconic.argtypes = [wt.HWND]
|
||||||
|
user32.IsIconic.restype = wt.BOOL
|
||||||
|
user32.GetWindowRect.argtypes = [wt.HWND, ctypes.POINTER(wt.RECT)]
|
||||||
|
user32.GetWindowRect.restype = wt.BOOL
|
||||||
|
user32.MonitorFromWindow.argtypes = [wt.HWND, wt.DWORD]
|
||||||
|
user32.MonitorFromWindow.restype = wt.HMONITOR
|
||||||
|
user32.GetMonitorInfoW.argtypes = [wt.HMONITOR, ctypes.c_void_p]
|
||||||
|
user32.GetMonitorInfoW.restype = wt.BOOL
|
||||||
|
|
||||||
|
kernel32.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
|
||||||
|
kernel32.OpenProcess.restype = wt.HANDLE
|
||||||
|
kernel32.CloseHandle.argtypes = [wt.HANDLE]
|
||||||
|
kernel32.CloseHandle.restype = wt.BOOL
|
||||||
|
kernel32.QueryFullProcessImageNameW.argtypes = [
|
||||||
|
wt.HANDLE, wt.DWORD, wt.LPWSTR, ctypes.POINTER(wt.DWORD)
|
||||||
|
]
|
||||||
|
kernel32.QueryFullProcessImageNameW.restype = wt.BOOL
|
||||||
|
kernel32.GetProcessTimes.argtypes = [
|
||||||
|
wt.HANDLE,
|
||||||
|
ctypes.POINTER(wt.FILETIME),
|
||||||
|
ctypes.POINTER(wt.FILETIME),
|
||||||
|
ctypes.POINTER(wt.FILETIME),
|
||||||
|
ctypes.POINTER(wt.FILETIME),
|
||||||
|
]
|
||||||
|
kernel32.GetProcessTimes.restype = wt.BOOL
|
||||||
|
|
||||||
|
psapi.GetModuleFileNameExW.argtypes = [wt.HANDLE, wt.HMODULE, wt.LPWSTR, wt.DWORD]
|
||||||
|
psapi.GetModuleFileNameExW.restype = wt.DWORD
|
||||||
|
|
||||||
|
_win32_user32, _win32_kernel32, _win32_psapi = user32, kernel32, psapi
|
||||||
|
_WIN32_INITIALIZED = True
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_windows() -> ForegroundInfo:
|
||||||
|
"""Probe foreground window state on Windows via Win32 API."""
|
||||||
|
import ctypes
|
||||||
|
import ctypes.wintypes as wt
|
||||||
|
|
||||||
|
_init_win32_apis()
|
||||||
|
user32 = _win32_user32
|
||||||
|
kernel32 = _win32_kernel32
|
||||||
|
psapi = _win32_psapi
|
||||||
|
|
||||||
|
hwnd = user32.GetForegroundWindow()
|
||||||
|
if not hwnd:
|
||||||
|
return ForegroundInfo(available=True, error="no foreground window")
|
||||||
|
|
||||||
|
# PID + window thread.
|
||||||
|
pid = wt.DWORD(0)
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||||
|
pid_val = int(pid.value) if pid.value else None
|
||||||
|
|
||||||
|
# Window title — Unicode.
|
||||||
|
length = user32.GetWindowTextLengthW(hwnd)
|
||||||
|
title_buf = ctypes.create_unicode_buffer(length + 1)
|
||||||
|
user32.GetWindowTextW(hwnd, title_buf, length + 1)
|
||||||
|
window_title = title_buf.value or None
|
||||||
|
|
||||||
|
# Minimized flag.
|
||||||
|
is_minimized = bool(user32.IsIconic(hwnd))
|
||||||
|
|
||||||
|
# Window rect (screen coords).
|
||||||
|
rect = wt.RECT()
|
||||||
|
window_geometry: dict[str, int] | None = None
|
||||||
|
if user32.GetWindowRect(hwnd, ctypes.byref(rect)):
|
||||||
|
window_geometry = {
|
||||||
|
"left": int(rect.left),
|
||||||
|
"top": int(rect.top),
|
||||||
|
"right": int(rect.right),
|
||||||
|
"bottom": int(rect.bottom),
|
||||||
|
"width": int(rect.right - rect.left),
|
||||||
|
"height": int(rect.bottom - rect.top),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Monitor under the window + its geometry.
|
||||||
|
monitor_geometry: dict[str, int] | None = None
|
||||||
|
monitor_id: int | None = None
|
||||||
|
is_fullscreen = False
|
||||||
|
try:
|
||||||
|
MONITOR_DEFAULTTONEAREST = 2
|
||||||
|
|
||||||
|
class MONITORINFO(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("cbSize", wt.DWORD),
|
||||||
|
("rcMonitor", wt.RECT),
|
||||||
|
("rcWork", wt.RECT),
|
||||||
|
("dwFlags", wt.DWORD),
|
||||||
|
]
|
||||||
|
|
||||||
|
hmon = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
|
||||||
|
if hmon:
|
||||||
|
mi = MONITORINFO()
|
||||||
|
mi.cbSize = ctypes.sizeof(mi)
|
||||||
|
if user32.GetMonitorInfoW(hmon, ctypes.byref(mi)):
|
||||||
|
monitor_geometry = {
|
||||||
|
"left": int(mi.rcMonitor.left),
|
||||||
|
"top": int(mi.rcMonitor.top),
|
||||||
|
"right": int(mi.rcMonitor.right),
|
||||||
|
"bottom": int(mi.rcMonitor.bottom),
|
||||||
|
"width": int(mi.rcMonitor.right - mi.rcMonitor.left),
|
||||||
|
"height": int(mi.rcMonitor.bottom - mi.rcMonitor.top),
|
||||||
|
}
|
||||||
|
# Fullscreen heuristic: window rect equals monitor rect AND
|
||||||
|
# not minimized. Many media players (VLC, browser fullscreen)
|
||||||
|
# set themselves to exactly the monitor bounds.
|
||||||
|
if window_geometry and not is_minimized:
|
||||||
|
is_fullscreen = (
|
||||||
|
window_geometry["left"] == monitor_geometry["left"]
|
||||||
|
and window_geometry["top"] == monitor_geometry["top"]
|
||||||
|
and window_geometry["right"] == monitor_geometry["right"]
|
||||||
|
and window_geometry["bottom"] == monitor_geometry["bottom"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve monitor index by enumerating displays in order. Coerce
|
||||||
|
# both the foreground hmon and the per-enum hmon to int so the
|
||||||
|
# equality compare uses 64-bit values consistently regardless of
|
||||||
|
# how ctypes represents the handle internally.
|
||||||
|
try:
|
||||||
|
indexed: list[int] = []
|
||||||
|
|
||||||
|
def _cb(hm, _hdc, _rect, _data):
|
||||||
|
indexed.append(int(hm) if hm else 0)
|
||||||
|
return True
|
||||||
|
|
||||||
|
MONITORENUMPROC = ctypes.WINFUNCTYPE(
|
||||||
|
ctypes.c_int,
|
||||||
|
wt.HMONITOR,
|
||||||
|
wt.HDC,
|
||||||
|
ctypes.POINTER(wt.RECT),
|
||||||
|
wt.LPARAM,
|
||||||
|
)
|
||||||
|
user32.EnumDisplayMonitors.argtypes = [
|
||||||
|
wt.HDC, ctypes.POINTER(wt.RECT), MONITORENUMPROC, wt.LPARAM
|
||||||
|
]
|
||||||
|
user32.EnumDisplayMonitors.restype = wt.BOOL
|
||||||
|
user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(_cb), 0)
|
||||||
|
target = int(hmon) if hmon else 0
|
||||||
|
if target and target in indexed:
|
||||||
|
monitor_id = indexed.index(target)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor index resolution failed: %s", e)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor info probe failed: %s", e)
|
||||||
|
|
||||||
|
# Process executable path + start time.
|
||||||
|
executable_path: str | None = None
|
||||||
|
process_name: str | None = None
|
||||||
|
started_at: float | None = None
|
||||||
|
if pid_val:
|
||||||
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||||
|
h_proc = kernel32.OpenProcess(
|
||||||
|
PROCESS_QUERY_LIMITED_INFORMATION, False, pid_val
|
||||||
|
)
|
||||||
|
if h_proc:
|
||||||
|
try:
|
||||||
|
# Image filename — full path. QueryFullProcessImageNameW works
|
||||||
|
# across 32/64-bit boundaries, unlike GetModuleFileNameExW.
|
||||||
|
buf = ctypes.create_unicode_buffer(1024)
|
||||||
|
size = wt.DWORD(len(buf))
|
||||||
|
if kernel32.QueryFullProcessImageNameW(
|
||||||
|
h_proc, 0, buf, ctypes.byref(size)
|
||||||
|
):
|
||||||
|
executable_path = buf.value or None
|
||||||
|
else:
|
||||||
|
# Fallback via psapi. Return value is the length copied
|
||||||
|
# into the buffer (0 on failure); ignoring it would leave
|
||||||
|
# `executable_path` as an empty string from the freshly
|
||||||
|
# allocated buffer instead of None.
|
||||||
|
written = psapi.GetModuleFileNameExW(h_proc, None, buf, len(buf))
|
||||||
|
if written:
|
||||||
|
executable_path = buf.value or None
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"QueryFullProcessImageNameW + psapi fallback both "
|
||||||
|
"failed for pid=%s (err=%d)",
|
||||||
|
pid_val,
|
||||||
|
ctypes.get_last_error(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if executable_path:
|
||||||
|
import os
|
||||||
|
process_name = os.path.basename(executable_path)
|
||||||
|
|
||||||
|
# Process creation time (FILETIME, 100ns ticks since 1601).
|
||||||
|
creation = wt.FILETIME()
|
||||||
|
exit_t = wt.FILETIME()
|
||||||
|
kernel_t = wt.FILETIME()
|
||||||
|
user_t = wt.FILETIME()
|
||||||
|
if kernel32.GetProcessTimes(
|
||||||
|
h_proc,
|
||||||
|
ctypes.byref(creation),
|
||||||
|
ctypes.byref(exit_t),
|
||||||
|
ctypes.byref(kernel_t),
|
||||||
|
ctypes.byref(user_t),
|
||||||
|
):
|
||||||
|
ticks = (creation.dwHighDateTime << 32) | creation.dwLowDateTime
|
||||||
|
# Convert to Unix epoch seconds (1601-01-01 → 1970-01-01).
|
||||||
|
if ticks:
|
||||||
|
started_at = (ticks - 116444736000000000) / 10_000_000
|
||||||
|
finally:
|
||||||
|
kernel32.CloseHandle(h_proc)
|
||||||
|
|
||||||
|
return ForegroundInfo(
|
||||||
|
available=True,
|
||||||
|
pid=pid_val,
|
||||||
|
process_name=process_name,
|
||||||
|
executable_path=executable_path,
|
||||||
|
window_title=window_title,
|
||||||
|
window_handle=int(hwnd) if hwnd else None,
|
||||||
|
is_fullscreen=is_fullscreen,
|
||||||
|
is_minimized=is_minimized,
|
||||||
|
monitor_id=monitor_id,
|
||||||
|
monitor_geometry=monitor_geometry,
|
||||||
|
window_geometry=window_geometry,
|
||||||
|
started_at=started_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_macos() -> ForegroundInfo:
|
||||||
|
"""Best-effort probe on macOS via AppKit (PyObjC).
|
||||||
|
|
||||||
|
Returns ``available=False`` when PyObjC is not installed — we don't take
|
||||||
|
a hard dependency on it because the typical macOS install path uses pip
|
||||||
|
+ the standalone wheel.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from AppKit import NSWorkspace # type: ignore
|
||||||
|
from Quartz import ( # type: ignore
|
||||||
|
CGWindowListCopyWindowInfo,
|
||||||
|
kCGNullWindowID,
|
||||||
|
kCGWindowListOptionOnScreenOnly,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return ForegroundInfo(available=False, error="AppKit/Quartz not available")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws = NSWorkspace.sharedWorkspace()
|
||||||
|
app = ws.frontmostApplication()
|
||||||
|
if app is None:
|
||||||
|
return ForegroundInfo(available=True, error="no frontmost app")
|
||||||
|
|
||||||
|
pid = int(app.processIdentifier())
|
||||||
|
process_name = str(app.localizedName() or "")
|
||||||
|
bundle_url = app.bundleURL()
|
||||||
|
executable_path = str(bundle_url.path()) if bundle_url else None
|
||||||
|
started_at = None
|
||||||
|
launch_date = app.launchDate()
|
||||||
|
if launch_date is not None:
|
||||||
|
started_at = float(launch_date.timeIntervalSince1970())
|
||||||
|
|
||||||
|
# Window title — frontmost on-screen window owned by this PID.
|
||||||
|
window_title: str | None = None
|
||||||
|
try:
|
||||||
|
windows = CGWindowListCopyWindowInfo(
|
||||||
|
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
|
||||||
|
)
|
||||||
|
for w in windows or []:
|
||||||
|
if int(w.get("kCGWindowOwnerPID", -1)) == pid:
|
||||||
|
name = w.get("kCGWindowName")
|
||||||
|
if name:
|
||||||
|
window_title = str(name)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("CGWindowListCopyWindowInfo failed: %s", e)
|
||||||
|
|
||||||
|
return ForegroundInfo(
|
||||||
|
available=True,
|
||||||
|
pid=pid,
|
||||||
|
process_name=process_name,
|
||||||
|
executable_path=executable_path,
|
||||||
|
window_title=window_title,
|
||||||
|
started_at=started_at,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("macOS foreground probe failed: %s", e)
|
||||||
|
return ForegroundInfo(available=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_linux() -> ForegroundInfo:
|
||||||
|
"""Best-effort probe on Linux via Xlib (X11 only).
|
||||||
|
|
||||||
|
Wayland sessions intentionally hide window/process info from unprivileged
|
||||||
|
clients, so this returns ``available=False`` on Wayland. The caller still
|
||||||
|
gets a structured response and can render "unavailable" in the UI.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
if os.environ.get("WAYLAND_DISPLAY"):
|
||||||
|
return ForegroundInfo(
|
||||||
|
available=False, error="Wayland session — foreground probe unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from Xlib import X, display # type: ignore # noqa: F401
|
||||||
|
except Exception:
|
||||||
|
return ForegroundInfo(available=False, error="python-xlib not installed")
|
||||||
|
|
||||||
|
try:
|
||||||
|
d = display.Display()
|
||||||
|
root = d.screen().root
|
||||||
|
NET_ACTIVE_WINDOW = d.intern_atom("_NET_ACTIVE_WINDOW")
|
||||||
|
NET_WM_PID = d.intern_atom("_NET_WM_PID")
|
||||||
|
NET_WM_NAME = d.intern_atom("_NET_WM_NAME")
|
||||||
|
UTF8_STRING = d.intern_atom("UTF8_STRING")
|
||||||
|
|
||||||
|
active = root.get_full_property(NET_ACTIVE_WINDOW, X.AnyPropertyType)
|
||||||
|
if not active or not active.value:
|
||||||
|
return ForegroundInfo(available=True, error="no active window")
|
||||||
|
win_id = int(active.value[0])
|
||||||
|
win = d.create_resource_object("window", win_id)
|
||||||
|
|
||||||
|
pid_prop = win.get_full_property(NET_WM_PID, X.AnyPropertyType)
|
||||||
|
pid_val = int(pid_prop.value[0]) if pid_prop and pid_prop.value else None
|
||||||
|
|
||||||
|
name_prop = win.get_full_property(NET_WM_NAME, UTF8_STRING)
|
||||||
|
window_title = (
|
||||||
|
name_prop.value.decode("utf-8", "replace") if name_prop and name_prop.value else None
|
||||||
|
)
|
||||||
|
|
||||||
|
process_name: str | None = None
|
||||||
|
executable_path: str | None = None
|
||||||
|
started_at: float | None = None
|
||||||
|
if pid_val:
|
||||||
|
try:
|
||||||
|
exe = os.readlink(f"/proc/{pid_val}/exe")
|
||||||
|
executable_path = exe
|
||||||
|
process_name = os.path.basename(exe)
|
||||||
|
except OSError as e:
|
||||||
|
logger.debug("readlink /proc/%d/exe failed: %s", pid_val, e)
|
||||||
|
try:
|
||||||
|
started_at = os.stat(f"/proc/{pid_val}").st_ctime
|
||||||
|
except OSError as e:
|
||||||
|
logger.debug("stat /proc/%d failed: %s", pid_val, e)
|
||||||
|
|
||||||
|
return ForegroundInfo(
|
||||||
|
available=True,
|
||||||
|
pid=pid_val,
|
||||||
|
process_name=process_name,
|
||||||
|
executable_path=executable_path,
|
||||||
|
window_title=window_title,
|
||||||
|
window_handle=win_id,
|
||||||
|
started_at=started_at,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Linux foreground probe failed: %s", e)
|
||||||
|
return ForegroundInfo(available=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_browser(info: ForegroundInfo) -> ForegroundInfo:
|
||||||
|
"""If ``info`` describes a focused browser, attach URL + page title.
|
||||||
|
|
||||||
|
The UIA lookup is wrapped in its own try/except so a failure here can't
|
||||||
|
take down the rest of the foreground probe.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from . import browser_url_service as bus
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("browser_url_service unavailable: %s", e)
|
||||||
|
return info
|
||||||
|
|
||||||
|
if not info.available or not bus.is_browser_process(info.process_name):
|
||||||
|
return info
|
||||||
|
|
||||||
|
try:
|
||||||
|
page = bus.get_browser_page(
|
||||||
|
hwnd=info.window_handle,
|
||||||
|
process_name=info.process_name,
|
||||||
|
window_title=info.window_title,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Browser URL enrichment failed: %s", e)
|
||||||
|
return info
|
||||||
|
|
||||||
|
# ``dataclasses.replace`` keeps the frozen-dataclass contract.
|
||||||
|
from dataclasses import replace
|
||||||
|
return replace(
|
||||||
|
info,
|
||||||
|
is_browser=True,
|
||||||
|
browser_url=page.url,
|
||||||
|
browser_page_title=page.page_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe() -> ForegroundInfo:
|
||||||
|
system = platform.system()
|
||||||
|
try:
|
||||||
|
if system == "Windows":
|
||||||
|
info = _probe_windows()
|
||||||
|
elif system == "Darwin":
|
||||||
|
info = _probe_macos()
|
||||||
|
elif system == "Linux":
|
||||||
|
info = _probe_linux()
|
||||||
|
else:
|
||||||
|
return ForegroundInfo(
|
||||||
|
available=False, error=f"unsupported platform: {system}"
|
||||||
|
)
|
||||||
|
return _enrich_browser(info)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Foreground probe crashed: %s", e)
|
||||||
|
return ForegroundInfo(available=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def get_foreground_info(force_refresh: bool = False) -> ForegroundInfo:
|
||||||
|
"""Return the current foreground window/process snapshot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force_refresh: bypass the short TTL cache. WebSocket broadcast loop
|
||||||
|
should leave this False; the REST endpoint accepts ?refresh=1
|
||||||
|
for callers that want a fresh probe.
|
||||||
|
"""
|
||||||
|
if force_refresh:
|
||||||
|
_cache.invalidate()
|
||||||
|
return _cache.get(_CACHE_TTL, _probe)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_cache() -> None:
|
||||||
|
"""Reset the cache. Useful in tests."""
|
||||||
|
_cache.invalidate()
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -15,6 +16,11 @@ _DEFAULT_BASE_URL = "https://git.dolgolyov-family.by"
|
|||||||
_DEFAULT_OWNER = "alexei.dolgolyov"
|
_DEFAULT_OWNER = "alexei.dolgolyov"
|
||||||
_DEFAULT_REPO = "media-player-server"
|
_DEFAULT_REPO = "media-player-server"
|
||||||
|
|
||||||
|
# Restrictive tag whitelist — prevents a hostile Gitea response (or MITM) from
|
||||||
|
# injecting `..`, slashes, or URL-altering characters into the release URL we
|
||||||
|
# broadcast to clients. SemVer + pre-release suffix only.
|
||||||
|
_TAG_RE = re.compile(r"^v?\d+\.\d+\.\d+(?:[\w.\-+]{0,32})?$")
|
||||||
|
|
||||||
|
|
||||||
class GiteaReleaseProvider(ReleaseProvider):
|
class GiteaReleaseProvider(ReleaseProvider):
|
||||||
"""Fetches the latest release from a Gitea repository."""
|
"""Fetches the latest release from a Gitea repository."""
|
||||||
@@ -53,6 +59,9 @@ class GiteaReleaseProvider(ReleaseProvider):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
tag = release.get("tag_name", "")
|
tag = release.get("tag_name", "")
|
||||||
|
if not isinstance(tag, str) or not _TAG_RE.match(tag):
|
||||||
|
logger.warning("Rejecting malformed release tag from upstream: %r", tag)
|
||||||
|
continue
|
||||||
version = tag.lstrip("v")
|
version = tag.lstrip("v")
|
||||||
if not version:
|
if not version:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -264,8 +264,12 @@ class MacOSMediaController(MediaController):
|
|||||||
|
|
||||||
async def set_volume(self, volume: int) -> bool:
|
async def set_volume(self, volume: int) -> bool:
|
||||||
"""Set system volume."""
|
"""Set system volume."""
|
||||||
result = self._run_osascript(f"set volume output volume {volume}")
|
# osascript returns empty string on success and None on failure (the
|
||||||
return result is not None or True # osascript returns empty on success
|
# _run_osascript helper catches subprocess errors). The previous
|
||||||
|
# `result is not None or True` always returned True regardless of
|
||||||
|
# outcome — surface real failures so the route can return 503.
|
||||||
|
result = self._run_osascript(f"set volume output volume {int(volume)}")
|
||||||
|
return result is not None
|
||||||
|
|
||||||
async def toggle_mute(self) -> bool:
|
async def toggle_mute(self) -> bool:
|
||||||
"""Toggle mute state."""
|
"""Toggle mute state."""
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""In-process token-bucket rate limiter.
|
||||||
|
|
||||||
|
Light enough for a single-process app: one dict keyed by ``(bucket, peer)``
|
||||||
|
guarded by a thread lock. No extra dependency, no Redis. Good enough for
|
||||||
|
defeating credential-stuffing and runaway clients on a LAN; not a substitute
|
||||||
|
for an upstream WAF in a public deployment.
|
||||||
|
|
||||||
|
Buckets:
|
||||||
|
auth — failed-auth attempts, 5/min/peer (used in auth middleware)
|
||||||
|
execute — script + callback execute calls, 10/min/peer (LAN-friendly)
|
||||||
|
default — generic POST/DELETE writes, 60/min/peer
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BucketConfig:
|
||||||
|
capacity: float # max tokens (= burst size)
|
||||||
|
refill_per_sec: float # tokens added per second
|
||||||
|
|
||||||
|
|
||||||
|
# Defaults — tuned for "trusted LAN" use; operator can override via Settings.
|
||||||
|
BUCKETS: dict[str, BucketConfig] = {
|
||||||
|
"auth": BucketConfig(capacity=5, refill_per_sec=5 / 60), # 5/min
|
||||||
|
"execute": BucketConfig(capacity=10, refill_per_sec=10 / 60), # 10/min
|
||||||
|
"default": BucketConfig(capacity=60, refill_per_sec=60 / 60), # 60/min
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_state: dict[tuple[str, str], tuple[float, float]] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_LAST_CLEANUP = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _evict_stale_locked(now: float) -> None:
|
||||||
|
"""Drop entries whose buckets are full (= idle for capacity / refill seconds)."""
|
||||||
|
global _LAST_CLEANUP
|
||||||
|
if now - _LAST_CLEANUP < 60:
|
||||||
|
return
|
||||||
|
_LAST_CLEANUP = now
|
||||||
|
stale = []
|
||||||
|
for key, (tokens, last) in _state.items():
|
||||||
|
bucket = BUCKETS.get(key[0])
|
||||||
|
if bucket is None:
|
||||||
|
continue
|
||||||
|
if tokens >= bucket.capacity and (now - last) > 3600:
|
||||||
|
stale.append(key)
|
||||||
|
for key in stale:
|
||||||
|
_state.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def check(bucket: str, peer: str) -> tuple[bool, Optional[float]]:
|
||||||
|
"""Try to consume one token from ``(bucket, peer)``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(allowed, retry_after_seconds). When allowed=True retry_after is None.
|
||||||
|
When allowed=False, retry_after is the seconds to wait for one more token.
|
||||||
|
"""
|
||||||
|
cfg = BUCKETS.get(bucket) or BUCKETS["default"]
|
||||||
|
now = time.monotonic()
|
||||||
|
with _lock:
|
||||||
|
_evict_stale_locked(now)
|
||||||
|
tokens, last = _state.get((bucket, peer), (cfg.capacity, now))
|
||||||
|
elapsed = max(0.0, now - last)
|
||||||
|
tokens = min(cfg.capacity, tokens + elapsed * cfg.refill_per_sec)
|
||||||
|
if tokens >= 1:
|
||||||
|
tokens -= 1
|
||||||
|
_state[(bucket, peer)] = (tokens, now)
|
||||||
|
return True, None
|
||||||
|
deficit = 1 - tokens
|
||||||
|
retry = deficit / cfg.refill_per_sec if cfg.refill_per_sec > 0 else 60
|
||||||
|
_state[(bucket, peer)] = (tokens, now)
|
||||||
|
return False, retry
|
||||||
|
|
||||||
|
|
||||||
|
def get_peer(request) -> str:
|
||||||
|
"""Best-effort peer identifier from a Starlette request.
|
||||||
|
|
||||||
|
Honors X-Forwarded-For (only when settings.proxy_headers is True, which is
|
||||||
|
already enforced by uvicorn's middleware) so a reverse-proxied install
|
||||||
|
still rate-limits per real client.
|
||||||
|
"""
|
||||||
|
client = getattr(request, "client", None)
|
||||||
|
if client and client.host:
|
||||||
|
return client.host
|
||||||
|
return "unknown"
|
||||||
@@ -26,12 +26,23 @@ class ThumbnailService:
|
|||||||
def get_cache_dir() -> Path:
|
def get_cache_dir() -> Path:
|
||||||
"""Get the thumbnail cache directory path.
|
"""Get the thumbnail cache directory path.
|
||||||
|
|
||||||
Returns:
|
Returns user-writable platform cache dir so installs under
|
||||||
Path to the cache directory (project-local).
|
``%PROGRAMFILES%`` / ``/opt`` work without elevated permissions.
|
||||||
|
Mirrors the platform branching of ``config.get_config_dir``.
|
||||||
"""
|
"""
|
||||||
# Store cache in project directory: media-server/.cache/thumbnails/
|
import os
|
||||||
project_root = Path(__file__).parent.parent.parent
|
|
||||||
cache_dir = project_root / ".cache" / "thumbnails"
|
if os.name == "nt":
|
||||||
|
# %LOCALAPPDATA% so the cache survives roaming-profile sync.
|
||||||
|
base = Path(os.environ.get("LOCALAPPDATA")
|
||||||
|
or os.environ.get("APPDATA")
|
||||||
|
or Path.home() / "AppData" / "Local")
|
||||||
|
cache_dir = base / "media-server" / "cache" / "thumbnails"
|
||||||
|
else:
|
||||||
|
# XDG_CACHE_HOME convention; falls back to ~/.cache.
|
||||||
|
xdg = os.environ.get("XDG_CACHE_HOME")
|
||||||
|
base = Path(xdg) if xdg else Path.home() / ".cache"
|
||||||
|
cache_dir = base / "media-server" / "thumbnails"
|
||||||
|
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return cache_dir
|
return cache_dir
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class ConnectionManager:
|
|||||||
self._active_connections: set[WebSocket] = set()
|
self._active_connections: set[WebSocket] = set()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._last_status: dict[str, Any] | None = None
|
self._last_status: dict[str, Any] | None = None
|
||||||
|
self._last_foreground: dict[str, Any] | None = None
|
||||||
|
self._foreground_poll_interval: float = 1.0
|
||||||
|
self._last_foreground_poll: float = 0.0
|
||||||
self._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
|
self._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
|
||||||
self._broadcast_task: asyncio.Task | None = None
|
self._broadcast_task: asyncio.Task | None = None
|
||||||
self._poll_interval: float = 0.5 # Internal poll interval for change detection
|
self._poll_interval: float = 0.5 # Internal poll interval for change detection
|
||||||
@@ -30,8 +33,14 @@ class ConnectionManager:
|
|||||||
self._audio_task: asyncio.Task | None = None
|
self._audio_task: asyncio.Task | None = None
|
||||||
self._audio_analyzer = None
|
self._audio_analyzer = None
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket) -> None:
|
async def connect(self, websocket: WebSocket, already_accepted: bool = False) -> None:
|
||||||
"""Accept a new WebSocket connection."""
|
"""Accept a new WebSocket connection.
|
||||||
|
|
||||||
|
``already_accepted=True`` is for callers that needed to call
|
||||||
|
``websocket.accept(subprotocol=...)`` themselves (token-via-subprotocol
|
||||||
|
auth path).
|
||||||
|
"""
|
||||||
|
if not already_accepted:
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._active_connections.add(websocket)
|
self._active_connections.add(websocket)
|
||||||
@@ -54,6 +63,18 @@ class ConnectionManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Failed to send initial status: %s", e)
|
logger.debug("Failed to send initial status: %s", e)
|
||||||
|
|
||||||
|
# Push a fresh foreground snapshot on connect so the UI can render
|
||||||
|
# the tile immediately instead of waiting for the next change.
|
||||||
|
try:
|
||||||
|
from .foreground_service import get_foreground_info
|
||||||
|
|
||||||
|
fg = await asyncio.to_thread(get_foreground_info)
|
||||||
|
fg_dict = fg.to_dict()
|
||||||
|
self._last_foreground = fg_dict
|
||||||
|
await websocket.send_json({"type": "foreground", "data": fg_dict})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Failed to send initial foreground snapshot: %s", e)
|
||||||
|
|
||||||
async def disconnect(self, websocket: WebSocket) -> None:
|
async def disconnect(self, websocket: WebSocket) -> None:
|
||||||
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
|
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
|
||||||
should_stop = False
|
should_stop = False
|
||||||
@@ -115,6 +136,35 @@ class ConnectionManager:
|
|||||||
await self.broadcast(message)
|
await self.broadcast(message)
|
||||||
logger.info("Broadcast sent: links_changed")
|
logger.info("Broadcast sent: links_changed")
|
||||||
|
|
||||||
|
def foreground_changed(
|
||||||
|
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
"""Detect a meaningful change in the foreground process snapshot.
|
||||||
|
|
||||||
|
The probe also returns ``window_geometry`` which jitters on every
|
||||||
|
pixel of cursor drag — comparing the whole dict would flood clients.
|
||||||
|
We only diff the fields a user (or HA automation) would actually act
|
||||||
|
on. ``window_geometry``/``monitor_geometry``/``started_at`` are still
|
||||||
|
delivered in the payload, but they don't drive broadcast cadence.
|
||||||
|
"""
|
||||||
|
if old is None:
|
||||||
|
return True
|
||||||
|
diff_fields = (
|
||||||
|
"pid",
|
||||||
|
"process_name",
|
||||||
|
"executable_path",
|
||||||
|
"window_title",
|
||||||
|
"is_fullscreen",
|
||||||
|
"is_minimized",
|
||||||
|
"monitor_id",
|
||||||
|
"available",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
for f in diff_fields:
|
||||||
|
if old.get(f) != new.get(f):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
async def subscribe_visualizer(self, websocket: WebSocket) -> None:
|
async def subscribe_visualizer(self, websocket: WebSocket) -> None:
|
||||||
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
|
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
|
||||||
should_start = False
|
should_start = False
|
||||||
@@ -314,6 +364,10 @@ class ConnectionManager:
|
|||||||
get_status_func: Callable[[], Coroutine[Any, Any, Any]],
|
get_status_func: Callable[[], Coroutine[Any, Any, Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Background loop that polls for status changes and broadcasts."""
|
"""Background loop that polls for status changes and broadcasts."""
|
||||||
|
# Foreground tracker is imported lazily so unit tests of the WS
|
||||||
|
# manager don't drag in platform-specific probe code.
|
||||||
|
from .foreground_service import get_foreground_info
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
# Only poll if we have connected clients
|
# Only poll if we have connected clients
|
||||||
@@ -340,6 +394,28 @@ class ConnectionManager:
|
|||||||
# Update cached status even without broadcast
|
# Update cached status even without broadcast
|
||||||
self._last_status = status_dict
|
self._last_status = status_dict
|
||||||
|
|
||||||
|
# Foreground process — poll at a coarser interval than media
|
||||||
|
# status. Broadcasts only fire on a real change, so a quiet
|
||||||
|
# desktop costs nothing.
|
||||||
|
now = time.time()
|
||||||
|
if (
|
||||||
|
now - self._last_foreground_poll
|
||||||
|
) >= self._foreground_poll_interval:
|
||||||
|
self._last_foreground_poll = now
|
||||||
|
try:
|
||||||
|
fg = await asyncio.to_thread(get_foreground_info)
|
||||||
|
fg_dict = fg.to_dict()
|
||||||
|
if self.foreground_changed(self._last_foreground, fg_dict):
|
||||||
|
self._last_foreground = fg_dict
|
||||||
|
await self.broadcast(
|
||||||
|
{"type": "foreground_update", "data": fg_dict}
|
||||||
|
)
|
||||||
|
logger.debug("Broadcast sent: foreground change")
|
||||||
|
else:
|
||||||
|
self._last_foreground = fg_dict
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Foreground poll failed: %s", e)
|
||||||
|
|
||||||
await asyncio.sleep(self._poll_interval)
|
await asyncio.sleep(self._poll_interval)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
@@ -31,8 +31,15 @@ def _thread_loop() -> asyncio.AbstractEventLoop:
|
|||||||
_thread_local.loop = loop
|
_thread_local.loop = loop
|
||||||
return loop
|
return loop
|
||||||
|
|
||||||
# Global storage for current album art (as bytes)
|
# Global storage for current album art (as bytes). Guarded by _art_lock so the
|
||||||
|
# WinRT polling thread and the FastAPI handler thread don't race on swap.
|
||||||
_current_album_art_bytes: bytes | None = None
|
_current_album_art_bytes: bytes | None = None
|
||||||
|
_art_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Identity of the track whose art is currently in _current_album_art_bytes.
|
||||||
|
# Used to gate the expensive WinRT thumbnail.open_read_async() so the bytes
|
||||||
|
# aren't re-decoded on every 500ms status poll.
|
||||||
|
_current_album_art_key: tuple | None = None
|
||||||
|
|
||||||
# Lock protecting _position_cache and _track_skip_pending from concurrent access
|
# Lock protecting _position_cache and _track_skip_pending from concurrent access
|
||||||
_position_lock = threading.Lock()
|
_position_lock = threading.Lock()
|
||||||
@@ -56,7 +63,8 @@ _track_skip_pending = {
|
|||||||
|
|
||||||
|
|
||||||
def get_current_album_art() -> bytes | None:
|
def get_current_album_art() -> bytes | None:
|
||||||
"""Get the current album art bytes."""
|
"""Get the current album art bytes (thread-safe snapshot)."""
|
||||||
|
with _art_lock:
|
||||||
return _current_album_art_bytes
|
return _current_album_art_bytes
|
||||||
|
|
||||||
# Windows-specific imports
|
# Windows-specific imports
|
||||||
@@ -379,8 +387,20 @@ def _sync_get_media_status() -> dict[str, Any]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Timeline parse error: {e}")
|
logger.debug(f"Timeline parse error: {e}")
|
||||||
|
|
||||||
# Try to get album art (requires media_props)
|
# Try to get album art (requires media_props). Gated by track key so
|
||||||
|
# the WinRT IPC + bytes copy only runs when the track actually
|
||||||
|
# changes; otherwise we just preserve the existing cached bytes.
|
||||||
if media_props:
|
if media_props:
|
||||||
|
track_key = (
|
||||||
|
getattr(media_props, "title", "") or "",
|
||||||
|
getattr(media_props, "artist", "") or "",
|
||||||
|
getattr(media_props, "album_title", "") or "",
|
||||||
|
)
|
||||||
|
global _current_album_art_bytes, _current_album_art_key
|
||||||
|
if track_key == _current_album_art_key and _current_album_art_bytes:
|
||||||
|
# Same track — reuse cached art bytes without touching WinRT.
|
||||||
|
result["album_art_url"] = "/api/media/artwork"
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
thumbnail = media_props.thumbnail
|
thumbnail = media_props.thumbnail
|
||||||
if thumbnail:
|
if thumbnail:
|
||||||
@@ -396,9 +416,17 @@ def _sync_get_media_status() -> dict[str, Any]:
|
|||||||
reader.close()
|
reader.close()
|
||||||
stream.close()
|
stream.close()
|
||||||
|
|
||||||
global _current_album_art_bytes
|
with _art_lock:
|
||||||
_current_album_art_bytes = bytes(buffer)
|
_current_album_art_bytes = bytes(buffer)
|
||||||
|
_current_album_art_key = track_key
|
||||||
result["album_art_url"] = "/api/media/artwork"
|
result["album_art_url"] = "/api/media/artwork"
|
||||||
|
else:
|
||||||
|
# No thumbnail on this track — drop stale bytes so
|
||||||
|
# the ETag flips and clients don't keep showing the
|
||||||
|
# previous album's cover.
|
||||||
|
with _art_lock:
|
||||||
|
_current_album_art_bytes = None
|
||||||
|
_current_album_art_key = track_key
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to get album art: {e}")
|
logger.debug(f"Failed to get album art: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -9321,3 +9321,321 @@ body.is-fullscreen-player .now-playing .vu-meter {
|
|||||||
body.is-fullscreen-player .fs-bloom #fs-bloom-art { animation: none !important; }
|
body.is-fullscreen-player .fs-bloom #fs-bloom-art { animation: none !important; }
|
||||||
:root[data-theme="light"] body.is-fullscreen-player .fs-bloom { opacity: 0.22; }
|
:root[data-theme="light"] body.is-fullscreen-player .fs-bloom { opacity: 0.22; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
FOREGROUND container — editorial process plate
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
.foreground-container {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-stage {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match the inter-section gap used between .settings-section blocks
|
||||||
|
in the Settings tab — keeps cadence consistent across tabs. */
|
||||||
|
.display-container > * + * {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-card {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
padding: clamp(24px, 3vw, 40px) clamp(24px, 3vw, 40px) 28px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-top: 2px solid var(--copper);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 0% 0%, rgba(var(--copper-rgb), 0.05), transparent 60%),
|
||||||
|
var(--bg-paper);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--bg-paper),
|
||||||
|
0 28px 60px -28px rgba(0, 0, 0, 0.45),
|
||||||
|
0 8px 20px -10px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.foreground-card[data-fullscreen="1"] {
|
||||||
|
border-top-color: var(--copper-hi);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--bg-paper),
|
||||||
|
0 28px 60px -28px rgba(0, 0, 0, 0.55),
|
||||||
|
0 0 0 1px rgba(var(--copper-rgb), 0.18),
|
||||||
|
0 0 60px -12px var(--copper-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-card .fg-kicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.32em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--copper);
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-kicker::before,
|
||||||
|
.foreground-card .fg-kicker::after {
|
||||||
|
content: "";
|
||||||
|
height: 1px;
|
||||||
|
background: var(--copper);
|
||||||
|
opacity: 0.6;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-kicker::after { flex: 1 0 auto; }
|
||||||
|
|
||||||
|
.foreground-card .fg-process {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: clamp(34px, 4.4vw, 56px);
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variation-settings: 'opsz' 144;
|
||||||
|
color: var(--ink);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
transition: color 180ms var(--ease, ease);
|
||||||
|
}
|
||||||
|
.foreground-card .fg-process:hover {
|
||||||
|
color: var(--copper-hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-card .fg-window-title {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-variation-settings: 'opsz' 60;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-window-title:empty { display: none; }
|
||||||
|
|
||||||
|
.foreground-card .fg-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-chips:empty { display: none; }
|
||||||
|
|
||||||
|
.fg-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 11px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fg-chip.fg-chip-accent {
|
||||||
|
color: var(--copper);
|
||||||
|
border-color: var(--copper);
|
||||||
|
background: rgba(var(--copper-rgb), 0.07);
|
||||||
|
}
|
||||||
|
.fg-chip.fg-chip-mute {
|
||||||
|
color: var(--ink-mute);
|
||||||
|
border-color: var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-card .fg-details {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.foreground-card .fg-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 220px) 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
align-items: baseline;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-row dt {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--copper);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-row dd {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-variation-settings: 'opsz' 30;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-mono {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-empty {
|
||||||
|
padding: 60px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
}
|
||||||
|
.foreground-empty svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
opacity: 0.55;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.foreground-empty p {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.foreground-empty .foreground-empty-error {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Header status badge ──────────────────────────────────── */
|
||||||
|
.foreground-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px 0 10px;
|
||||||
|
margin-right: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 240px;
|
||||||
|
transition: color 180ms ease, border-color 180ms ease, background 180ms ease;
|
||||||
|
}
|
||||||
|
.foreground-status-badge:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--copper);
|
||||||
|
background: rgba(var(--copper-rgb), 0.06);
|
||||||
|
}
|
||||||
|
.foreground-status-badge.hidden { display: none !important; }
|
||||||
|
|
||||||
|
.foreground-status-badge .fg-badge-mark {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink-mute);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.foreground-status-badge.is-media .fg-badge-mark,
|
||||||
|
.foreground-status-badge.is-fullscreen .fg-badge-mark {
|
||||||
|
background: var(--copper);
|
||||||
|
box-shadow: 0 0 8px var(--copper-glow);
|
||||||
|
}
|
||||||
|
.foreground-status-badge.is-fullscreen {
|
||||||
|
border-color: var(--copper);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-status-badge .fg-badge-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
.foreground-status-badge .fg-badge-tag {
|
||||||
|
color: var(--copper);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.foreground-status-badge .fg-badge-tag.hidden { display: none; }
|
||||||
|
|
||||||
|
/* ─── Light theme overrides ──────────────────────────────── */
|
||||||
|
:root[data-theme="light"] .foreground-card {
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 0% 0%, rgba(var(--copper-rgb), 0.05), transparent 60%),
|
||||||
|
var(--bg-paper);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--bg-paper),
|
||||||
|
0 22px 50px -24px rgba(26, 23, 21, 0.20),
|
||||||
|
0 6px 16px -8px rgba(26, 23, 21, 0.12);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .foreground-card[data-fullscreen="1"] {
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--bg-paper),
|
||||||
|
0 22px 50px -24px rgba(26, 23, 21, 0.28),
|
||||||
|
0 0 0 1px rgba(var(--copper-rgb), 0.20),
|
||||||
|
0 0 50px -12px var(--copper-glow);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .foreground-status-badge {
|
||||||
|
border-color: rgba(26, 23, 21, 0.18);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .foreground-status-badge:hover {
|
||||||
|
background: rgba(var(--copper-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Mobile breakpoint ──────────────────────────────────── */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.foreground-card {
|
||||||
|
padding: 22px 18px 20px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-process {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-window-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-row dd {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.foreground-status-badge {
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
.foreground-status-badge .fg-badge-name {
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
.foreground-status-badge .fg-badge-tag {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,16 +26,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mini-controls">
|
<div class="mini-controls">
|
||||||
<button class="mini-control-btn mini-nav-btn" data-onclick="previousTrack()" data-i18n-title="player.previous" title="Previous">
|
<button class="mini-control-btn mini-nav-btn" data-onclick="previousTrack()" data-i18n-title="player.previous" data-i18n-aria-label="player.previous" title="Previous" aria-label="Previous">
|
||||||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="mini-control-btn" data-onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause">
|
<button class="mini-control-btn" data-onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause" aria-label="Play/Pause">
|
||||||
<svg viewBox="0 0 24 24" id="mini-play-pause-icon">
|
<svg viewBox="0 0 24 24" id="mini-play-pause-icon" aria-hidden="true" focusable="false">
|
||||||
<path d="M8 5v14l11-7z"/>
|
<path d="M8 5v14l11-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="mini-control-btn mini-nav-btn" data-onclick="nextTrack()" data-i18n-title="player.next" title="Next">
|
<button class="mini-control-btn mini-nav-btn" data-onclick="nextTrack()" data-i18n-title="player.next" data-i18n-aria-label="player.next" title="Next" aria-label="Next">
|
||||||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mini-progress-container">
|
<div class="mini-progress-container">
|
||||||
@@ -48,8 +48,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mini-volume-container">
|
<div class="mini-volume-container">
|
||||||
<button class="mini-control-btn" data-onclick="toggleMute()" id="mini-btn-mute" title="Mute">
|
<button class="mini-control-btn" data-onclick="toggleMute()" id="mini-btn-mute" title="Mute" aria-label="Mute" aria-pressed="false">
|
||||||
<svg viewBox="0 0 24 24" id="mini-mute-icon">
|
<svg viewBox="0 0 24 24" id="mini-mute-icon" aria-hidden="true" focusable="false">
|
||||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-toolbar">
|
<div class="header-toolbar">
|
||||||
<div id="headerLinks" class="header-links"></div>
|
<div id="headerLinks" class="header-links"></div>
|
||||||
<a class="header-btn" href="/docs" target="_blank" title="API Documentation" aria-label="API Documentation">
|
<a class="header-btn" href="/docs" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" title="API Documentation" aria-label="API Documentation">
|
||||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
|
||||||
</a>
|
</a>
|
||||||
<button class="header-btn" data-onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
|
<button class="header-btn" data-onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
|
||||||
@@ -535,7 +535,7 @@
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Display Control Section -->
|
<!-- Display Control Section (monitors first, foreground overview below) -->
|
||||||
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
|
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
|
||||||
<div class="display-monitors" id="displayMonitors">
|
<div class="display-monitors" id="displayMonitors">
|
||||||
<div class="empty-state-illustration">
|
<div class="empty-state-illustration">
|
||||||
@@ -543,6 +543,12 @@
|
|||||||
<p data-i18n="display.loading">Loading monitors...</p>
|
<p data-i18n="display.loading">Loading monitors...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="foreground-stage" id="foregroundStage">
|
||||||
|
<div class="empty-state-illustration">
|
||||||
|
<svg viewBox="0 0 64 64"><rect x="6" y="12" width="52" height="36" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="20" x2="58" y2="20" stroke="currentColor" stroke-width="2"/><circle cx="11" cy="16" r="1.4" fill="currentColor"/><circle cx="15" cy="16" r="1.4" fill="currentColor"/><circle cx="19" cy="16" r="1.4" fill="currentColor"/></svg>
|
||||||
|
<p data-i18n="foreground.loading">Waiting for foreground signal…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ import {
|
|||||||
toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors,
|
toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors,
|
||||||
} from './background.js';
|
} from './background.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
updateForegroundUI, loadForegroundProcess,
|
||||||
|
} from './foreground.js';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Register late-bound callbacks for core's updateAllText()
|
// Register late-bound callbacks for core's updateAllText()
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -136,6 +140,8 @@ Object.assign(window, {
|
|||||||
onAudioDeviceChanged,
|
onAudioDeviceChanged,
|
||||||
// About
|
// About
|
||||||
showAboutDialog, closeAboutDialog,
|
showAboutDialog, closeAboutDialog,
|
||||||
|
// Foreground
|
||||||
|
loadForegroundProcess,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Foreground: Currently-focused desktop process card (rendered at
|
||||||
|
// the top of the Display tab)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { t } from './core.js';
|
||||||
|
|
||||||
|
let latestForeground = null;
|
||||||
|
let agoTickTimer = null;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s === null || s === undefined) return '';
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAgo(epoch) {
|
||||||
|
if (!epoch) return '';
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const diff = Math.max(0, now - epoch);
|
||||||
|
if (diff < 60) {
|
||||||
|
return t('foreground.ago.seconds', { n: Math.floor(diff) });
|
||||||
|
}
|
||||||
|
if (diff < 3600) {
|
||||||
|
return t('foreground.ago.minutes', { n: Math.floor(diff / 60) });
|
||||||
|
}
|
||||||
|
if (diff < 86400) {
|
||||||
|
const h = Math.floor(diff / 3600);
|
||||||
|
const m = Math.floor((diff % 3600) / 60);
|
||||||
|
return t('foreground.ago.hours', { n: h, m: m });
|
||||||
|
}
|
||||||
|
return t('foreground.ago.days', { n: Math.floor(diff / 86400) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGeometry(g) {
|
||||||
|
if (!g) return '—';
|
||||||
|
const w = g.width ?? (g.right - g.left);
|
||||||
|
const h = g.height ?? (g.bottom - g.top);
|
||||||
|
return `${w}×${h} @ (${g.left}, ${g.top})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncatePath(p, max = 64) {
|
||||||
|
if (!p) return '';
|
||||||
|
if (p.length <= max) return p;
|
||||||
|
// Keep the tail (filename) visible — that's the part the user cares about.
|
||||||
|
return '…' + p.slice(-(max - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmpty(message, errorMsg) {
|
||||||
|
const stage = document.getElementById('foregroundStage');
|
||||||
|
if (!stage) return;
|
||||||
|
stage.innerHTML = `
|
||||||
|
<div class="empty-state-illustration foreground-empty">
|
||||||
|
<svg viewBox="0 0 64 64"><rect x="6" y="12" width="52" height="36" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="20" x2="58" y2="20" stroke="currentColor" stroke-width="2"/><circle cx="11" cy="16" r="1.4" fill="currentColor"/><circle cx="15" cy="16" r="1.4" fill="currentColor"/><circle cx="19" cy="16" r="1.4" fill="currentColor"/></svg>
|
||||||
|
<p>${escapeHtml(message)}</p>
|
||||||
|
${errorMsg ? `<p class="foreground-empty-error">${escapeHtml(errorMsg)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTile(data) {
|
||||||
|
const stage = document.getElementById('foregroundStage');
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
const procName = data.process_name || '—';
|
||||||
|
const winTitle = data.window_title || '';
|
||||||
|
const execPath = data.executable_path || '';
|
||||||
|
const pid = data.pid ?? '—';
|
||||||
|
const startedEpoch = data.started_at;
|
||||||
|
const startedAgo = startedEpoch ? formatAgo(startedEpoch) : '—';
|
||||||
|
const startedAbs = startedEpoch
|
||||||
|
? new Date(startedEpoch * 1000).toLocaleString()
|
||||||
|
: '';
|
||||||
|
const geom = formatGeometry(data.window_geometry);
|
||||||
|
const platform = data.platform || '—';
|
||||||
|
const monitorId = data.monitor_id;
|
||||||
|
|
||||||
|
// Chips: only render ones that apply
|
||||||
|
const chips = [];
|
||||||
|
if (data.is_fullscreen) {
|
||||||
|
chips.push(`<span class="fg-chip fg-chip-accent">${escapeHtml(t('foreground.fullscreen'))}</span>`);
|
||||||
|
} else if (!data.is_minimized) {
|
||||||
|
chips.push(`<span class="fg-chip">${escapeHtml(t('foreground.windowed'))}</span>`);
|
||||||
|
}
|
||||||
|
if (data.is_minimized) {
|
||||||
|
chips.push(`<span class="fg-chip fg-chip-mute">${escapeHtml(t('foreground.minimized'))}</span>`);
|
||||||
|
}
|
||||||
|
if (monitorId !== null && monitorId !== undefined) {
|
||||||
|
chips.push(`<span class="fg-chip">${escapeHtml(t('foreground.monitor', { n: monitorId + 1 }))}</span>`);
|
||||||
|
}
|
||||||
|
if (data.is_browser) {
|
||||||
|
chips.push(`<span class="fg-chip fg-chip-mute">${escapeHtml(t('foreground.browser'))}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional browser-only detail rows (page title + URL when available)
|
||||||
|
const browserRows = [];
|
||||||
|
if (data.is_browser) {
|
||||||
|
if (data.browser_page_title) {
|
||||||
|
browserRows.push(`
|
||||||
|
<div class="fg-row">
|
||||||
|
<dt>${escapeHtml(t('foreground.page_title'))}</dt>
|
||||||
|
<dd title="${escapeHtml(data.browser_page_title)}">${escapeHtml(data.browser_page_title)}</dd>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
if (data.browser_url) {
|
||||||
|
browserRows.push(`
|
||||||
|
<div class="fg-row">
|
||||||
|
<dt>${escapeHtml(t('foreground.url'))}</dt>
|
||||||
|
<dd title="${escapeHtml(data.browser_url)}"><span class="fg-mono">${escapeHtml(truncatePath(data.browser_url, 80))}</span></dd>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.innerHTML = `
|
||||||
|
<article class="foreground-card" data-fullscreen="${data.is_fullscreen ? '1' : '0'}">
|
||||||
|
<div class="fg-kicker">
|
||||||
|
<span data-i18n="foreground.kicker">Foreground</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="fg-process" title="${escapeHtml(procName)}">${escapeHtml(procName)}</h1>
|
||||||
|
<div class="fg-window-title" title="${escapeHtml(winTitle)}">${escapeHtml(winTitle)}</div>
|
||||||
|
|
||||||
|
<div class="fg-chips">${chips.join('')}</div>
|
||||||
|
|
||||||
|
<dl class="fg-details">
|
||||||
|
${browserRows.join('')}
|
||||||
|
<div class="fg-row">
|
||||||
|
<dt>${escapeHtml(t('foreground.executable'))}</dt>
|
||||||
|
<dd title="${escapeHtml(execPath)}"><span class="fg-mono">${escapeHtml(truncatePath(execPath))}</span></dd>
|
||||||
|
</div>
|
||||||
|
<div class="fg-row">
|
||||||
|
<dt>${escapeHtml(t('foreground.pid'))}</dt>
|
||||||
|
<dd><span class="fg-mono">${escapeHtml(String(pid))}</span></dd>
|
||||||
|
</div>
|
||||||
|
<div class="fg-row">
|
||||||
|
<dt>${escapeHtml(t('foreground.started'))}</dt>
|
||||||
|
<dd title="${escapeHtml(startedAbs)}"><span class="fg-ago" data-started="${startedEpoch ?? ''}">${escapeHtml(startedAgo)}</span></dd>
|
||||||
|
</div>
|
||||||
|
<div class="fg-row">
|
||||||
|
<dt>${escapeHtml(t('foreground.geometry'))}</dt>
|
||||||
|
<dd><span class="fg-mono">${escapeHtml(geom)}</span></dd>
|
||||||
|
</div>
|
||||||
|
<div class="fg-row">
|
||||||
|
<dt>${escapeHtml(t('foreground.platform'))}</dt>
|
||||||
|
<dd>${escapeHtml(platform)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAgoTicker() {
|
||||||
|
if (agoTickTimer) return;
|
||||||
|
agoTickTimer = setInterval(() => {
|
||||||
|
const el = document.querySelector('.fg-ago[data-started]');
|
||||||
|
if (!el) return;
|
||||||
|
const epoch = parseFloat(el.getAttribute('data-started'));
|
||||||
|
if (!epoch) return;
|
||||||
|
el.textContent = formatAgo(epoch);
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateForegroundUI(data) {
|
||||||
|
latestForeground = data;
|
||||||
|
|
||||||
|
if (!data || data.available === false) {
|
||||||
|
const errMsg = data && data.error ? data.error : '';
|
||||||
|
renderEmpty(t('foreground.unavailable'), errMsg);
|
||||||
|
} else if (!data.process_name && !data.pid) {
|
||||||
|
renderEmpty(t('foreground.no_process'), '');
|
||||||
|
} else {
|
||||||
|
renderTile(data);
|
||||||
|
startAgoTicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadForegroundProcess() {
|
||||||
|
// Push-only — just render the cached state. If nothing has arrived
|
||||||
|
// yet, leave the loading placeholder visible.
|
||||||
|
if (latestForeground !== null) {
|
||||||
|
updateForegroundUI(latestForeground);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,8 +182,7 @@ export async function loadDisplayMonitors() {
|
|||||||
<span class="display-slider-label" data-i18n="display.contrast">${t('display.contrast')}</span>
|
<span class="display-slider-label" data-i18n="display.contrast">${t('display.contrast')}</span>
|
||||||
<input type="range" class="display-slider display-contrast-slider"
|
<input type="range" class="display-slider display-contrast-slider"
|
||||||
min="0" max="100" value="${contrastValue}"
|
min="0" max="100" value="${contrastValue}"
|
||||||
oninput="onDisplayContrastInput(${monitor.id}, this.value)"
|
data-display-slider="contrast" data-monitor-id="${monitor.id}">
|
||||||
onchange="onDisplayContrastChange(${monitor.id}, this.value)">
|
|
||||||
<span class="display-slider-value" id="contrast-val-${monitor.id}">${contrastValue}%</span>
|
<span class="display-slider-value" id="contrast-val-${monitor.id}">${contrastValue}%</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -296,8 +295,7 @@ export async function loadDisplayMonitors() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="display-slider-label" data-i18n="display.brightness">${t('display.brightness')}</span>
|
<span class="display-slider-label" data-i18n="display.brightness">${t('display.brightness')}</span>
|
||||||
<input type="range" class="display-slider display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
|
<input type="range" class="display-slider display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
|
||||||
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)"
|
data-display-slider="brightness" data-monitor-id="${monitor.id}">
|
||||||
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
|
|
||||||
<span class="display-slider-value display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
<span class="display-slider-value display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
||||||
</div>
|
</div>
|
||||||
${contrastRow}
|
${contrastRow}
|
||||||
@@ -306,10 +304,15 @@ export async function loadDisplayMonitors() {
|
|||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bind a single delegated click handler for the power buttons.
|
// Bind a single delegated click handler for the power buttons,
|
||||||
// Avoids inline onclick="..." with interpolated monitor data.
|
// plus input/change handlers for the brightness & contrast sliders.
|
||||||
|
// Avoids inline on* attributes (blocked by script-src 'self' CSP).
|
||||||
container.removeEventListener('click', _onPowerButtonClick);
|
container.removeEventListener('click', _onPowerButtonClick);
|
||||||
container.addEventListener('click', _onPowerButtonClick);
|
container.addEventListener('click', _onPowerButtonClick);
|
||||||
|
container.removeEventListener('input', _onDisplaySliderInput);
|
||||||
|
container.addEventListener('input', _onDisplaySliderInput);
|
||||||
|
container.removeEventListener('change', _onDisplaySliderChange);
|
||||||
|
container.addEventListener('change', _onDisplaySliderChange);
|
||||||
|
|
||||||
// Enhance every tuning <select> with an IconSelect now that the
|
// Enhance every tuning <select> with an IconSelect now that the
|
||||||
// cards are in the DOM (IconSelect needs offsetParent + sibling).
|
// cards are in the DOM (IconSelect needs offsetParent + sibling).
|
||||||
@@ -456,6 +459,30 @@ function _onPowerButtonClick(event) {
|
|||||||
if (Number.isFinite(id)) toggleDisplayPower(id);
|
if (Number.isFinite(id)) toggleDisplayPower(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _onDisplaySliderInput(event) {
|
||||||
|
const el = event.target.closest('input[data-display-slider]');
|
||||||
|
if (!el) return;
|
||||||
|
const id = Number(el.dataset.monitorId);
|
||||||
|
if (!Number.isFinite(id)) return;
|
||||||
|
if (el.dataset.displaySlider === 'brightness') {
|
||||||
|
onDisplayBrightnessInput(id, el.value);
|
||||||
|
} else if (el.dataset.displaySlider === 'contrast') {
|
||||||
|
onDisplayContrastInput(id, el.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onDisplaySliderChange(event) {
|
||||||
|
const el = event.target.closest('input[data-display-slider]');
|
||||||
|
if (!el) return;
|
||||||
|
const id = Number(el.dataset.monitorId);
|
||||||
|
if (!Number.isFinite(id)) return;
|
||||||
|
if (el.dataset.displaySlider === 'brightness') {
|
||||||
|
onDisplayBrightnessChange(id, el.value);
|
||||||
|
} else if (el.dataset.displaySlider === 'contrast') {
|
||||||
|
onDisplayContrastChange(id, el.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleDisplayPower(monitorId) {
|
export async function toggleDisplayPower(monitorId) {
|
||||||
const btn = document.getElementById(`power-btn-${monitorId}`);
|
const btn = document.getElementById(`power-btn-${monitorId}`);
|
||||||
const isOn = btn && btn.classList.contains('on');
|
const isOn = btn && btn.classList.contains('on');
|
||||||
@@ -509,6 +536,8 @@ export async function loadHeaderLinks() {
|
|||||||
a.href = link.url;
|
a.href = link.url;
|
||||||
a.target = '_blank';
|
a.target = '_blank';
|
||||||
a.rel = 'noopener noreferrer';
|
a.rel = 'noopener noreferrer';
|
||||||
|
// Prevent leaking the WebUI URL (with ?token=) via Referer.
|
||||||
|
a.referrerPolicy = 'no-referrer';
|
||||||
a.className = 'header-link';
|
a.className = 'header-link';
|
||||||
a.title = link.label || link.url;
|
a.title = link.label || link.url;
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import {
|
|||||||
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
|
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
|
||||||
POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume,
|
POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume,
|
||||||
getAuthHeaders, hasCredentials,
|
getAuthHeaders, hasCredentials,
|
||||||
|
togglePlayPause, nextTrack, previousTrack,
|
||||||
} from './core.js';
|
} from './core.js';
|
||||||
import { updateBackgroundColors } from './background.js';
|
import { updateBackgroundColors } from './background.js';
|
||||||
import { loadDisplayMonitors } from './links.js';
|
import { loadDisplayMonitors } from './links.js';
|
||||||
|
import { loadForegroundProcess } from './foreground.js';
|
||||||
import { IconSelect } from './icon-select.js';
|
import { IconSelect } from './icon-select.js';
|
||||||
|
|
||||||
// Tab management
|
// Tab management
|
||||||
@@ -75,6 +77,7 @@ export function switchTab(tabName) {
|
|||||||
|
|
||||||
if (tabName === 'display') {
|
if (tabName === 'display') {
|
||||||
loadDisplayMonitors();
|
loadDisplayMonitors();
|
||||||
|
loadForegroundProcess();
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('activeTab', tabName);
|
localStorage.setItem('activeTab', tabName);
|
||||||
@@ -205,20 +208,39 @@ export function renderAccentSwatches() {
|
|||||||
const swatches = accentPresets.map(p =>
|
const swatches = accentPresets.map(p =>
|
||||||
`<div class="accent-swatch ${p.color === current ? 'active' : ''}"
|
`<div class="accent-swatch ${p.color === current ? 'active' : ''}"
|
||||||
style="background: ${p.color}"
|
style="background: ${p.color}"
|
||||||
onclick="selectAccentColor('${p.color}', '${p.hover}')"
|
data-accent-color="${p.color}" data-accent-hover="${p.hover}"
|
||||||
title="${p.name}"></div>`
|
title="${p.name}"></div>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const customRow = `
|
const customRow = `
|
||||||
<div class="accent-custom-row ${isCustom ? 'active' : ''}" onclick="document.getElementById('accentCustomInput').click()">
|
<div class="accent-custom-row ${isCustom ? 'active' : ''}" data-accent-custom-row>
|
||||||
<span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
|
<span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
|
||||||
<span class="accent-custom-label">${t('accent.custom')}</span>
|
<span class="accent-custom-label">${t('accent.custom')}</span>
|
||||||
<input type="color" id="accentCustomInput" value="${current}"
|
<input type="color" id="accentCustomInput" value="${current}">
|
||||||
onclick="event.stopPropagation()"
|
|
||||||
onchange="selectAccentColor(this.value, lightenColor(this.value, 15))">
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
dropdown.innerHTML = swatches + customRow;
|
dropdown.innerHTML = swatches + customRow;
|
||||||
|
|
||||||
|
// Wire CSP-safe handlers (script-src 'self' blocks inline on* attributes).
|
||||||
|
dropdown.querySelectorAll('.accent-swatch[data-accent-color]').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
selectAccentColor(el.dataset.accentColor, el.dataset.accentHover);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const customRowEl = dropdown.querySelector('[data-accent-custom-row]');
|
||||||
|
const customInput = dropdown.querySelector('#accentCustomInput');
|
||||||
|
if (customRowEl && customInput) {
|
||||||
|
customRowEl.addEventListener('click', (e) => {
|
||||||
|
// The native color popup only opens from a user-initiated click on
|
||||||
|
// the <input>. Forward clicks on the row to the input — except when
|
||||||
|
// the input itself was the source (avoids re-entry).
|
||||||
|
if (e.target !== customInput) customInput.click();
|
||||||
|
});
|
||||||
|
customInput.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
customInput.addEventListener('change', () => {
|
||||||
|
selectAccentColor(customInput.value, lightenColor(customInput.value, 15));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectAccentColor(color, hover) {
|
export function selectAccentColor(color, hover) {
|
||||||
@@ -360,11 +382,85 @@ function buildVisualizerGradient() {
|
|||||||
|
|
||||||
function startVisualizerRender() {
|
function startVisualizerRender() {
|
||||||
if (visualizerAnimFrame) return;
|
if (visualizerAnimFrame) return;
|
||||||
|
// Don't even queue a frame while the tab is hidden — rAF still fires on
|
||||||
|
// hidden tabs (throttled but not paused) and would burn CPU + battery
|
||||||
|
// smoothing into bars no one can see. We resume on `visibilitychange`.
|
||||||
|
if (typeof document !== 'undefined' && document.hidden) return;
|
||||||
// Cache editorial spectrum bar refs once per start.
|
// Cache editorial spectrum bar refs once per start.
|
||||||
cacheEditorialSpectrumBars();
|
cacheEditorialSpectrumBars();
|
||||||
renderVisualizerFrame();
|
renderVisualizerFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── OS Media Session integration ─────────────────────────────
|
||||||
|
// Hooks the page into the system's media session so headset / lockscreen /
|
||||||
|
// Bluetooth play-pause-skip buttons drive the active track. Action handlers
|
||||||
|
// are set once and never re-registered; only the metadata + playback state
|
||||||
|
// flip when a track changes.
|
||||||
|
let _mediaSessionInitialised = false;
|
||||||
|
let _lastMediaSessionKey = '';
|
||||||
|
function syncMediaSession(status) {
|
||||||
|
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
|
||||||
|
const session = navigator.mediaSession;
|
||||||
|
|
||||||
|
if (!_mediaSessionInitialised) {
|
||||||
|
const setHandler = (name, fn) => {
|
||||||
|
try { session.setActionHandler(name, fn); } catch { /* unsupported action */ }
|
||||||
|
};
|
||||||
|
setHandler('play', () => togglePlayPause());
|
||||||
|
setHandler('pause', () => togglePlayPause());
|
||||||
|
setHandler('nexttrack', () => nextTrack());
|
||||||
|
setHandler('previoustrack', () => previousTrack());
|
||||||
|
setHandler('seekto', (ev) => { if (ev && typeof ev.seekTime === 'number') seek(ev.seekTime); });
|
||||||
|
_mediaSessionInitialised = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track-identity key — re-build metadata only when title/artist/album change.
|
||||||
|
const artworkSrc = status && status.album_art_url ? '/api/media/artwork' : '';
|
||||||
|
const key = `${status.title || ''}|${status.artist || ''}|${status.album || ''}|${artworkSrc}`;
|
||||||
|
if (key !== _lastMediaSessionKey) {
|
||||||
|
_lastMediaSessionKey = key;
|
||||||
|
try {
|
||||||
|
session.metadata = new MediaMetadata({
|
||||||
|
title: status.title || '',
|
||||||
|
artist: status.artist || '',
|
||||||
|
album: status.album || '',
|
||||||
|
artwork: artworkSrc ? [{ src: artworkSrc, sizes: '512x512', type: 'image/*' }] : [],
|
||||||
|
});
|
||||||
|
} catch { /* MediaMetadata unsupported on very old browsers */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
session.playbackState =
|
||||||
|
status.state === 'playing' ? 'playing'
|
||||||
|
: status.state === 'paused' ? 'paused'
|
||||||
|
: 'none';
|
||||||
|
|
||||||
|
if (typeof session.setPositionState === 'function'
|
||||||
|
&& status.duration && status.duration > 0
|
||||||
|
&& typeof status.position === 'number') {
|
||||||
|
try {
|
||||||
|
session.setPositionState({
|
||||||
|
duration: status.duration,
|
||||||
|
position: Math.min(status.position, status.duration),
|
||||||
|
playbackRate: 1.0,
|
||||||
|
});
|
||||||
|
} catch { /* invalid range — ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause / resume the visualizer with tab visibility. Idempotent: called once
|
||||||
|
// at module init below, no-op if no listener support.
|
||||||
|
if (typeof document !== 'undefined' && document.addEventListener) {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopVisualizerRender();
|
||||||
|
} else if (frequencyData) {
|
||||||
|
// Only restart if a payload is live (otherwise startVisualizerRender
|
||||||
|
// would queue a no-op rAF chain forever waiting for one).
|
||||||
|
startVisualizerRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function stopVisualizerRender() {
|
export function stopVisualizerRender() {
|
||||||
if (visualizerAnimFrame) {
|
if (visualizerAnimFrame) {
|
||||||
cancelAnimationFrame(visualizerAnimFrame);
|
cancelAnimationFrame(visualizerAnimFrame);
|
||||||
@@ -882,6 +978,11 @@ export function updateUI(status) {
|
|||||||
|
|
||||||
updateMuteIcon(status.muted);
|
updateMuteIcon(status.muted);
|
||||||
|
|
||||||
|
// Wire the OS Media Session so headset buttons, lockscreen controls, and
|
||||||
|
// Bluetooth remotes drive the active media (not the WebUI tab). Cheap and
|
||||||
|
// idempotent — re-running setActionHandler with the same fn is a no-op.
|
||||||
|
syncMediaSession(status);
|
||||||
|
|
||||||
const src = resolveMediaSource(status.source);
|
const src = resolveMediaSource(status.source);
|
||||||
dom.source.textContent = src ? src.name : t('player.unknown_source');
|
dom.source.textContent = src ? src.name : t('player.unknown_source');
|
||||||
dom.sourceIcon.innerHTML = src?.icon || '';
|
dom.sourceIcon.innerHTML = src?.icon || '';
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ export async function displayQuickAccess() {
|
|||||||
card.href = link.url;
|
card.href = link.url;
|
||||||
card.target = '_blank';
|
card.target = '_blank';
|
||||||
card.rel = 'noopener noreferrer';
|
card.rel = 'noopener noreferrer';
|
||||||
|
// Prevent the WebUI's URL (which may carry ?token=...) from
|
||||||
|
// leaking to third-party sites via Referer.
|
||||||
|
card.referrerPolicy = 'no-referrer';
|
||||||
|
|
||||||
if (link.icon) {
|
if (link.icon) {
|
||||||
const iconEl = document.createElement('div');
|
const iconEl = document.createElement('div');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices
|
|||||||
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
||||||
import { loadCallbacksTable } from './callbacks.js';
|
import { loadCallbacksTable } from './callbacks.js';
|
||||||
import { loadHeaderLinks, loadLinksTable } from './links.js';
|
import { loadHeaderLinks, loadLinksTable } from './links.js';
|
||||||
|
import { updateForegroundUI } from './foreground.js';
|
||||||
|
|
||||||
let reconnectTimeout = null;
|
let reconnectTimeout = null;
|
||||||
let wsReconnectAttempts = 0;
|
let wsReconnectAttempts = 0;
|
||||||
@@ -87,9 +88,22 @@ export function connectWebSocket(token) {
|
|||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
|
const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
|
||||||
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
|
|
||||||
|
|
||||||
const newWs = new WebSocket(wsUrl);
|
// Prefer Sec-WebSocket-Protocol-based auth so the token never appears in
|
||||||
|
// the URL (which would otherwise land in browser history, server access
|
||||||
|
// logs, and Referer headers). Keep the ?token=... fallback for clients
|
||||||
|
// that pre-date this change and don't speak the subprotocol.
|
||||||
|
let newWs;
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
newWs = new WebSocket(wsBase, [`media-server.token.${token}`]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Subprotocol WS handshake failed, falling back to ?token=', e);
|
||||||
|
newWs = new WebSocket(`${wsBase}?token=${encodeURIComponent(token)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newWs = new WebSocket(wsBase);
|
||||||
|
}
|
||||||
activeSocket = newWs;
|
activeSocket = newWs;
|
||||||
setWs(newWs);
|
setWs(newWs);
|
||||||
|
|
||||||
@@ -118,6 +132,8 @@ export function connectWebSocket(token) {
|
|||||||
|
|
||||||
if (msg.type === 'status' || msg.type === 'status_update') {
|
if (msg.type === 'status' || msg.type === 'status_update') {
|
||||||
updateUI(msg.data);
|
updateUI(msg.data);
|
||||||
|
} else if (msg.type === 'foreground' || msg.type === 'foreground_update') {
|
||||||
|
updateForegroundUI(msg.data);
|
||||||
} else if (msg.type === 'scripts_changed') {
|
} else if (msg.type === 'scripts_changed') {
|
||||||
console.log('Scripts changed, reloading...');
|
console.log('Scripts changed, reloading...');
|
||||||
loadScripts();
|
loadScripts();
|
||||||
|
|||||||
@@ -292,5 +292,29 @@
|
|||||||
"about.source_code": "Source Code",
|
"about.source_code": "Source Code",
|
||||||
"dialog.close": "Close",
|
"dialog.close": "Close",
|
||||||
"update.available": "Update available: v{version}",
|
"update.available": "Update available: v{version}",
|
||||||
"update.view_release": "View Release"
|
"update.view_release": "View Release",
|
||||||
|
"tab.foreground": "Foreground",
|
||||||
|
"foreground.kicker": "Foreground",
|
||||||
|
"foreground.loading": "Waiting for foreground signal…",
|
||||||
|
"foreground.no_process": "No foreground process",
|
||||||
|
"foreground.unavailable": "Foreground tracking unavailable on this platform",
|
||||||
|
"foreground.process": "Process",
|
||||||
|
"foreground.window_title": "Window title",
|
||||||
|
"foreground.executable": "Executable",
|
||||||
|
"foreground.pid": "PID",
|
||||||
|
"foreground.monitor": "Monitor {n}",
|
||||||
|
"foreground.started": "Started",
|
||||||
|
"foreground.geometry": "Geometry",
|
||||||
|
"foreground.platform": "Platform",
|
||||||
|
"foreground.fullscreen": "Fullscreen",
|
||||||
|
"foreground.minimized": "Minimized",
|
||||||
|
"foreground.windowed": "Windowed",
|
||||||
|
"foreground.browser": "Browser",
|
||||||
|
"foreground.page_title": "Page title",
|
||||||
|
"foreground.url": "URL",
|
||||||
|
"foreground.badge.title": "View foreground process",
|
||||||
|
"foreground.ago.seconds": "{n}s ago",
|
||||||
|
"foreground.ago.minutes": "{n}m ago",
|
||||||
|
"foreground.ago.hours": "{n}h {m}m ago",
|
||||||
|
"foreground.ago.days": "{n}d ago"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,5 +292,29 @@
|
|||||||
"about.source_code": "Исходный код",
|
"about.source_code": "Исходный код",
|
||||||
"dialog.close": "Закрыть",
|
"dialog.close": "Закрыть",
|
||||||
"update.available": "Доступно обновление: v{version}",
|
"update.available": "Доступно обновление: v{version}",
|
||||||
"update.view_release": "Перейти к релизу"
|
"update.view_release": "Перейти к релизу",
|
||||||
|
"tab.foreground": "Активное окно",
|
||||||
|
"foreground.kicker": "Активное окно",
|
||||||
|
"foreground.loading": "Ожидание сигнала об активном окне…",
|
||||||
|
"foreground.no_process": "Активное окно не определено",
|
||||||
|
"foreground.unavailable": "Отслеживание активного окна недоступно",
|
||||||
|
"foreground.process": "Процесс",
|
||||||
|
"foreground.window_title": "Заголовок окна",
|
||||||
|
"foreground.executable": "Путь к программе",
|
||||||
|
"foreground.pid": "PID",
|
||||||
|
"foreground.monitor": "Монитор {n}",
|
||||||
|
"foreground.started": "Запущено",
|
||||||
|
"foreground.geometry": "Геометрия",
|
||||||
|
"foreground.platform": "Платформа",
|
||||||
|
"foreground.fullscreen": "Полноэкранный",
|
||||||
|
"foreground.minimized": "Свёрнут",
|
||||||
|
"foreground.windowed": "Оконный",
|
||||||
|
"foreground.browser": "Браузер",
|
||||||
|
"foreground.page_title": "Заголовок страницы",
|
||||||
|
"foreground.url": "URL",
|
||||||
|
"foreground.badge.title": "Открыть активное окно",
|
||||||
|
"foreground.ago.seconds": "{n} с назад",
|
||||||
|
"foreground.ago.minutes": "{n} мин назад",
|
||||||
|
"foreground.ago.hours": "{n} ч {m} мин назад",
|
||||||
|
"foreground.ago.days": "{n} дн назад"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/",
|
||||||
"name": "Media Server",
|
"name": "Media Server",
|
||||||
"short_name": "Media",
|
"short_name": "Media",
|
||||||
"description": "Remote media player control and file browser",
|
"description": "Remote media player control and file browser",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"background_color": "#121212",
|
"background_color": "#0E0D0B",
|
||||||
"theme_color": "#121212",
|
"theme_color": "#0E0D0B",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/static/icons/icon.svg",
|
"src": "/static/icons/icon.svg",
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ class TrayManager:
|
|||||||
|
|
||||||
self._port = port
|
self._port = port
|
||||||
self._on_exit = on_exit
|
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(
|
menu = pystray.Menu(
|
||||||
pystray.MenuItem("Show UI", self._show_ui, default=True),
|
pystray.MenuItem("Show UI", self._show_ui, default=True),
|
||||||
@@ -123,13 +126,16 @@ class TrayManager:
|
|||||||
if not _confirm("Media Server", "Restart the server?"):
|
if not _confirm("Media Server", "Restart the server?"):
|
||||||
return
|
return
|
||||||
logger.info("Restart requested from tray")
|
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._restart_requested = True
|
||||||
self._on_exit()
|
self._on_exit()
|
||||||
self._icon.stop()
|
self._icon.stop()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def restart_requested(self) -> bool:
|
def restart_requested(self) -> bool:
|
||||||
return getattr(self, "_restart_requested", False)
|
return self._restart_requested
|
||||||
|
|
||||||
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
|
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
|
||||||
if not _confirm("Media Server", "Shut down the server?"):
|
if not _confirm("Media Server", "Shut down the server?"):
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "media-server-frontend",
|
"name": "media-server-frontend",
|
||||||
"version": "0.2.5",
|
"version": "0.2.7",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "media-server-frontend",
|
"name": "media-server-frontend",
|
||||||
"version": "0.2.5",
|
"version": "0.2.7",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.27.4"
|
"esbuild": "^0.27.4"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "media-server-frontend",
|
"name": "media-server-frontend",
|
||||||
"version": "0.2.5",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Frontend build tooling for media server WebUI",
|
"description": "Frontend build tooling for media server WebUI",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "media-server"
|
name = "media-server"
|
||||||
version = "0.2.5"
|
version = "0.3.1"
|
||||||
description = "REST API server for controlling system-wide media playback"
|
description = "REST API server for controlling system-wide media playback"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Tests for token scope hierarchy + back-compat with legacy bare-string tokens."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from media_server.config import Settings, TokenSpec
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_string_token_promotes_to_admin_scope():
|
||||||
|
"""Legacy `label: <token>` form must still work and grant admin."""
|
||||||
|
s = Settings(api_tokens={"legacy": "deadbeef-deadbeef-deadbeef-deadbeef"})
|
||||||
|
spec = s.api_tokens["legacy"]
|
||||||
|
assert isinstance(spec, TokenSpec)
|
||||||
|
assert spec.token == "deadbeef-deadbeef-deadbeef-deadbeef"
|
||||||
|
assert spec.scopes == ["admin"]
|
||||||
|
assert spec.grants("admin")
|
||||||
|
assert spec.grants("control")
|
||||||
|
assert spec.grants("read")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dict_token_with_explicit_scopes():
|
||||||
|
s = Settings(api_tokens={
|
||||||
|
"ha": {"token": "aaaaaaaaaaaaaaaa", "scopes": ["read", "control"]},
|
||||||
|
})
|
||||||
|
spec = s.api_tokens["ha"]
|
||||||
|
assert spec.grants("control")
|
||||||
|
assert spec.grants("read")
|
||||||
|
assert not spec.grants("admin")
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_only_scope_grants_only_read():
|
||||||
|
spec = TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=["read"])
|
||||||
|
assert spec.grants("read")
|
||||||
|
assert not spec.grants("control")
|
||||||
|
assert not spec.grants("admin")
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_scope_implies_control_and_read():
|
||||||
|
spec = TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=["admin"])
|
||||||
|
assert spec.grants("read")
|
||||||
|
assert spec.grants("control")
|
||||||
|
assert spec.grants("admin")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_scope_rejected():
|
||||||
|
with pytest.raises(ValueError, match="unknown scopes"):
|
||||||
|
TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=["root"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_scopes_rejected():
|
||||||
|
with pytest.raises(ValueError, match="at least one"):
|
||||||
|
TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=[])
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_token_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
TokenSpec(token="short", scopes=["read"])
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Path traversal defence for BrowserService.validate_path.
|
||||||
|
|
||||||
|
The browser endpoint is the single most security-critical filesystem entry
|
||||||
|
point in the app: it serves file contents and folder listings to the WebUI.
|
||||||
|
A bypass here = arbitrary read of any file the server process can see.
|
||||||
|
|
||||||
|
The current implementation signals rejection by *raising* (ValueError for
|
||||||
|
traversal/NUL/unknown folder, FileNotFoundError for non-existent absolute
|
||||||
|
paths). Either rejection mode is acceptable — these tests assert that the
|
||||||
|
adversarial input never returns a path *inside* the configured base.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from media_server.services.browser_service import BrowserService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_media_folder():
|
||||||
|
"""A real temp dir registered as a media folder for the test duration."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
base = Path(tmp).resolve()
|
||||||
|
(base / "ok.mp3").write_bytes(b"id3")
|
||||||
|
(base / "sub").mkdir()
|
||||||
|
(base / "sub" / "nested.mp3").write_bytes(b"id3")
|
||||||
|
|
||||||
|
from media_server.config import MediaFolderConfig
|
||||||
|
folders = {"test": MediaFolderConfig(path=str(base), label="Test", enabled=True)}
|
||||||
|
with patch("media_server.services.browser_service.settings.media_folders", folders):
|
||||||
|
yield base
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rejected(folder_id: str, rel: str) -> bool:
|
||||||
|
"""Helper: True iff validate_path either raises or returns None."""
|
||||||
|
try:
|
||||||
|
result = BrowserService.validate_path(folder_id, rel)
|
||||||
|
except (ValueError, FileNotFoundError, OSError):
|
||||||
|
return True
|
||||||
|
return result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_path_accepts_a_real_file(tmp_media_folder: Path):
|
||||||
|
p = BrowserService.validate_path("test", "ok.mp3")
|
||||||
|
assert p is not None
|
||||||
|
assert p.is_file()
|
||||||
|
# Defence-in-depth: resolved path must live inside the base.
|
||||||
|
assert tmp_media_folder in p.resolve().parents or p.resolve().parent == tmp_media_folder
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_path_accepts_nested(tmp_media_folder: Path):
|
||||||
|
p = BrowserService.validate_path("test", "sub/nested.mp3")
|
||||||
|
assert p is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_folder_rejected(tmp_media_folder: Path):
|
||||||
|
assert _is_rejected("ghost", "ok.mp3")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dotdot_traversal_rejected(tmp_media_folder: Path):
|
||||||
|
assert _is_rejected("test", "../etc/passwd")
|
||||||
|
assert _is_rejected("test", "..\\..\\Windows\\System32")
|
||||||
|
assert _is_rejected("test", "sub/../../etc/passwd")
|
||||||
|
|
||||||
|
|
||||||
|
def test_absolute_path_rejected(tmp_media_folder: Path):
|
||||||
|
assert _is_rejected("test", "/etc/passwd")
|
||||||
|
assert _is_rejected("test", "C:\\Windows\\System32")
|
||||||
|
assert _is_rejected("test", "C:/Windows")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unc_path_rejected(tmp_media_folder: Path):
|
||||||
|
assert _is_rejected("test", "\\\\server\\share")
|
||||||
|
assert _is_rejected("test", "//server/share")
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_byte_rejected(tmp_media_folder: Path):
|
||||||
|
assert _is_rejected("test", "ok.mp3\x00.png")
|
||||||
|
assert _is_rejected("test", "sub\x00/nested.mp3")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Atomic config writes + POSIX permission hardening."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from media_server.config import _restrict_config_perms, _write_yaml_atomic
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_write_round_trip():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "config.yaml"
|
||||||
|
_write_yaml_atomic(path, {"port": 8765, "host": "127.0.0.1"})
|
||||||
|
assert path.exists()
|
||||||
|
# Tmp file from the rename should be gone.
|
||||||
|
assert not path.with_suffix(path.suffix + ".tmp").exists()
|
||||||
|
# Contents are valid YAML and round-trip.
|
||||||
|
import yaml
|
||||||
|
data = yaml.safe_load(path.read_text())
|
||||||
|
assert data == {"port": 8765, "host": "127.0.0.1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_write_replaces_existing():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "config.yaml"
|
||||||
|
path.write_text("old: 1\n")
|
||||||
|
_write_yaml_atomic(path, {"new": 2})
|
||||||
|
import yaml
|
||||||
|
assert yaml.safe_load(path.read_text()) == {"new": 2}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only permission check")
|
||||||
|
def test_restrict_config_perms_posix():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "config.yaml"
|
||||||
|
path.write_text("token: secret\n")
|
||||||
|
_restrict_config_perms(path)
|
||||||
|
mode = stat.S_IMODE(os.stat(path).st_mode)
|
||||||
|
# Owner read+write only.
|
||||||
|
assert mode == 0o600, f"got {oct(mode)}"
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Smoke tests for the foreground tracker.
|
||||||
|
|
||||||
|
The OS-specific probe code is hard to mock end-to-end inside a CI container,
|
||||||
|
so these tests focus on the platform-agnostic surface: the dataclass shape,
|
||||||
|
TTL caching, and graceful fallback when the platform probe raises. The
|
||||||
|
Windows/Linux/macOS probes themselves are exercised through manual runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_server.services import foreground_service as fg
|
||||||
|
|
||||||
|
|
||||||
|
def setup_function(_):
|
||||||
|
fg.reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_default_shape():
|
||||||
|
info = fg.ForegroundInfo(available=False)
|
||||||
|
d = info.to_dict()
|
||||||
|
assert d["available"] is False
|
||||||
|
assert d["pid"] is None
|
||||||
|
assert d["process_name"] is None
|
||||||
|
assert d["is_fullscreen"] is False
|
||||||
|
assert "platform" in d
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_returns_same_instance(monkeypatch):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake_probe():
|
||||||
|
calls["n"] += 1
|
||||||
|
return fg.ForegroundInfo(available=True, pid=42, process_name="x.exe")
|
||||||
|
|
||||||
|
monkeypatch.setattr(fg, "_probe", fake_probe)
|
||||||
|
|
||||||
|
a = fg.get_foreground_info()
|
||||||
|
b = fg.get_foreground_info()
|
||||||
|
assert a is b
|
||||||
|
assert calls["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_force_refresh(monkeypatch):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake_probe():
|
||||||
|
calls["n"] += 1
|
||||||
|
return fg.ForegroundInfo(available=True, pid=calls["n"])
|
||||||
|
|
||||||
|
monkeypatch.setattr(fg, "_probe", fake_probe)
|
||||||
|
|
||||||
|
fg.get_foreground_info()
|
||||||
|
fg.get_foreground_info(force_refresh=True)
|
||||||
|
assert calls["n"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_ttl_expiry(monkeypatch):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake_probe():
|
||||||
|
calls["n"] += 1
|
||||||
|
return fg.ForegroundInfo(available=True, pid=calls["n"])
|
||||||
|
|
||||||
|
monkeypatch.setattr(fg, "_probe", fake_probe)
|
||||||
|
monkeypatch.setattr(fg, "_CACHE_TTL", 0.0)
|
||||||
|
# Re-bind the cache's TTL by exercising it twice with TTL 0.
|
||||||
|
fg.get_foreground_info()
|
||||||
|
fg.get_foreground_info()
|
||||||
|
assert calls["n"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_crash_returns_unavailable(monkeypatch):
|
||||||
|
def boom():
|
||||||
|
raise RuntimeError("kaboom")
|
||||||
|
|
||||||
|
# Force every platform branch to call our crashing probe.
|
||||||
|
monkeypatch.setattr(fg, "_probe_windows", boom)
|
||||||
|
monkeypatch.setattr(fg, "_probe_linux", boom)
|
||||||
|
monkeypatch.setattr(fg, "_probe_macos", boom)
|
||||||
|
|
||||||
|
info = fg._probe()
|
||||||
|
assert info.available is False
|
||||||
|
assert info.error and "kaboom" in info.error
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Tag-name validation in the Gitea release provider.
|
||||||
|
|
||||||
|
Whitelist regex protects the URL we broadcast to clients from any path
|
||||||
|
traversal or character-set abuse in a hostile (or MITM'd) upstream response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_server.services.gitea_release_provider import _TAG_RE
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepts_plain_semver():
|
||||||
|
assert _TAG_RE.match("1.0.0")
|
||||||
|
assert _TAG_RE.match("v1.0.0")
|
||||||
|
assert _TAG_RE.match("0.3.7")
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepts_pre_release_suffix():
|
||||||
|
assert _TAG_RE.match("v1.0.0-alpha.1")
|
||||||
|
assert _TAG_RE.match("v2.3.4-rc.10")
|
||||||
|
assert _TAG_RE.match("v0.2.7+build.42")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_path_traversal():
|
||||||
|
assert not _TAG_RE.match("../etc/passwd")
|
||||||
|
assert not _TAG_RE.match("v1.0.0/../../evil")
|
||||||
|
assert not _TAG_RE.match("v1.0.0/secret")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_url_injection():
|
||||||
|
assert not _TAG_RE.match("v1.0.0?evil=1")
|
||||||
|
assert not _TAG_RE.match("v1.0.0#frag")
|
||||||
|
assert not _TAG_RE.match("v1.0.0 OR 1=1")
|
||||||
|
assert not _TAG_RE.match("https://evil.example/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_empty_and_garbage():
|
||||||
|
assert not _TAG_RE.match("")
|
||||||
|
assert not _TAG_RE.match("not-a-version")
|
||||||
|
assert not _TAG_RE.match("v")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_excessively_long_suffix():
|
||||||
|
long_suffix = "x" * 40
|
||||||
|
assert not _TAG_RE.match(f"v1.0.0-{long_suffix}")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Token-bucket rate limiter behaviour."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from media_server.services import rate_limit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_state():
|
||||||
|
rate_limit._state.clear()
|
||||||
|
rate_limit._LAST_CLEANUP = 0.0
|
||||||
|
yield
|
||||||
|
rate_limit._state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_allows_up_to_capacity_then_blocks(monkeypatch):
|
||||||
|
"""Default execute bucket = 10/min."""
|
||||||
|
peer = "10.0.0.1"
|
||||||
|
for i in range(10):
|
||||||
|
ok, retry = rate_limit.check("execute", peer)
|
||||||
|
assert ok, f"expected allow on attempt {i + 1}, got block (retry={retry})"
|
||||||
|
ok, retry = rate_limit.check("execute", peer)
|
||||||
|
assert ok is False
|
||||||
|
assert retry is not None and retry > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_peers_independent():
|
||||||
|
for _ in range(10):
|
||||||
|
assert rate_limit.check("execute", "10.0.0.1")[0]
|
||||||
|
# Different peer should still be allowed.
|
||||||
|
assert rate_limit.check("execute", "10.0.0.2")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_bucket_uses_default():
|
||||||
|
peer = "10.0.0.3"
|
||||||
|
# default = 60/min — first call always allowed.
|
||||||
|
allowed, _ = rate_limit.check("nonexistent-bucket", peer)
|
||||||
|
assert allowed
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_bucket_is_strict():
|
||||||
|
"""auth bucket = 5/min."""
|
||||||
|
peer = "10.0.0.4"
|
||||||
|
for _ in range(5):
|
||||||
|
assert rate_limit.check("auth", peer)[0]
|
||||||
|
blocked, retry = rate_limit.check("auth", peer)
|
||||||
|
assert not blocked
|
||||||
|
assert retry is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_refill_eventually_unblocks(monkeypatch):
|
||||||
|
"""Verify the bucket refills — exhaust then wait one refill period."""
|
||||||
|
peer = "10.0.0.5"
|
||||||
|
# Replace BUCKETS with a fast-refilling one for the test only.
|
||||||
|
monkeypatch.setitem(
|
||||||
|
rate_limit.BUCKETS,
|
||||||
|
"fast-test",
|
||||||
|
rate_limit.BucketConfig(capacity=2, refill_per_sec=10.0),
|
||||||
|
)
|
||||||
|
assert rate_limit.check("fast-test", peer)[0]
|
||||||
|
assert rate_limit.check("fast-test", peer)[0]
|
||||||
|
assert not rate_limit.check("fast-test", peer)[0]
|
||||||
|
time.sleep(0.15) # 0.15 * 10 = 1.5 tokens
|
||||||
|
assert rate_limit.check("fast-test", peer)[0]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Validation rules for script parameters (type coercion, regex pattern)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from media_server.config import ScriptParameterConfig
|
||||||
|
from media_server.routes.scripts import _validate_params
|
||||||
|
|
||||||
|
|
||||||
|
def _defs(**kwargs) -> dict[str, ScriptParameterConfig]:
|
||||||
|
return {name: ScriptParameterConfig(**spec) for name, spec in kwargs.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_param_rejected():
|
||||||
|
with pytest.raises(HTTPException) as ei:
|
||||||
|
_validate_params({"x": "1"}, _defs())
|
||||||
|
assert ei.value.status_code == 400
|
||||||
|
assert "Unknown" in ei.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_required_rejected():
|
||||||
|
defs = _defs(name={"type": "string", "required": True})
|
||||||
|
with pytest.raises(HTTPException, match="missing"):
|
||||||
|
_validate_params({}, defs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_integer_coercion_and_bounds():
|
||||||
|
defs = _defs(volume={"type": "integer", "min": 0, "max": 100})
|
||||||
|
out = _validate_params({"volume": "42"}, defs)
|
||||||
|
assert out == {"SCRIPT_PARAM_VOLUME": "42"}
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException, match="<="):
|
||||||
|
_validate_params({"volume": 200}, defs)
|
||||||
|
with pytest.raises(HTTPException, match=">="):
|
||||||
|
_validate_params({"volume": -1}, defs)
|
||||||
|
with pytest.raises(HTTPException, match="integer"):
|
||||||
|
_validate_params({"volume": "not-a-number"}, defs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_boolean_coercion():
|
||||||
|
defs = _defs(flag={"type": "boolean"})
|
||||||
|
assert _validate_params({"flag": "true"}, defs) == {"SCRIPT_PARAM_FLAG": "True"}
|
||||||
|
assert _validate_params({"flag": "no"}, defs) == {"SCRIPT_PARAM_FLAG": "False"}
|
||||||
|
with pytest.raises(HTTPException, match="boolean"):
|
||||||
|
_validate_params({"flag": "maybe"}, defs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_rejects_non_option():
|
||||||
|
defs = _defs(mode={"type": "select", "options": ["a", "b", "c"]})
|
||||||
|
assert _validate_params({"mode": "a"}, defs) == {"SCRIPT_PARAM_MODE": "a"}
|
||||||
|
with pytest.raises(HTTPException, match="must be one of"):
|
||||||
|
_validate_params({"mode": "z"}, defs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_enforced_on_string():
|
||||||
|
"""Regex pattern is the defence against shell metachars in shell=true scripts."""
|
||||||
|
defs = _defs(host={"type": "string", "pattern": r"^[a-z0-9.\-]+$"})
|
||||||
|
assert _validate_params({"host": "example.com"}, defs) == {"SCRIPT_PARAM_HOST": "example.com"}
|
||||||
|
with pytest.raises(HTTPException, match="pattern"):
|
||||||
|
_validate_params({"host": "evil & calc.exe"}, defs)
|
||||||
|
with pytest.raises(HTTPException, match="pattern"):
|
||||||
|
_validate_params({"host": "$(rm -rf /)"}, defs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_can_disallow_empty():
|
||||||
|
defs = _defs(host={"type": "string", "pattern": r"^[a-z]+$"})
|
||||||
|
with pytest.raises(HTTPException, match="pattern"):
|
||||||
|
_validate_params({"host": ""}, defs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_pattern_in_config_fails_closed():
|
||||||
|
defs = _defs(host={"type": "string", "pattern": r"["}) # unmatched bracket
|
||||||
|
with pytest.raises(HTTPException) as ei:
|
||||||
|
_validate_params({"host": "x"}, defs)
|
||||||
|
assert ei.value.status_code == 500
|
||||||
Reference in New Issue
Block a user