feat: main entry point — FastAPI + pystray tray + SIGUSR1 via uvicorn loop capture

This commit is contained in:
2026-04-01 02:29:52 +02:00
parent b62c3d6604
commit 4ec9c56812
+155
View File
@@ -0,0 +1,155 @@
import asyncio
import os
import signal
import threading
import webbrowser
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
import pystray
from PIL import Image, ImageDraw
from api.router import router
from api.state import state, Status
from config import load as load_config
# ── FastAPI ────────────────────────────────────────────────────────────────────
app = FastAPI(title="tüit Transkriptor")
app.include_router(router)
FRONTEND_DIR = Path(__file__).parent / "frontend"
@app.get("/")
async def index():
return FileResponse(str(FRONTEND_DIR / "index.html"))
@app.get("/app.js")
async def appjs():
return FileResponse(str(FRONTEND_DIR / "app.js"))
# ── PID file ───────────────────────────────────────────────────────────────────
def write_pid(pid_path: str):
os.makedirs(os.path.dirname(pid_path), exist_ok=True)
Path(pid_path).write_text(str(os.getpid()))
def remove_pid(pid_path: str):
try:
os.unlink(pid_path)
except FileNotFoundError:
pass
# ── SIGUSR1 → toggle ──────────────────────────────────────────────────────────
# We capture uvicorn's event loop after it starts, so the signal handler can
# schedule the toggle coroutine in the correct loop — not a separate one.
_uvicorn_loop: asyncio.AbstractEventLoop | None = None
def _sigusr1_handler(signum, frame):
if _uvicorn_loop:
_uvicorn_loop.call_soon_threadsafe(
lambda: asyncio.ensure_future(_async_toggle(), loop=_uvicorn_loop)
)
async def _async_toggle():
from api.router import toggle_recording
# Toggle without a real user dependency — use guest context for signal-triggered recordings.
from api.router import _guest_user
await toggle_recording(user=_guest_user())
# ── Tray ───────────────────────────────────────────────────────────────────────
def _make_icon(recording: bool = False) -> Image.Image:
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
color = (218, 37, 28, 255) if recording else (80, 80, 80, 255)
draw.ellipse([8, 8, 56, 56], fill=color)
return img
def run_tray(port: int):
icon = pystray.Icon(
"tueit-transcriber",
_make_icon(False),
"tüit Transkriptor",
menu=pystray.Menu(
pystray.MenuItem("Aufnahme starten/stoppen", lambda i, it: (
_uvicorn_loop and _uvicorn_loop.call_soon_threadsafe(
lambda: asyncio.ensure_future(_async_toggle(), loop=_uvicorn_loop)
)
), default=True),
pystray.MenuItem("Öffnen", lambda i, it: webbrowser.open(f"http://localhost:{port}")),
pystray.MenuItem("Beenden", lambda i, it: (icon.stop(), os._exit(0))),
),
)
def update_icon(s):
icon.icon = _make_icon(s.status == Status.RECORDING)
state.subscribe(update_icon)
icon.run()
# ── Server ─────────────────────────────────────────────────────────────────────
class _LoopCapture(uvicorn.Server):
"""Subclass that exposes its event loop for the SIGUSR1 handler."""
def install_signal_handlers(self):
# Disable uvicorn's own signal handlers so our SIGUSR1 handler works.
pass
async def startup(self, sockets=None):
global _uvicorn_loop
_uvicorn_loop = asyncio.get_running_loop()
await super().startup(sockets=sockets)
def run_server(config: uvicorn.Config):
server = _LoopCapture(config)
server.run()
# ── Entrypoint ─────────────────────────────────────────────────────────────────
if __name__ == "__main__":
from auth import setup_wizard, has_users
if not has_users():
setup_wizard()
cfg = load_config()
port = cfg["server"]["port"]
host = cfg.get("network", {}).get("host", "127.0.0.1")
pid_path = cfg.get("pid_file", os.path.expanduser("~/.local/run/tueit-transcriber.pid"))
write_pid(pid_path)
signal.signal(signal.SIGUSR1, _sigusr1_handler)
uvicorn_cfg = uvicorn.Config(app, host=host, port=port, log_level="warning")
server_thread = threading.Thread(target=run_server, args=(uvicorn_cfg,), daemon=True)
server_thread.start()
# Wait until uvicorn has captured its loop
import time
for _ in range(50):
if _uvicorn_loop is not None:
break
time.sleep(0.1)
webbrowser.open(f"http://localhost:{port}")
try:
run_tray(port)
finally:
remove_pid(pid_path)