Initial commit
This commit is contained in:
1 parent
fd83ad9ccc
commit
2ce845c51d
18 files changed
+686
-1
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -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 |
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pyinstaller
|
||||||
|
ruff
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user