Add settings page, PIN manager, and auto-config
Build Android APK / build (push) Failing after 4m21s
Build Server .exe / build (push) Failing after 2m34s

- 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:
Kareem Horstink
2026-07-03 08:21:46 +00:00
parent 4e3befbb82
commit 331de4bc9e
12 changed files with 652 additions and 19 deletions
+1 -2
View File
@@ -515,8 +515,7 @@
return; return;
} }
SETTINGS_STATUS.textContent = SETTINGS_STATUS.textContent = strings.settings_saved || "Settings saved";
strings.settings_saved || "Settings saved";
currentApiToken = token; currentApiToken = token;
} catch (err) { } catch (err) {
SETTINGS_STATUS.textContent = SETTINGS_STATUS.textContent =
+1 -1
View File
@@ -121,7 +121,7 @@
<button id="settings-access-btn" data-i18n="access">Access</button> <button id="settings-access-btn" data-i18n="access">Access</button>
<p id="settings-error" class="error"></p> <p id="settings-error" class="error"></p>
<div id="settings-content" style="display: none;"> <div id="settings-content" style="display: none">
<section class="panel"> <section class="panel">
<h2 data-i18n="dashboard_pin_label">Dashboard PIN</h2> <h2 data-i18n="dashboard_pin_label">Dashboard PIN</h2>
<input <input
+1 -1
View File
@@ -8,6 +8,6 @@ DASHBOARD_PIN=1234
API_TOKEN=your-secret-token-here API_TOKEN=your-secret-token-here
# Server settings # Server settings
HOST=0.0.0.0 # HOST defaults to auto-detected local IP (set to override)
PORT=8921 PORT=8921
LOG_LEVEL=INFO LOG_LEVEL=INFO
+6 -1
View File
@@ -36,7 +36,10 @@ a = Analysis(
["tray_wrapper.py"], ["tray_wrapper.py"],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[], datas=[
("settings.html", "."),
("settings.css", "."),
],
hiddenimports=[ hiddenimports=[
"fastapi", "fastapi",
"uvicorn", "uvicorn",
@@ -48,6 +51,7 @@ a = Analysis(
"notifications", "notifications",
"telemetry", "telemetry",
"main", "main",
"yaml",
], ],
hookspath=[], hookspath=[],
hooksconfig={{}}, hooksconfig={{}},
@@ -81,6 +85,7 @@ exe = EXE(
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
icon=None, icon=None,
onefile=True,
) )
''' '''
+72 -6
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import logging import logging
import os import os
import secrets
import socket
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -19,6 +21,7 @@ logger = logging.getLogger("paperdash.config")
# Try to import yaml, fall back to simple parser # Try to import yaml, fall back to simple parser
try: try:
import yaml import yaml
HAS_YAML = True HAS_YAML = True
except ImportError: except ImportError:
HAS_YAML = False HAS_YAML = False
@@ -30,16 +33,20 @@ class Config:
def __init__(self) -> None: def __init__(self) -> None:
self.dashboard_pin: str = "" self.dashboard_pin: str = ""
self.api_token: str = "" self.api_token: str = ""
self.host: str = "0.0.0.0" self.host: str = ""
self.port: int = 8921 self.port: int = 8921
self.log_level: str = "INFO" self.log_level: str = "INFO"
self.pin_ttl: int = 300 # 5 minutes in seconds
def load(self) -> None: def load(self) -> None:
"""Load configuration from all sources.""" """Load configuration from all sources."""
# 1. Load config.yaml # 1. Load config.yaml (or create if missing)
config_file = self._find_config_file() config_file = self._find_config_file()
if config_file and config_file.exists(): if config_file and config_file.exists():
self._load_yaml(config_file) self._load_yaml(config_file)
else:
# First run — create default config
self._create_default_config()
# 2. Load .env file # 2. Load .env file
env_file = self._find_env_file() env_file = self._find_env_file()
@@ -53,11 +60,60 @@ class Config:
if not self.dashboard_pin: if not self.dashboard_pin:
logger.warning("DASHBOARD_PIN not set — WebSocket auth will be disabled") logger.warning("DASHBOARD_PIN not set — WebSocket auth will be disabled")
if not self.api_token: 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: def _get_base_path(self) -> Path:
"""Get base path (exe directory or cwd).""" """Get base path (exe directory or cwd)."""
if getattr(sys, 'frozen', False): if getattr(sys, "frozen", False):
return Path(sys.executable).parent return Path(sys.executable).parent
return Path.cwd() return Path.cwd()
@@ -91,9 +147,10 @@ class Config:
self.dashboard_pin = data.get("dashboard_pin", self.dashboard_pin) self.dashboard_pin = data.get("dashboard_pin", self.dashboard_pin)
self.api_token = data.get("api_token", self.api_token) 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.port = data.get("port", self.port)
self.log_level = data.get("log_level", self.log_level).upper() 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}") logger.info(f"Loaded config from {path}")
except Exception as e: except Exception as e:
@@ -121,6 +178,10 @@ class Config:
self.port = int(value) self.port = int(value)
case "LOG_LEVEL": case "LOG_LEVEL":
self.log_level = value.upper() self.log_level = value.upper()
case "PIN_TTL":
self.pin_ttl = int(value)
case _:
pass
logger.info(f"Loaded config from {path}") logger.info(f"Loaded config from {path}")
except Exception as e: except Exception as e:
@@ -130,12 +191,16 @@ class Config:
"""Load configuration from environment variables (highest priority).""" """Load configuration from environment variables (highest priority)."""
self.dashboard_pin = os.environ.get("DASHBOARD_PIN", self.dashboard_pin) self.dashboard_pin = os.environ.get("DASHBOARD_PIN", self.dashboard_pin)
self.api_token = os.environ.get("API_TOKEN", self.api_token) 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: try:
self.port = int(os.environ.get("PORT", self.port)) self.port = int(os.environ.get("PORT", self.port))
except ValueError: except ValueError:
logger.warning("Invalid PORT environment variable") logger.warning("Invalid PORT environment variable")
self.log_level = os.environ.get("LOG_LEVEL", self.log_level).upper() 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: def save(self, path: Path | None = None) -> None:
"""Save configuration to YAML file.""" """Save configuration to YAML file."""
@@ -169,6 +234,7 @@ class Config:
"host": self.host, "host": self.host,
"port": self.port, "port": self.port,
"log_level": self.log_level, "log_level": self.log_level,
"pin_ttl": self.pin_ttl,
} }
+69 -5
View File
@@ -12,7 +12,14 @@ import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any 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.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -20,6 +27,7 @@ from .audio_mixer import AudioMixerManager
from .config import config from .config import config
from .media import MediaManager from .media import MediaManager
from .notifications import NotificationManager, Notification from .notifications import NotificationManager, Notification
from .pin_manager import PinManager
from .telemetry import TelemetryManager from .telemetry import TelemetryManager
logger = logging.getLogger("paperdash") logger = logging.getLogger("paperdash")
@@ -37,6 +45,7 @@ def check_subsystems() -> dict[str, bool]:
# Check Voicemeeter # Check Voicemeeter
try: try:
import voicemeeter_api # noqa: F401 # type: ignore import voicemeeter_api # noqa: F401 # type: ignore
results["voicemeeter"] = True results["voicemeeter"] = True
except ImportError: except ImportError:
results["voicemeeter"] = False results["voicemeeter"] = False
@@ -44,6 +53,7 @@ def check_subsystems() -> dict[str, bool]:
# Check Windows Audio (pycaw) # Check Windows Audio (pycaw)
try: try:
import pycaw # noqa: F401 # type: ignore import pycaw # noqa: F401 # type: ignore
results["windows_audio"] = True results["windows_audio"] = True
except ImportError: except ImportError:
results["windows_audio"] = False results["windows_audio"] = False
@@ -51,6 +61,7 @@ def check_subsystems() -> dict[str, bool]:
# Check Windows Media (winsdk) # Check Windows Media (winsdk)
try: try:
import winsdk # noqa: F401 # type: ignore import winsdk # noqa: F401 # type: ignore
results["windows_media"] = True results["windows_media"] = True
except ImportError: except ImportError:
results["windows_media"] = False results["windows_media"] = False
@@ -58,6 +69,7 @@ def check_subsystems() -> dict[str, bool]:
# Check HWiNFO (pyhwinfo) # Check HWiNFO (pyhwinfo)
try: try:
import pyhwinfo # noqa: F401 # type: ignore import pyhwinfo # noqa: F401 # type: ignore
results["hwinfo"] = True results["hwinfo"] = True
except ImportError: except ImportError:
results["hwinfo"] = False results["hwinfo"] = False
@@ -103,6 +115,12 @@ async def lifespan(app: FastAPI):
app.state.notifications = NotificationManager() app.state.notifications = NotificationManager()
app.state.telemetry = TelemetryManager() 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 # Start background tasks
app.state.tasks = [ app.state.tasks = [
asyncio.create_task(app.state.media.run()), asyncio.create_task(app.state.media.run()),
@@ -110,6 +128,9 @@ async def lifespan(app: FastAPI):
asyncio.create_task(app.state.telemetry.run()), asyncio.create_task(app.state.telemetry.run()),
] ]
# Start PIN cleanup loop
app.state.pin_manager.start_cleanup_loop()
logger.info("PaperDash server started") logger.info("PaperDash server started")
yield yield
@@ -117,6 +138,11 @@ async def lifespan(app: FastAPI):
for task in app.state.tasks: for task in app.state.tasks:
task.cancel() task.cancel()
await asyncio.gather(*app.state.tasks, return_exceptions=True) 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") logger.info("PaperDash server stopped")
@@ -188,6 +214,11 @@ async def websocket_endpoint(
pin: str = Query(..., description="Dashboard PIN"), pin: str = Query(..., description="Dashboard PIN"),
): ):
"""WebSocket endpoint for the E-Ink dashboard client.""" """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): if await ws_manager.connect(websocket, pin):
try: try:
# Keep connection alive; the server pushes state updates. # 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: class SettingsUpdate:
"""Settings update payload.""" """Settings update payload."""
@@ -291,21 +328,23 @@ class SettingsUpdate:
@app.get("/api/v1/settings") @app.get("/api/v1/settings")
async def get_settings( async def get_settings(
request: Request,
authorization: str | None = None, authorization: str | None = None,
): ):
"""Get current settings (requires API token).""" """Get current settings (requires API token or localhost)."""
if not _verify_token(authorization): if not _verify_token(authorization) and not _is_localhost(request):
raise HTTPException(status_code=401, detail="Invalid or missing API token") raise HTTPException(status_code=401, detail="Invalid or missing API token")
return config.to_dict() return config.to_dict()
@app.post("/api/v1/settings") @app.post("/api/v1/settings")
async def update_settings( async def update_settings(
request: Request,
settings: SettingsUpdate, settings: SettingsUpdate,
authorization: str | None = None, authorization: str | None = None,
): ):
"""Update settings (requires API token).""" """Update settings (requires API token or localhost)."""
if not _verify_token(authorization): if not _verify_token(authorization) and not _is_localhost(request):
raise HTTPException(status_code=401, detail="Invalid or missing API token") raise HTTPException(status_code=401, detail="Invalid or missing API token")
if settings.dashboard_pin is not None: 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 # Health
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+141
View File
@@ -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
)
+117
View File
@@ -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;
}
+142
View File
@@ -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>
+33
View File
@@ -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
+34
View File
@@ -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
View File
@@ -8,7 +8,10 @@ from __future__ import annotations
import logging import logging
import multiprocessing import multiprocessing
import os
import signal import signal
import subprocess
import sys
import time import time
import pystray import pystray
@@ -42,9 +45,9 @@ def _run_server() -> None:
uvicorn.run( uvicorn.run(
"main:app", "main:app",
host="0.0.0.0", host=CONFIG_HOST,
port=8921, port=CONFIG_PORT,
log_level="info", log_level=CONFIG_LOG_LEVEL,
reload=False, reload=False,
) )
@@ -55,6 +58,21 @@ def _on_quit(icon: pystray.Icon, item: pystray.MenuItem) -> None:
icon.stop() 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: def main() -> None:
"""Entry point for the compiled .exe.""" """Entry point for the compiled .exe."""
logger.info("PaperDash starting...") logger.info("PaperDash starting...")
@@ -72,6 +90,8 @@ def main() -> None:
menu = pystray.Menu( menu = pystray.Menu(
pystray.MenuItem("PaperDash", pystray.MenuItem.default, enabled=False), pystray.MenuItem("PaperDash", pystray.MenuItem.default, enabled=False),
pystray.Menu.Separator(), pystray.Menu.Separator(),
pystray.MenuItem("Settings", _on_settings),
pystray.Menu.Separator(),
pystray.MenuItem("Quit", _on_quit), pystray.MenuItem("Quit", _on_quit),
) )
@@ -100,5 +120,17 @@ def main() -> None:
server_process.join(timeout=5) 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__": if __name__ == "__main__":
main() main()