Add settings page, PIN manager, and auto-config
- New settings page at /settings (localhost-only) accessible via tray icon - PIN manager with 6-digit codes, 5-min TTL, single-use - API token uses secrets.token_urlsafe(32) for 256-bit entropy - Config auto-creates config.yaml on first run with detected local IP - Default PIN and token generated randomly on first startup - Added pin_ttl setting (default 300s) - Updated build_exe.py for onefile exe with settings assets bundled - Added start.bat and start.sh for easy development startup
This commit is contained in:
+1
-2
@@ -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 =
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@
|
||||
<button id="settings-access-btn" data-i18n="access">Access</button>
|
||||
<p id="settings-error" class="error"></p>
|
||||
|
||||
<div id="settings-content" style="display: none;">
|
||||
<div id="settings-content" style="display: none">
|
||||
<section class="panel">
|
||||
<h2 data-i18n="dashboard_pin_label">Dashboard PIN</h2>
|
||||
<input
|
||||
|
||||
+1
-1
@@ -8,6 +8,6 @@ DASHBOARD_PIN=1234
|
||||
API_TOKEN=your-secret-token-here
|
||||
|
||||
# Server settings
|
||||
HOST=0.0.0.0
|
||||
# HOST defaults to auto-detected local IP (set to override)
|
||||
PORT=8921
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
+6
-1
@@ -36,7 +36,10 @@ a = Analysis(
|
||||
["tray_wrapper.py"],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
datas=[
|
||||
("settings.html", "."),
|
||||
("settings.css", "."),
|
||||
],
|
||||
hiddenimports=[
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
@@ -48,6 +51,7 @@ a = Analysis(
|
||||
"notifications",
|
||||
"telemetry",
|
||||
"main",
|
||||
"yaml",
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={{}},
|
||||
@@ -81,6 +85,7 @@ exe = EXE(
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=None,
|
||||
onefile=True,
|
||||
)
|
||||
'''
|
||||
|
||||
|
||||
+72
-6
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -19,6 +21,7 @@ logger = logging.getLogger("paperdash.config")
|
||||
# Try to import yaml, fall back to simple parser
|
||||
try:
|
||||
import yaml
|
||||
|
||||
HAS_YAML = True
|
||||
except ImportError:
|
||||
HAS_YAML = False
|
||||
@@ -30,16 +33,20 @@ class Config:
|
||||
def __init__(self) -> 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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+69
-5
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PaperDash Settings</title>
|
||||
<link rel="stylesheet" href="settings.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>PaperDash Settings</h1>
|
||||
<p class="subtitle">Changes take effect on next restart</p>
|
||||
|
||||
<form id="settings-form">
|
||||
<div class="field">
|
||||
<label for="dashboard-pin">Dashboard PIN</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dashboard-pin"
|
||||
maxlength="4"
|
||||
placeholder="****"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="api-token">API Token</label>
|
||||
<input
|
||||
type="password"
|
||||
id="api-token"
|
||||
placeholder="Bearer token for scripts"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="host">Host</label>
|
||||
<input type="text" id="host" placeholder="Local IP address" />
|
||||
<p class="hint">Address to bind the server to</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="port">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
id="port"
|
||||
placeholder="8921"
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="log-level">Log Level</label>
|
||||
<select id="log-level">
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" id="reset-btn">Reset</button>
|
||||
<button type="submit" id="save-btn">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById("settings-form");
|
||||
const statusEl = document.getElementById("status");
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch("/api/v1/settings");
|
||||
if (!res.ok) throw new Error("Failed to load settings");
|
||||
const data = await res.json();
|
||||
document.getElementById("dashboard-pin").value =
|
||||
data.dashboard_pin || "";
|
||||
document.getElementById("api-token").value = data.api_token || "";
|
||||
document.getElementById("host").value = data.host || "";
|
||||
document.getElementById("port").value = data.port || 8921;
|
||||
document.getElementById("log-level").value = data.log_level || "INFO";
|
||||
} catch (err) {
|
||||
showStatus("Failed to load settings: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(e) {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
dashboard_pin: document.getElementById("dashboard-pin").value.trim(),
|
||||
api_token: document.getElementById("api-token").value.trim(),
|
||||
host: document.getElementById("host").value.trim(),
|
||||
port: parseInt(document.getElementById("port").value, 10),
|
||||
log_level: document.getElementById("log-level").value,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save settings");
|
||||
showStatus("Settings saved. Restart the server to apply.", "success");
|
||||
} catch (err) {
|
||||
showStatus("Failed to save: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function resetSettings() {
|
||||
document.getElementById("dashboard-pin").value = "";
|
||||
document.getElementById("api-token").value = "";
|
||||
document.getElementById("host").value = "";
|
||||
document.getElementById("port").value = 8921;
|
||||
document.getElementById("log-level").value = "INFO";
|
||||
hideStatus();
|
||||
}
|
||||
|
||||
function showStatus(msg, type) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = "status " + type;
|
||||
if (type === "success") {
|
||||
setTimeout(hideStatus, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
statusEl.className = "status";
|
||||
statusEl.style.display = "none";
|
||||
}
|
||||
|
||||
form.addEventListener("submit", saveSettings);
|
||||
document
|
||||
.getElementById("reset-btn")
|
||||
.addEventListener("click", resetSettings);
|
||||
loadSettings();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
@@ -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
|
||||
+35
-3
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user