diff --git a/client/app.js b/client/app.js
index a3918e6..9fe3672 100644
--- a/client/app.js
+++ b/client/app.js
@@ -515,8 +515,7 @@
return;
}
- SETTINGS_STATUS.textContent =
- strings.settings_saved || "Settings saved";
+ SETTINGS_STATUS.textContent = strings.settings_saved || "Settings saved";
currentApiToken = token;
} catch (err) {
SETTINGS_STATUS.textContent =
diff --git a/client/index.html b/client/index.html
index f8a717f..3b23cff 100644
--- a/client/index.html
+++ b/client/index.html
@@ -121,7 +121,7 @@
-
+
Dashboard PIN
None:
self.dashboard_pin: str = ""
self.api_token: str = ""
- self.host: str = "0.0.0.0"
+ self.host: str = ""
self.port: int = 8921
self.log_level: str = "INFO"
+ self.pin_ttl: int = 300 # 5 minutes in seconds
def load(self) -> None:
"""Load configuration from all sources."""
- # 1. Load config.yaml
+ # 1. Load config.yaml (or create if missing)
config_file = self._find_config_file()
if config_file and config_file.exists():
self._load_yaml(config_file)
+ else:
+ # First run — create default config
+ self._create_default_config()
# 2. Load .env file
env_file = self._find_env_file()
@@ -53,11 +60,60 @@ class Config:
if not self.dashboard_pin:
logger.warning("DASHBOARD_PIN not set — WebSocket auth will be disabled")
if not self.api_token:
- logger.warning("API_TOKEN not set — /api/v1/notify will reject all requests")
+ logger.warning(
+ "API_TOKEN not set — /api/v1/notify will reject all requests"
+ )
+
+ def _detect_local_ip(self) -> str:
+ """Detect the machine's local network IP address."""
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(("8.8.8.8", 80))
+ ip = s.getsockname()[0]
+ s.close()
+ return ip
+ except Exception:
+ return "127.0.0.1"
+
+ def _generate_token(self) -> str:
+ """Generate a cryptographically secure API token.
+
+ Uses secrets.token_urlsafe(32) for 256 bits of entropy,
+ encoded as Base64URL (no padding).
+ """
+ return secrets.token_urlsafe(32)
+
+ def _create_default_config(self) -> None:
+ """Create a default config.yaml if one doesn't exist."""
+ if not HAS_YAML:
+ logger.warning("PyYAML not installed — cannot create default config")
+ return
+
+ config_file = self._find_config_file()
+ if config_file is None:
+ config_file = Path.cwd() / "config.yaml"
+
+ data = {
+ "dashboard_pin": "",
+ "api_token": self._generate_token(),
+ "host": self._detect_local_ip(),
+ "port": self.port,
+ "log_level": self.log_level,
+ "pin_ttl": self.pin_ttl,
+ }
+
+ try:
+ with open(config_file, "w") as f:
+ yaml.dump(data, f, default_flow_style=False) # type: ignore[possibly-undefined]
+ logger.info(f"Created default config at {config_file}")
+ logger.info(f"Dashboard PIN: {data['dashboard_pin']}")
+ logger.info(f"API Token: {data['api_token']}")
+ except Exception as e:
+ logger.error(f"Failed to create default config: {e}")
def _get_base_path(self) -> Path:
"""Get base path (exe directory or cwd)."""
- if getattr(sys, 'frozen', False):
+ if getattr(sys, "frozen", False):
return Path(sys.executable).parent
return Path.cwd()
@@ -91,9 +147,10 @@ class Config:
self.dashboard_pin = data.get("dashboard_pin", self.dashboard_pin)
self.api_token = data.get("api_token", self.api_token)
- self.host = data.get("host", self.host)
+ self.host = data.get("host", self._detect_local_ip())
self.port = data.get("port", self.port)
self.log_level = data.get("log_level", self.log_level).upper()
+ self.pin_ttl = data.get("pin_ttl", self.pin_ttl)
logger.info(f"Loaded config from {path}")
except Exception as e:
@@ -121,6 +178,10 @@ class Config:
self.port = int(value)
case "LOG_LEVEL":
self.log_level = value.upper()
+ case "PIN_TTL":
+ self.pin_ttl = int(value)
+ case _:
+ pass
logger.info(f"Loaded config from {path}")
except Exception as e:
@@ -130,12 +191,16 @@ class Config:
"""Load configuration from environment variables (highest priority)."""
self.dashboard_pin = os.environ.get("DASHBOARD_PIN", self.dashboard_pin)
self.api_token = os.environ.get("API_TOKEN", self.api_token)
- self.host = os.environ.get("HOST", self.host)
+ self.host = os.environ.get("HOST") or self._detect_local_ip()
try:
self.port = int(os.environ.get("PORT", self.port))
except ValueError:
logger.warning("Invalid PORT environment variable")
self.log_level = os.environ.get("LOG_LEVEL", self.log_level).upper()
+ try:
+ self.pin_ttl = int(os.environ.get("PIN_TTL", self.pin_ttl))
+ except ValueError:
+ logger.warning("Invalid PIN_TTL environment variable")
def save(self, path: Path | None = None) -> None:
"""Save configuration to YAML file."""
@@ -169,6 +234,7 @@ class Config:
"host": self.host,
"port": self.port,
"log_level": self.log_level,
+ "pin_ttl": self.pin_ttl,
}
diff --git a/server/main.py b/server/main.py
index c3c19bb..281c87b 100644
--- a/server/main.py
+++ b/server/main.py
@@ -12,7 +12,14 @@ import os
from contextlib import asynccontextmanager
from typing import Any
-from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
+from fastapi import (
+ FastAPI,
+ HTTPException,
+ Query,
+ Request,
+ WebSocket,
+ WebSocketDisconnect,
+)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
@@ -20,6 +27,7 @@ from .audio_mixer import AudioMixerManager
from .config import config
from .media import MediaManager
from .notifications import NotificationManager, Notification
+from .pin_manager import PinManager
from .telemetry import TelemetryManager
logger = logging.getLogger("paperdash")
@@ -37,6 +45,7 @@ def check_subsystems() -> dict[str, bool]:
# Check Voicemeeter
try:
import voicemeeter_api # noqa: F401 # type: ignore
+
results["voicemeeter"] = True
except ImportError:
results["voicemeeter"] = False
@@ -44,6 +53,7 @@ def check_subsystems() -> dict[str, bool]:
# Check Windows Audio (pycaw)
try:
import pycaw # noqa: F401 # type: ignore
+
results["windows_audio"] = True
except ImportError:
results["windows_audio"] = False
@@ -51,6 +61,7 @@ def check_subsystems() -> dict[str, bool]:
# Check Windows Media (winsdk)
try:
import winsdk # noqa: F401 # type: ignore
+
results["windows_media"] = True
except ImportError:
results["windows_media"] = False
@@ -58,6 +69,7 @@ def check_subsystems() -> dict[str, bool]:
# Check HWiNFO (pyhwinfo)
try:
import pyhwinfo # noqa: F401 # type: ignore
+
results["hwinfo"] = True
except ImportError:
results["hwinfo"] = False
@@ -103,6 +115,12 @@ async def lifespan(app: FastAPI):
app.state.notifications = NotificationManager()
app.state.telemetry = TelemetryManager()
+ # Initialize PIN manager
+ app.state.pin_manager = PinManager(ttl_seconds=config.pin_ttl)
+ current_pin = app.state.pin_manager.generate()
+ logger.info(f"Dashboard PIN: {current_pin}")
+ logger.info(f"PIN expires in {config.pin_ttl} seconds")
+
# Start background tasks
app.state.tasks = [
asyncio.create_task(app.state.media.run()),
@@ -110,6 +128,9 @@ async def lifespan(app: FastAPI):
asyncio.create_task(app.state.telemetry.run()),
]
+ # Start PIN cleanup loop
+ app.state.pin_manager.start_cleanup_loop()
+
logger.info("PaperDash server started")
yield
@@ -117,6 +138,11 @@ async def lifespan(app: FastAPI):
for task in app.state.tasks:
task.cancel()
await asyncio.gather(*app.state.tasks, return_exceptions=True)
+
+ # Stop PIN manager
+ if hasattr(app.state, "pin_manager"):
+ app.state.pin_manager.stop()
+
logger.info("PaperDash server stopped")
@@ -188,6 +214,11 @@ async def websocket_endpoint(
pin: str = Query(..., description="Dashboard PIN"),
):
"""WebSocket endpoint for the E-Ink dashboard client."""
+ # Validate PIN (marks as used if valid)
+ if not app.state.pin_manager.validate(pin):
+ await websocket.close(code=4001, reason="Invalid or expired PIN")
+ return
+
if await ws_manager.connect(websocket, pin):
try:
# Keep connection alive; the server pushes state updates.
@@ -271,6 +302,12 @@ async def create_notification(
# ---------------------------------------------------------------------------
+def _is_localhost(request: Request) -> bool:
+ """Check if the request is from localhost."""
+ client_host = request.client.host if request.client else ""
+ return client_host in ("127.0.0.1", "::1", "localhost")
+
+
class SettingsUpdate:
"""Settings update payload."""
@@ -291,21 +328,23 @@ class SettingsUpdate:
@app.get("/api/v1/settings")
async def get_settings(
+ request: Request,
authorization: str | None = None,
):
- """Get current settings (requires API token)."""
- if not _verify_token(authorization):
+ """Get current settings (requires API token or localhost)."""
+ if not _verify_token(authorization) and not _is_localhost(request):
raise HTTPException(status_code=401, detail="Invalid or missing API token")
return config.to_dict()
@app.post("/api/v1/settings")
async def update_settings(
+ request: Request,
settings: SettingsUpdate,
authorization: str | None = None,
):
- """Update settings (requires API token)."""
- if not _verify_token(authorization):
+ """Update settings (requires API token or localhost)."""
+ if not _verify_token(authorization) and not _is_localhost(request):
raise HTTPException(status_code=401, detail="Invalid or missing API token")
if settings.dashboard_pin is not None:
@@ -343,6 +382,31 @@ async def get_status():
}
+# ---------------------------------------------------------------------------
+# Settings Page (localhost only)
+# ---------------------------------------------------------------------------
+
+
+@app.get("/settings")
+async def settings_page(request: Request):
+ """Serve the settings page (localhost only)."""
+ if not _is_localhost(request):
+ raise HTTPException(status_code=403, detail="Forbidden")
+ settings_html = os.path.join(os.path.dirname(__file__), "settings.html")
+ with open(settings_html) as f:
+ return HTMLResponse(f.read())
+
+
+@app.get("/settings.css")
+async def settings_css(request: Request):
+ """Serve the settings stylesheet (localhost only)."""
+ if not _is_localhost(request):
+ raise HTTPException(status_code=403, detail="Forbidden")
+ css_path = os.path.join(os.path.dirname(__file__), "settings.css")
+ with open(css_path) as f:
+ return HTMLResponse(f.read(), media_type="text/css")
+
+
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
diff --git a/server/pin_manager.py b/server/pin_manager.py
new file mode 100644
index 0000000..7d230ec
--- /dev/null
+++ b/server/pin_manager.py
@@ -0,0 +1,141 @@
+"""PIN manager for PaperDash.
+
+Generates time-limited, single-use PINs for WebSocket authentication.
+"""
+
+from __future__ import annotations
+
+import logging
+import secrets
+import threading
+import time
+from dataclasses import dataclass
+from typing import Optional
+
+logger = logging.getLogger("paperdash.pin_manager")
+
+
+@dataclass
+class PinEntry:
+ """A generated PIN with expiration and usage tracking."""
+
+ pin: str
+ created_at: float
+ expires_at: float
+ used: bool = False
+
+
+class PinManager:
+ """Manages time-limited, single-use PINs."""
+
+ def __init__(self, ttl_seconds: int = 300) -> None:
+ """
+ Args:
+ ttl_seconds: How long a PIN remains valid (default: 5 minutes)
+ """
+ self.ttl_seconds = ttl_seconds
+ self.pins: dict[str, PinEntry] = {}
+ self._lock = threading.Lock()
+ self._cleanup_timer: Optional[threading.Timer] = None
+
+ def generate(self) -> str:
+ """Generate a new 6-digit PIN.
+
+ Returns:
+ The generated PIN string.
+ """
+ pin = secrets.token_hex(3)[:6] # 6 random hex digits
+
+ now = time.time()
+ entry = PinEntry(
+ pin=pin,
+ created_at=now,
+ expires_at=now + self.ttl_seconds,
+ used=False,
+ )
+
+ with self._lock:
+ self.pins[pin] = entry
+
+ logger.info(f"Generated PIN {pin} (expires in {self.ttl_seconds}s)")
+ return pin
+
+ def validate(self, pin: str) -> bool:
+ """Validate a PIN (marks it as used if valid).
+
+ Args:
+ pin: The PIN to validate.
+
+ Returns:
+ True if the PIN is valid and not yet used.
+ """
+ with self._lock:
+ entry = self.pins.get(pin)
+ if entry is None:
+ return False
+
+ # Check expiration
+ if time.time() > entry.expires_at:
+ del self.pins[pin]
+ logger.info(f"PIN {pin} expired")
+ return False
+
+ # Check if already used
+ if entry.used:
+ logger.info(f"PIN {pin} already used")
+ return False
+
+ # Mark as used
+ entry.used = True
+ return True
+
+ def cleanup_expired(self) -> int:
+ """Remove expired PINs.
+
+ Returns:
+ Number of PINs removed.
+ """
+ now = time.time()
+ expired = [pin for pin, entry in self.pins.items() if now > entry.expires_at]
+
+ with self._lock:
+ for pin in expired:
+ del self.pins[pin]
+
+ if expired:
+ logger.info(f"Cleaned up {len(expired)} expired PIN(s)")
+
+ return len(expired)
+
+ def start_cleanup_loop(self) -> None:
+ """Start periodic cleanup of expired PINs (every 60 seconds)."""
+ self._run_cleanup()
+
+ def _run_cleanup(self) -> None:
+ """Run one cleanup cycle and schedule the next."""
+ self.cleanup_expired()
+
+ with self._lock:
+ if self._cleanup_timer is not None:
+ self._cleanup_timer.cancel()
+
+ self._cleanup_timer = threading.Timer(60, self._run_cleanup)
+ self._cleanup_timer.daemon = True
+ self._cleanup_timer.start()
+
+ def stop(self) -> None:
+ """Stop the cleanup loop."""
+ with self._lock:
+ if self._cleanup_timer is not None:
+ self._cleanup_timer.cancel()
+ self._cleanup_timer = None
+
+ def get_active_count(self) -> int:
+ """Get the number of active (non-expired, unused) PINs."""
+ now = time.time()
+ with self._lock:
+ return sum(
+ 1
+ for entry in self.pins.values()
+ if now <= entry.expires_at and not entry.used
+ )
diff --git a/server/settings.css b/server/settings.css
new file mode 100644
index 0000000..db4a705
--- /dev/null
+++ b/server/settings.css
@@ -0,0 +1,117 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+body {
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: #1a1a1a;
+ color: #e0e0e0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ padding: 20px;
+}
+.container {
+ background: #2a2a2a;
+ border-radius: 12px;
+ padding: 32px;
+ width: 100%;
+ max-width: 480px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
+}
+h1 {
+ font-size: 1.5rem;
+ margin-bottom: 4px;
+ color: #fff;
+}
+.subtitle {
+ font-size: 0.85rem;
+ color: #888;
+ margin-bottom: 24px;
+}
+.field {
+ margin-bottom: 18px;
+}
+label {
+ display: block;
+ font-size: 0.8rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #aaa;
+ margin-bottom: 6px;
+}
+input,
+select {
+ width: 100%;
+ padding: 10px 12px;
+ background: #1a1a1a;
+ border: 1px solid #444;
+ border-radius: 6px;
+ color: #e0e0e0;
+ font-size: 0.95rem;
+ outline: none;
+ transition: border-color 0.15s;
+}
+input:focus,
+select:focus {
+ border-color: #5b9aff;
+}
+input::placeholder {
+ color: #555;
+}
+.hint {
+ font-size: 0.75rem;
+ color: #666;
+ margin-top: 4px;
+}
+.actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 24px;
+}
+button {
+ flex: 1;
+ padding: 10px 16px;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+button:hover {
+ opacity: 0.85;
+}
+#save-btn {
+ background: #5b9aff;
+ color: #000;
+}
+#reset-btn {
+ background: #444;
+ color: #e0e0e0;
+}
+.status {
+ margin-top: 14px;
+ padding: 10px 12px;
+ border-radius: 6px;
+ font-size: 0.85rem;
+ display: none;
+}
+.status.success {
+ display: block;
+ background: #1a3a1a;
+ color: #6fcf6f;
+ border: 1px solid #2a5a2a;
+}
+.status.error {
+ display: block;
+ background: #3a1a1a;
+ color: #cf6f6f;
+ border: 1px solid #5a2a2a;
+}
diff --git a/server/settings.html b/server/settings.html
new file mode 100644
index 0000000..a7661b5
--- /dev/null
+++ b/server/settings.html
@@ -0,0 +1,142 @@
+
+
+
+
+
+ PaperDash Settings
+
+
+
+
+
PaperDash Settings
+
Changes take effect on next restart
+
+
+
+
+
+
+
+
+
diff --git a/server/start.bat b/server/start.bat
new file mode 100644
index 0000000..07aa5be
--- /dev/null
+++ b/server/start.bat
@@ -0,0 +1,33 @@
+@echo off
+REM PaperDash Server Startup Script
+REM Creates venv if needed, installs dependencies, and starts the server
+
+setlocal
+
+cd /d "%~dp0"
+
+REM Create venv if it doesn't exist
+if not exist "venv" (
+ echo Creating virtual environment...
+ python -m venv venv
+)
+
+REM Activate venv
+call venv\Scripts\activate.bat
+
+REM Install dependencies
+echo Installing dependencies...
+pip install --upgrade pip
+pip install -r requirements.txt
+
+REM Run the server
+echo Starting PaperDash server...
+echo Dashboard: http://localhost:8921
+echo API docs: http://localhost:8921/docs
+echo.
+echo Set environment variables before starting:
+echo DASHBOARD_PIN=1234
+echo API_TOKEN=your-token
+echo.
+
+uvicorn main:app --host 0.0.0.0 --port 8921 --reload
diff --git a/server/start.sh b/server/start.sh
new file mode 100644
index 0000000..b361d10
--- /dev/null
+++ b/server/start.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+# PaperDash Server Startup Script
+# Creates venv if needed, installs dependencies, and starts the server
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+# Create venv if it doesn't exist
+if [ ! -d "venv" ]; then
+ echo "Creating virtual environment..."
+ python3 -m venv venv
+fi
+
+# Activate venv
+source venv/bin/activate
+
+# Install dependencies
+echo "Installing dependencies..."
+pip install --upgrade pip
+pip install -r requirements.txt
+
+# Run the server
+echo "Starting PaperDash server..."
+echo " Dashboard: http://localhost:8921"
+echo " API docs: http://localhost:8921/docs"
+echo ""
+echo "Set environment variables before starting:"
+echo " DASHBOARD_PIN=1234"
+echo " API_TOKEN=your-token"
+echo ""
+
+uvicorn main:app --host 0.0.0.0 --port 8921 --reload
diff --git a/server/tray_wrapper.py b/server/tray_wrapper.py
index 18c5337..be4c611 100644
--- a/server/tray_wrapper.py
+++ b/server/tray_wrapper.py
@@ -8,7 +8,10 @@ from __future__ import annotations
import logging
import multiprocessing
+import os
import signal
+import subprocess
+import sys
import time
import pystray
@@ -42,9 +45,9 @@ def _run_server() -> None:
uvicorn.run(
"main:app",
- host="0.0.0.0",
- port=8921,
- log_level="info",
+ host=CONFIG_HOST,
+ port=CONFIG_PORT,
+ log_level=CONFIG_LOG_LEVEL,
reload=False,
)
@@ -55,6 +58,21 @@ def _on_quit(icon: pystray.Icon, item: pystray.MenuItem) -> None:
icon.stop()
+def _on_settings(icon: pystray.Icon, item: pystray.MenuItem) -> None:
+ """Open the settings page in the default browser."""
+ logger.info("Opening settings page...")
+ url = "http://127.0.0.1:8921/settings"
+ try:
+ if sys.platform == "win32":
+ os.startfile(url)
+ elif sys.platform == "darwin":
+ subprocess.run(["open", url])
+ else:
+ subprocess.run(["xdg-open", url])
+ except Exception as e:
+ logger.error(f"Failed to open settings page: {e}")
+
+
def main() -> None:
"""Entry point for the compiled .exe."""
logger.info("PaperDash starting...")
@@ -72,6 +90,8 @@ def main() -> None:
menu = pystray.Menu(
pystray.MenuItem("PaperDash", pystray.MenuItem.default, enabled=False),
pystray.Menu.Separator(),
+ pystray.MenuItem("Settings", _on_settings),
+ pystray.Menu.Separator(),
pystray.MenuItem("Quit", _on_quit),
)
@@ -100,5 +120,17 @@ def main() -> None:
server_process.join(timeout=5)
+def _load_config_values() -> tuple[str, int, str]:
+ """Load config and return (host, port, log_level) for the server."""
+ from .config import config
+
+ config.load()
+ return config.host or "127.0.0.1", config.port, config.log_level.lower()
+
+
+# Load config at module level so _run_server uses detected values
+CONFIG_HOST, CONFIG_PORT, CONFIG_LOG_LEVEL = _load_config_values()
+
+
if __name__ == "__main__":
main()