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
|
||||
|
||||
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