Initial commit

This commit is contained in:
florian committed 2026-10-14 15:06:34 +02:00
1 parent fd83ad9ccc
commit 2ce845c51d
18 files changed
+686 -1

No files matched your search

+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+68 -1
View File
@@ -1,3 +1,70 @@
# frontend-app-windows # frontend-app-windows
Windows notification app that pulls messages Windows tray app that pulls notifications from the backend and shows them as toasts.
Clicking a toast opens its link. Requires Windows 10/11 and Python 3.12.
## How it works
- A background thread polls `GET /internal/pull-notifications` every 5 minutes (every 30 s while the server reports `has_more`, with backoff on errors).
- The newest 5 messages pop up; older ones go silently to the notification bar. One extra toast says how many more are there and whether more are waiting on the server.
- The API key is stored in Windows Credential Manager.
## Setup
```powershell
py -3.12 -m venv .venv
.\.venv\Scripts\activate
pip install -r requirements-dev.txt
```
## Run from source
```powershell
$env:NOTIFIER_API_KEY = "your-key" # or skip: the app asks on first run
python src\main.py # tray app
python src\main.py --once # one poll, then exit
python src\main.py --set-key # (re)enter the API key
```
## Test without the real backend
The mock server always returns 10 messages with `has_more: true`.
```powershell
# terminal 1
python tools\mock_server.py
# terminal 2
$env:NOTIFIER_URL = "http://127.0.0.1:8000/internal/pull-notifications"
$env:NOTIFIER_API_KEY = "dev" # any value, the mock ignores it
python src\main.py --once
```
Without `--once` the tray app polls again after 30 s, because the mock always says `has_more`.
## Configuration
| Variable | Default | Purpose |
|----------|---------|---------|
| `NOTIFIER_URL` | `https://notifier.gansejunge.com/internal/pull-notifications` | Endpoint |
| `NOTIFIER_API_KEY` | (Credential Manager) | Overrides the stored key, for development |
| `NOTIFIER_API_KEY_HEADER` | `X-API-Key` | Header name the backend expects |
| `NOTIFIER_POLL_SECONDS` | `300` | Normal polling interval |
Other settings (app name, backoff, `has_more` delay) are in `src/config.py`.
## Build the exe
```powershell
.\build.ps1 -Console # first build: keeps a console so startup errors are visible
.\build.ps1 # release build, no console
```
Output: `dist\MyApp\MyApp.exe`. Ship the whole `dist\MyApp\` folder (it is `--onedir`, not a single file).
Test the built exe, not just the source: missing PyInstaller imports only show up there.
## Runtime notes
- **Logs:** `%LOCALAPPDATA%\Notifier\app.log`
- **Tray menu:** Check now, Set API key, Start with Windows (built exe only), Quit.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+21
View File
@@ -0,0 +1,21 @@
# Build the exe. Run from the project root with the venv activated:
# .\build.ps1 windowed build (no console), for release
# .\build.ps1 -Console keeps a console window so startup errors are visible
param([switch]$Console)
$ErrorActionPreference = "Stop"
$windowMode = if ($Console) { "--console" } else { "--windowed" }
$appName = python -c "import sys; sys.path.insert(0, 'src'); from config import APP_NAME; print(APP_NAME)"
if (-not $appName) { throw "Could not read APP_NAME from src\config.py" }
pyinstaller --noconfirm --clean --onedir $windowMode `
--name $appName `
--icon assets\images\icon.png `
--add-data "assets;assets" `
--hidden-import keyring.backends.Windows `
--hidden-import pystray._win32 `
src\main.py
Write-Host "Built: dist\$appName\$appName.exe"
+17
View File
@@ -0,0 +1,17 @@
[tool.ruff]
line-length = 120
target-version = "py312"
src = ["src"]
[tool.ruff.lint]
select = [
"E", "F", # pycodestyle errors, pyflakes (unused imports/variables, undefined names)
"I", # import sorting (replaces isort)
"B", # bugbear: likely bugs
"ASYNC", # async mistakes
"RUF006", # un-referenced asyncio.create_task (tasks can be garbage-collected)
]
ignore = ["E501"]
[tool.ruff.format]
indent-style = "tab"
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pyinstaller
ruff
+22
View File
@@ -0,0 +1,22 @@
anyio==4.15.1
certifi==2026.7.22
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.20
jaraco.classes==3.4.0
jaraco.context==6.1.2
jaraco.functools==4.6.0
keyring==25.7.0
more-itertools==11.2.0
pillow==12.3.0
pystray==0.19.5
pywin32-ctypes==0.2.3
six==1.17.0
typing_extensions==4.16.0
Windows-Toasts==1.3.1
winrt-runtime==3.2.1
winrt-Windows.Data.Xml.Dom==3.2.1
winrt-Windows.Foundation==3.2.1
winrt-Windows.Foundation.Collections==3.2.1
winrt-Windows.UI.Notifications==3.2.1
+51
View File
@@ -0,0 +1,51 @@
import os
import httpx
from credentials import get_api_key
API_URL = os.getenv("NOTIFIER_URL", "https://notifier.gansejunge.com/internal/pull-notifications")
# Longer than the backend's own timeouts: if the app gives up while the backend is
# still draining the queue, those (already acked) messages are lost.
TIMEOUT = httpx.Timeout(30.0, connect=5.0)
class FetchError(Exception):
"""The pull request failed or returned something unusable."""
class NoApiKeyError(FetchError):
"""No API key has been stored yet."""
class AuthError(FetchError):
"""The server rejected the API key (401/403)."""
def fetch_notifications() -> dict:
"""GET the pull endpoint and return the decoded response ({"messages", "count", "has_more"}).
Raises FetchError (or a subclass) on a missing key, network errors, bad status codes
and malformed responses.
"""
api_key = get_api_key()
if not api_key:
raise NoApiKeyError("No API key set")
headers = {"X-API-Key": api_key}
try:
response = httpx.get(API_URL, headers=headers, timeout=TIMEOUT)
response.raise_for_status()
payload = response.json()
except httpx.HTTPStatusError as e:
status = e.response.status_code
if status in (401, 403):
raise AuthError(f"Server rejected the API key ({status})") from e
raise FetchError(f"Server returned {status}") from e
except (httpx.HTTPError, ValueError) as e:
raise FetchError(f"Request failed: {e!r}") from e
if not isinstance(payload, dict) or not isinstance(payload.get("messages"), list):
raise FetchError("Unexpected response format")
return payload
+8
View File
@@ -0,0 +1,8 @@
import os
APP_NAME = "Notifier"
POLL_INTERVAL = int(os.getenv("NOTIFIER_POLL_SECONDS", "300"))
MORE_WAITING_DELAY = 30 # poll again this soon when the server says has_more
BACKOFF_START = 30 # first retry delay after a failed poll, doubled on each further failure
BACKOFF_MAX = 900
+59
View File
@@ -0,0 +1,59 @@
import logging
import os
import keyring
from keyring.errors import KeyringError, PasswordDeleteError
from config import APP_NAME
logger = logging.getLogger(__name__)
SERVICE_NAME = APP_NAME
ACCOUNT_NAME = "api_key"
def get_api_key() -> str | None:
"""Return the stored API key, or None. The NOTIFIER_API_KEY env var overrides it (development)."""
env_key = os.getenv("NOTIFIER_API_KEY")
if env_key:
return env_key
try:
return keyring.get_password(SERVICE_NAME, ACCOUNT_NAME)
except KeyringError as e:
logger.error("Could not read the API key from the credential store: %r", e)
return None
def set_api_key(api_key: str) -> None:
"""Store the key in Windows Credential Manager (encrypted for the current Windows user)."""
keyring.set_password(SERVICE_NAME, ACCOUNT_NAME, api_key.strip())
def delete_api_key() -> None:
try:
keyring.delete_password(SERVICE_NAME, ACCOUNT_NAME)
except PasswordDeleteError:
pass # nothing stored
def prompt_for_api_key() -> bool:
"""Ask for the key in a small masked dialog and store it. Returns True if a key was saved.
Must run on the main thread of its own process (see the --set-key flag in main.py).
"""
import tkinter as tk
from tkinter import simpledialog
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
try:
key = simpledialog.askstring(APP_NAME, "Paste your API key:", show="*", parent=root)
finally:
root.destroy()
if not key or not key.strip():
return False
set_api_key(key)
logger.info("API key saved")
return True
+79
View File
@@ -0,0 +1,79 @@
import ctypes
import logging
import logging.handlers
import os
import sys
from pathlib import Path
from api_client import FetchError, fetch_notifications
from config import APP_NAME
from credentials import get_api_key, prompt_for_api_key
from notifier import show_notifications
from poller import Poller
from tray import run_tray
logger = logging.getLogger(__name__)
ERROR_ALREADY_EXISTS = 183
_mutex_handle = None # keep a reference so the mutex lives as long as the process
def setup_logging() -> None:
"""Log to a file: the windowed exe has no console, so stdout/stderr may not exist."""
log_dir = Path(os.getenv("LOCALAPPDATA", str(Path.home()))) / APP_NAME
log_dir.mkdir(parents=True, exist_ok=True)
handlers: list[logging.Handler] = [
logging.handlers.RotatingFileHandler(log_dir / "app.log", maxBytes=500_000, backupCount=2, encoding="utf-8")
]
if sys.stderr is not None:
handlers.append(logging.StreamHandler())
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
handlers=handlers,
)
def acquire_single_instance() -> bool:
"""Return False if another instance of the app is already running for this user."""
global _mutex_handle
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateMutexW.restype = ctypes.c_void_p
_mutex_handle = kernel32.CreateMutexW(None, False, f"Local\\{APP_NAME}-Notifier")
return ctypes.get_last_error() != ERROR_ALREADY_EXISTS
def main() -> int:
setup_logging()
args = sys.argv[1:]
if "--set-key" in args:
return 0 if prompt_for_api_key() else 1
if "--once" in args: # one poll, then exit
try:
payload = fetch_notifications()
except FetchError as e:
logger.error("Could not fetch notifications: %s", e)
return 1
show_notifications(payload)
return 0
if not acquire_single_instance():
logger.info("Already running, exiting")
return 0
if get_api_key() is None:
prompt_for_api_key()
poller = Poller()
poller.start()
try:
run_tray(poller)
finally:
poller.stop()
return 0
if __name__ == "__main__":
sys.exit(main())
+34
View File
@@ -0,0 +1,34 @@
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class Notification:
title: str
body: str
category: str
timestamp: int
link: str | None = None
@classmethod
def from_dict(cls, data: dict) -> "Notification":
"""Build a Notification from a raw message dict. Raises KeyError/ValueError/TypeError if malformed."""
return cls(
title=str(data["title"]),
body=str(data["body"]),
category=str(data.get("category", "")),
timestamp=int(data.get("timestamp", 0)),
link=data.get("link") or None,
)
def parse_messages(payload: dict) -> list[Notification]:
"""Turn a pull response into Notifications, newest first. Malformed messages are skipped and logged."""
notifications: list[Notification] = []
for raw in payload.get("messages", []):
try:
notifications.append(Notification.from_dict(raw))
except (KeyError, TypeError, ValueError) as e:
logger.warning("Skipping malformed message: %r", e)
notifications.sort(key=lambda n: n.timestamp, reverse=True)
return notifications
+82
View File
@@ -0,0 +1,82 @@
import functools
import logging
from urllib.parse import urlparse
from windows_toasts import (
InteractableWindowsToaster,
Toast,
ToastDisplayImage,
ToastImagePosition,
)
from config import APP_NAME
from models import Notification, parse_messages
from resources import ICON_PATH
logger = logging.getLogger(__name__)
MAX_TOASTS_PER_BATCH = 3
MAX_LINK_CHARS = 70 # the toast cuts off long lines anyway; the full link is still opened
@functools.cache
def _get_toaster() -> InteractableWindowsToaster:
"""Create the toaster once and reuse it. The interactable variant is needed for click callbacks."""
return InteractableWindowsToaster(APP_NAME)
def _is_safe_link(link: str) -> bool:
parsed = urlparse(link)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _shorten(text: str, limit: int = MAX_LINK_CHARS) -> str:
return text if len(text) <= limit else text[: limit - 1] + "…"
def _show_summary(overflow: int, has_more: bool) -> None:
"""One extra toast: messages that went only to the notification bar and/or more waiting on the server."""
parts = []
if overflow:
noun = "message" if overflow == 1 else "messages"
parts.append(f"{overflow} more {noun} in the notification bar.")
if has_more:
parts.append("More notifications are on the way.")
show_notification(Notification(title="More notifications", body=" ".join(parts), category="", timestamp=0))
def show_notification(notification: Notification, popup: bool = True) -> None:
"""Show a toast with title, body and link."""
toast = Toast()
toast.suppress_popup = not popup
toast.text_fields = [notification.title, notification.body]
link = notification.link
if link:
if _is_safe_link(link):
toast.text_fields.append(_shorten(link))
toast.launch_action = link
else:
logger.warning("Ignoring non-http(s) link: %r", link)
if ICON_PATH.is_file():
toast.AddImage(
ToastDisplayImage.fromPath(str(ICON_PATH), position=ToastImagePosition.AppLogo)
)
else:
logger.warning("Icon not found at %s, showing toast without it", ICON_PATH)
toast.on_failed = lambda *args: logger.warning("Toast failed: %r", args)
_get_toaster().show_toast(toast)
def show_notifications(payload: dict) -> int:
"""Show a pull response: newest messages as popups, all in the notification bar. Returns the valid count."""
notifications = parse_messages(payload)
ordered = list(reversed(notifications))
overflow = max(0, len(ordered) - MAX_TOASTS_PER_BATCH)
for index, notification in enumerate(ordered):
show_notification(notification, popup=index >= overflow)
has_more = bool(payload.get("has_more"))
if overflow or has_more:
_show_summary(overflow, has_more)
return len(notifications)
+83
View File
@@ -0,0 +1,83 @@
import logging
import threading
from api_client import AuthError, FetchError, NoApiKeyError, fetch_notifications
from config import BACKOFF_MAX, BACKOFF_START, MORE_WAITING_DELAY, POLL_INTERVAL
from models import Notification
from notifier import show_notification, show_notifications
logger = logging.getLogger(__name__)
class Poller:
"""Pulls notifications in a background thread: every POLL_INTERVAL seconds, sooner when
the server has more waiting, and with exponential backoff while the server is unreachable."""
def __init__(self) -> None:
self._stop = threading.Event()
self._wake = threading.Event()
self._thread = threading.Thread(target=self._run, name="poller", daemon=True)
self._failures = 0
self._auth_notified = False
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._wake.set()
self._thread.join(timeout=5)
def poll_now(self) -> None:
"""Cut the current wait short and poll immediately (tray: "Check now")."""
self._wake.set()
def _run(self) -> None:
delay = 0.0 # poll right at startup
while not self._stop.is_set():
self._wake.wait(timeout=delay)
self._wake.clear()
if self._stop.is_set():
break
try:
delay = self._poll()
except Exception: # keep the thread alive whatever happens
logger.exception("Unexpected error while polling")
delay = POLL_INTERVAL
def _poll(self) -> float:
"""Do one poll and return the number of seconds to wait before the next one."""
try:
payload = fetch_notifications()
except NoApiKeyError:
logger.warning("No API key set, skipping poll")
return POLL_INTERVAL
except AuthError as e:
logger.error("%s", e)
self._notify_auth_failed_once()
return POLL_INTERVAL
except FetchError as e:
self._failures += 1
delay = min(BACKOFF_START * 2 ** (self._failures - 1), BACKOFF_MAX)
logger.error("Fetch failed (%d in a row), retrying in %ds: %s", self._failures, delay, e)
return delay
self._failures = 0
self._auth_notified = False
shown = show_notifications(payload)
has_more = bool(payload.get("has_more"))
logger.info("Showed %d notification(s), has_more=%s", shown, has_more)
return MORE_WAITING_DELAY if has_more else POLL_INTERVAL
def _notify_auth_failed_once(self) -> None:
if self._auth_notified:
return
self._auth_notified = True
show_notification(
Notification(
title="API key rejected",
body="Right-click the tray icon and choose 'Set API key...'.",
category="",
timestamp=0,
)
)
+14
View File
@@ -0,0 +1,14 @@
import sys
from pathlib import Path
def resource_path(*parts: str) -> Path:
"""Resolve a bundled resource, both in development and inside a PyInstaller build."""
if getattr(sys, "frozen", False):
base = Path(sys._MEIPASS) # type: ignore[attr-defined]
else:
base = Path(__file__).resolve().parent.parent # project root (src/ is one level down)
return base.joinpath(*parts)
ICON_PATH = resource_path("assets", "images", "icon.png")
+38
View File
@@ -0,0 +1,38 @@
import logging
import sys
import winreg
from config import APP_NAME
logger = logging.getLogger(__name__)
RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
def _command() -> str:
return f'"{sys.executable}"'
def is_enabled() -> bool:
"""True if the app is registered to start when the current user logs in."""
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
value, _ = winreg.QueryValueEx(key, APP_NAME)
except FileNotFoundError:
return False
return value == _command()
def set_enabled(enabled: bool) -> None:
"""Add or remove the per-user autostart entry. Only works for the built exe."""
if enabled and not getattr(sys, "frozen", False):
logger.warning("Autostart is only available in the built exe, not when running from source")
return
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, winreg.KEY_SET_VALUE) as key:
if enabled:
winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, _command())
else:
try:
winreg.DeleteValue(key, APP_NAME)
except FileNotFoundError:
pass
+46
View File
@@ -0,0 +1,46 @@
import subprocess
import sys
from pathlib import Path
import pystray
from PIL import Image
import startup
from config import APP_NAME
from poller import Poller
from resources import ICON_PATH
def _self_command(*args: str) -> list[str]:
"""Command line that starts this app again with extra arguments (exe or from source)."""
if getattr(sys, "frozen", False):
return [sys.executable, *args]
return [sys.executable, str(Path(__file__).resolve().parent / "main.py"), *args]
def run_tray(poller: Poller) -> None:
"""Show the tray icon and block until the user chooses Quit."""
def check_now(icon, item) -> None:
poller.poll_now()
def set_key(icon, item) -> None:
# Separate process: the dialog needs its own main thread, and the poller
# picks up the new key from the credential store on its next poll.
subprocess.Popen(_self_command("--set-key"))
def toggle_autostart(icon, item) -> None:
startup.set_enabled(not startup.is_enabled())
def quit_app(icon, item) -> None:
icon.stop()
menu = pystray.Menu(
pystray.MenuItem("Check now", check_now),
pystray.MenuItem("Set API key...", set_key),
pystray.MenuItem("Start with Windows", toggle_autostart, checked=lambda item: startup.is_enabled()),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Quit", quit_app),
)
icon = pystray.Icon(APP_NAME, Image.open(ICON_PATH), APP_NAME, menu)
icon.run()
+60
View File
@@ -0,0 +1,60 @@
"""Tiny stand-in for the backend, for testing the app without a real connection.
Run: python tools/mock_server.py
Then point the app at it: $env:NOTIFIER_URL = "http://127.0.0.1:8000/internal/pull-notifications"
Every request gets the same answer: MESSAGE_COUNT messages and has_more=true.
Uses only the standard library.
"""
import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST = "127.0.0.1"
PORT = 8000
PATH = "/internal/pull-notifications"
MESSAGE_COUNT = 10
HAS_MORE = True
def build_payload() -> dict:
now = int(time.time())
messages = [
{
"title": f"Test notification {i}",
"body": f"Test message {i} of {MESSAGE_COUNT}",
"category": "podcasts",
"timestamp": now - (MESSAGE_COUNT - i),
"link": "https://www.example.com",
}
for i in range(1, MESSAGE_COUNT + 1)
]
return {"messages": messages, "count": len(messages), "has_more": HAS_MORE}
class Handler(BaseHTTPRequestHandler):
def _respond(self) -> None:
if self.path != PATH:
self.send_error(404)
return
body = json.dumps(build_payload()).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
self._respond()
if __name__ == "__main__":
server = ThreadingHTTPServer((HOST, PORT), Handler)
print(f"Mock server on http://{HOST}:{PORT}{PATH} (Ctrl+C to stop)")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()