diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/README.md b/README.md index a7ee902..bb4e578 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,70 @@ # frontend-app-windows -Windows notification app that pulls messages \ No newline at end of file +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. \ No newline at end of file diff --git a/assets/images/icon.png b/assets/images/icon.png new file mode 100644 index 0000000..d113773 Binary files /dev/null and b/assets/images/icon.png differ diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..2e974ac --- /dev/null +++ b/build.ps1 @@ -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" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1dca888 --- /dev/null +++ b/pyproject.toml @@ -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" \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..97d7b98 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pyinstaller +ruff diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2923b9f --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/src/api_client.py b/src/api_client.py new file mode 100644 index 0000000..ecb063d --- /dev/null +++ b/src/api_client.py @@ -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 \ No newline at end of file diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..d73f87a --- /dev/null +++ b/src/config.py @@ -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 diff --git a/src/credentials.py b/src/credentials.py new file mode 100644 index 0000000..cef87ee --- /dev/null +++ b/src/credentials.py @@ -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 diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..64b821a --- /dev/null +++ b/src/main.py @@ -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()) \ No newline at end of file diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..9d310bd --- /dev/null +++ b/src/models.py @@ -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 \ No newline at end of file diff --git a/src/notifier.py b/src/notifier.py new file mode 100644 index 0000000..1654277 --- /dev/null +++ b/src/notifier.py @@ -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) \ No newline at end of file diff --git a/src/poller.py b/src/poller.py new file mode 100644 index 0000000..6c27023 --- /dev/null +++ b/src/poller.py @@ -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, + ) + ) diff --git a/src/resources.py b/src/resources.py new file mode 100644 index 0000000..15eb0e3 --- /dev/null +++ b/src/resources.py @@ -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") \ No newline at end of file diff --git a/src/startup.py b/src/startup.py new file mode 100644 index 0000000..a7db87f --- /dev/null +++ b/src/startup.py @@ -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 diff --git a/src/tray.py b/src/tray.py new file mode 100644 index 0000000..933298c --- /dev/null +++ b/src/tray.py @@ -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() diff --git a/tools/mock_server.py b/tools/mock_server.py new file mode 100644 index 0000000..f4e929c --- /dev/null +++ b/tools/mock_server.py @@ -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() \ No newline at end of file