The design of the full desktop app was still bad, and the reason it stayed bad is that it had no business existing. Five pages — Timer, Tasks, Blocker, Report, Settings — each a second view of the *server's* data: a second place to be wrong, a second place to style, and a second place to keep in step with a web app that already does lists, rules and reports properly in a browser, on any device. So it is gone, along with 2,100 lines of QML and the controller surface that fed it. What is left is what only a program on your machine can do, plus the two surfaces worth having in front of you: * **the tray menu — unchanged**, as asked. All fourteen entries, plus "Settings…". "Open Kadence" now brings the countdown up, because that is what it meant all along. * **the mini timer**, which is now the whole of this app's own interface and so finally carries the house style on its own: warm surface, hairline instead of shadow, a letterspaced eyebrow, the progress lane as a rule, and exactly one filled button — whichever action is the obvious next one. The countdown is set in the display serif on a **fixed advance**, because Fraunces has no tabular figures and ships no `tnum`: each glyph now sits in a box as wide as the widest digit, instead of the clock shifting sideways on every tick. The phone client documents the same trap; the desktop had it too and nobody had looked. It also says OFFLINE when the backend is away — the count and the clock are both the server's, and this is the only window left to say it in. * **a settings dialog**, because a fresh install has to be pointed at a backend somewhere. One column, hairline sections: the backend and its token, where the mini timer sits, what this machine enforces, and what it does to the session. It edits `desktop.json` and nothing else — with one honest exception, view-only mode, which is a server-side flag the other clients have to honour too. Structurally, `app.py` no longer loads a QML root at all: a plain `QQmlEngine` with the mini timer as a component, and the dialog built on demand. Everything that used to ask "is the main window visible?" is simpler for not having one — the mini timer's auto-show rule is now just "an interval is running", which is what the setting always claimed. The window-only controller surface goes with the pages: tasks and task writes, stats, the daily report, the settings document, the presets, the cycle and interruption counters, and their worker jobs and API calls. Two things were kept deliberately: the task list is still read once a minute for one fact — which task is pinned, so "Start focus" from the tray attaches what you pinned this morning — and `timerEnabled` now comes from the timer state, which already carries `timer_enabled`, rather than from a second fetch of a document this app can no longer edit. Also: `--settings` and a `Settings` D-Bus verb; toasts go to the tray's balloon now that the snackbar is gone; the launcher entry starts the agent and grew a Settings action; a systemd user unit is shipped rather than only written at runtime; and both READMEs say what this piece is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
5.3 KiB
Python
145 lines
5.3 KiB
Python
"""HTTP client for the backend.
|
|
|
|
Deliberately stdlib-only (`urllib`): the desktop app should start with the
|
|
system Python on a fresh machine, and a pomodoro client does not need an async
|
|
HTTP stack to call eight endpoints.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from typing import Any
|
|
|
|
|
|
class ApiError(RuntimeError):
|
|
"""A call failed. The message is meant to be shown in the UI."""
|
|
|
|
def __init__(self, message: str, status: int = 0) -> None:
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
|
|
class Api:
|
|
def __init__(self, base_url: str, token: str = "", timeout: float = 10.0) -> None:
|
|
self.base_url = base_url.rstrip("/")
|
|
self.token = token
|
|
self.timeout = timeout
|
|
|
|
def configure(self, base_url: str, token: str) -> None:
|
|
self.base_url = base_url.rstrip("/")
|
|
self.token = token
|
|
|
|
# ---- plumbing ----------------------------------------------------------
|
|
|
|
def _request(self, method: str, path: str, payload: Any | None = None) -> Any:
|
|
url = f"{self.base_url}{path}"
|
|
data = None
|
|
headers = {"Accept": "application/json"}
|
|
if payload is not None:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
if self.token:
|
|
headers["X-API-Token"] = self.token
|
|
|
|
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
body = response.read()
|
|
return json.loads(body) if body else None
|
|
except urllib.error.HTTPError as exc:
|
|
detail = _detail(exc)
|
|
if exc.code == 401:
|
|
hint = (
|
|
"Set the API token in Settings → Backend."
|
|
if self.token
|
|
else "No API token configured — add it in Settings → Backend."
|
|
)
|
|
raise ApiError(f"{detail} {hint}", exc.code) from exc
|
|
raise ApiError(detail, exc.code) from exc
|
|
except (urllib.error.URLError, TimeoutError) as exc:
|
|
raise ApiError(
|
|
f"Cannot reach the backend at {self.base_url} ({exc}). "
|
|
"Check the server URL in Settings → Backend, or is "
|
|
"`docker compose up` running locally?"
|
|
) from exc
|
|
except json.JSONDecodeError as exc:
|
|
raise ApiError(f"The backend sent something that is not JSON: {exc}") from exc
|
|
|
|
# ---- endpoints ---------------------------------------------------------
|
|
|
|
def health(self) -> dict:
|
|
return self._request("GET", "/api/health")
|
|
|
|
def timer_state(self) -> dict:
|
|
return self._request("GET", "/api/timer/state")
|
|
|
|
def start(self, kind: str | None = None, task_id: int | None = None, minutes: int | None = None) -> dict:
|
|
return self._request(
|
|
"POST", "/api/timer/start", {"kind": kind, "task_id": task_id, "minutes": minutes}
|
|
)
|
|
|
|
def pause(self) -> dict:
|
|
return self._request("POST", "/api/timer/pause", {})
|
|
|
|
def resume(self) -> dict:
|
|
return self._request("POST", "/api/timer/resume", {})
|
|
|
|
def complete(self, auto_next: bool = True) -> dict:
|
|
return self._request("POST", "/api/timer/complete", {"auto_next": auto_next})
|
|
|
|
def abandon(self, reason: str | None = None) -> dict:
|
|
return self._request("POST", "/api/timer/abandon", {"reason": reason})
|
|
|
|
def extend(self, minutes: int = 5) -> dict:
|
|
return self._request("POST", "/api/timer/extend", {"minutes": minutes})
|
|
|
|
def interrupt(self) -> dict:
|
|
return self._request("POST", "/api/timer/interrupt", {})
|
|
|
|
def tasks(self) -> dict:
|
|
"""The list, read for one fact: which task is pinned.
|
|
|
|
There is no Tasks page here any more — the list is the web app's job.
|
|
`Controller._on_tasks` keeps the pinned id so that starting an interval
|
|
from the tray attaches what you pinned this morning.
|
|
"""
|
|
return self._request("GET", "/api/tasks")
|
|
|
|
def refresh_tasks(self) -> dict:
|
|
return self._request("POST", "/api/tasks/refresh", {})
|
|
|
|
def blocker_policy(self) -> dict:
|
|
return self._request("GET", "/api/blocker/policy")
|
|
|
|
def report_block(self, kind: str, target: str, action: str = "blocked", details: str | None = None) -> dict:
|
|
return self._request(
|
|
"POST",
|
|
"/api/blocker/events",
|
|
{"kind": kind, "target": target, "action": action, "details": details},
|
|
)
|
|
|
|
def send_report(self) -> dict:
|
|
return self._request("POST", "/api/reports/daily/send", {"force": True})
|
|
|
|
def patch_settings(self, patch: dict) -> dict:
|
|
return self._request("PUT", "/api/settings", patch)
|
|
|
|
|
|
def _detail(exc: urllib.error.HTTPError) -> str:
|
|
try:
|
|
body = json.loads(exc.read())
|
|
except Exception:
|
|
return f"{exc.code} {exc.reason}"
|
|
detail = body.get("detail") if isinstance(body, dict) else None
|
|
if isinstance(detail, str):
|
|
return detail
|
|
if isinstance(detail, list):
|
|
return "; ".join(
|
|
f"{'.'.join(str(part) for part in item.get('loc', [])[1:])}: {item.get('msg', '')}"
|
|
for item in detail
|
|
)
|
|
return f"{exc.code} {exc.reason}"
|