331de4bc9e
- 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
137 lines
3.7 KiB
Python
137 lines
3.7 KiB
Python
"""System tray wrapper for PaperDash.
|
|
|
|
Runs the FastAPI server in a background thread and displays a tray icon
|
|
with a quit option. This is the entry point for the compiled .exe.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import multiprocessing
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import pystray
|
|
from PIL import Image, ImageDraw
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger("paperdash.tray")
|
|
|
|
|
|
def create_icon() -> Image.Image:
|
|
"""Create a simple 16x16 tray icon."""
|
|
img = Image.new("RGB", (16, 16), color=(0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
# Dashboard frame
|
|
draw.rectangle([1, 1, 15, 15], outline=(255, 255, 255), width=1)
|
|
# Screen lines
|
|
draw.line([3, 3, 13, 3], fill=(255, 255, 255), width=1)
|
|
draw.line([3, 5, 13, 5], fill=(255, 255, 255), width=1)
|
|
draw.line([3, 7, 8, 7], fill=(255, 255, 255), width=1)
|
|
# Stand
|
|
draw.line([6, 15, 10, 15], fill=(255, 255, 255), width=1)
|
|
return img
|
|
|
|
|
|
def _run_server() -> None:
|
|
"""Run the FastAPI server in this process (for multiprocessing)."""
|
|
import uvicorn
|
|
|
|
uvicorn.run(
|
|
"main:app",
|
|
host=CONFIG_HOST,
|
|
port=CONFIG_PORT,
|
|
log_level=CONFIG_LOG_LEVEL,
|
|
reload=False,
|
|
)
|
|
|
|
|
|
def _on_quit(icon: pystray.Icon, item: pystray.MenuItem) -> None:
|
|
"""Handle quit from tray menu."""
|
|
logger.info("Quit requested from tray")
|
|
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...")
|
|
|
|
# Start server in a subprocess
|
|
server_process = multiprocessing.Process(target=_run_server, daemon=True)
|
|
server_process.start()
|
|
logger.info(f"Server process started (PID {server_process.pid})")
|
|
|
|
# Wait briefly for server to be ready
|
|
time.sleep(2)
|
|
|
|
# Create and run tray icon
|
|
icon_image = create_icon()
|
|
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),
|
|
)
|
|
|
|
icon = pystray.Icon(
|
|
name="PaperDash",
|
|
image=icon_image,
|
|
title="PaperDash",
|
|
menu=menu,
|
|
)
|
|
|
|
# Handle OS exit signals
|
|
def _signal_handler(signum, frame):
|
|
logger.info(f"Received signal {signum}, shutting down")
|
|
icon.stop()
|
|
server_process.terminate()
|
|
|
|
signal.signal(signal.SIGINT, _signal_handler)
|
|
signal.signal(signal.SIGTERM, _signal_handler)
|
|
|
|
logger.info("PaperDash running in system tray. Quit from notification area.")
|
|
icon.run()
|
|
|
|
# Cleanup
|
|
logger.info("Shutting down server process")
|
|
server_process.terminate()
|
|
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()
|