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>
182 lines
5.6 KiB
Python
182 lines
5.6 KiB
Python
"""Kadence's own D-Bus API: `org.kadence.Desktop`.
|
|
|
|
This is what makes the app scriptable and bindable. KDE's System Settings →
|
|
Shortcuts → Custom Shortcuts can call a D-Bus method directly, so a global
|
|
hotkey for "start a pomodoro" needs no X11 key-grabbing hack:
|
|
|
|
qdbus org.kadence.Desktop /Timer org.kadence.Timer.StartWork
|
|
|
|
Owning the name also gives single-instance behaviour for free: a second launch
|
|
sees the name is taken, calls `Raise()` on the first, and exits.
|
|
|
|
The verbs live on a **QDBusAbstractAdaptor**, not on the exported object
|
|
itself. Qt only honours the "D-Bus Interface" class info on an adaptor; export
|
|
a plain QObject and the interface is named after its class
|
|
(`local.kadence.TimerDBusService`), which is exactly the "No such interface"
|
|
every documented command then hits.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Callable
|
|
|
|
from PyQt6.QtCore import QObject, pyqtClassInfo, pyqtSlot
|
|
from PyQt6.QtDBus import QDBusAbstractAdaptor, QDBusConnection
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
SERVICE_NAME = "org.kadence.Desktop"
|
|
OBJECT_PATH = "/Timer"
|
|
INTERFACE = "org.kadence.Timer"
|
|
|
|
|
|
class TimerDBusService(QObject):
|
|
"""Holds the actions; the adaptor below is what the bus sees."""
|
|
|
|
def __init__(self, actions: dict[str, Callable[[], object]], parent: QObject | None = None) -> None:
|
|
super().__init__(parent)
|
|
self._actions = actions
|
|
self.adaptor = TimerAdaptor(self)
|
|
|
|
def run(self, name: str) -> str:
|
|
action = self._actions.get(name)
|
|
if action is None:
|
|
return f"unknown action: {name}"
|
|
try:
|
|
result = action()
|
|
return str(result) if result is not None else "ok"
|
|
except Exception as exc: # pragma: no cover - never let a caller crash us
|
|
log.exception("D-Bus action %s failed", name)
|
|
return f"error: {exc}"
|
|
|
|
|
|
# Qt generates no introspection XML for a Python adaptor, and without it
|
|
# `busctl introspect` and KDE's D-Bus shortcut browser have nothing to list —
|
|
# so the interface describes itself. Every verb takes no arguments and returns
|
|
# one string.
|
|
VERBS = (
|
|
"StartWork",
|
|
"StartBreak",
|
|
"Toggle",
|
|
"Pause",
|
|
"Resume",
|
|
"Complete",
|
|
"Stop",
|
|
"Extend",
|
|
"Interrupt",
|
|
"Status",
|
|
"SendReport",
|
|
"ToggleMini",
|
|
"Raise",
|
|
"Settings",
|
|
)
|
|
|
|
INTROSPECTION = "\n".join(
|
|
[f'<interface name="{INTERFACE}">']
|
|
+ [
|
|
f' <method name="{verb}">\n <arg type="s" direction="out"/>\n </method>'
|
|
for verb in VERBS
|
|
]
|
|
+ ["</interface>"]
|
|
)
|
|
|
|
|
|
@pyqtClassInfo("D-Bus Interface", INTERFACE)
|
|
@pyqtClassInfo("D-Bus Introspection", INTROSPECTION)
|
|
class TimerAdaptor(QDBusAbstractAdaptor):
|
|
"""The published interface. Every slot is a verb you can bind to a key."""
|
|
|
|
def __init__(self, parent: TimerDBusService) -> None:
|
|
super().__init__(parent)
|
|
self._service = parent
|
|
|
|
# D-Bus methods are PascalCase by convention.
|
|
@pyqtSlot(result=str)
|
|
def StartWork(self) -> str:
|
|
"""Start a focus interval."""
|
|
return self._service.run("start_work")
|
|
|
|
@pyqtSlot(result=str)
|
|
def StartBreak(self) -> str:
|
|
return self._service.run("start_break")
|
|
|
|
@pyqtSlot(result=str)
|
|
def Toggle(self) -> str:
|
|
"""Start if idle, pause if running, resume if paused — one hotkey."""
|
|
return self._service.run("toggle")
|
|
|
|
@pyqtSlot(result=str)
|
|
def Pause(self) -> str:
|
|
return self._service.run("pause")
|
|
|
|
@pyqtSlot(result=str)
|
|
def Resume(self) -> str:
|
|
return self._service.run("resume")
|
|
|
|
@pyqtSlot(result=str)
|
|
def Complete(self) -> str:
|
|
return self._service.run("complete")
|
|
|
|
@pyqtSlot(result=str)
|
|
def Stop(self) -> str:
|
|
return self._service.run("stop")
|
|
|
|
@pyqtSlot(result=str)
|
|
def Extend(self) -> str:
|
|
return self._service.run("extend")
|
|
|
|
@pyqtSlot(result=str)
|
|
def Interrupt(self) -> str:
|
|
"""Count a distraction without stopping the clock."""
|
|
return self._service.run("interrupt")
|
|
|
|
@pyqtSlot(result=str)
|
|
def Status(self) -> str:
|
|
"""One line: what is running and how much is left."""
|
|
return self._service.run("status")
|
|
|
|
@pyqtSlot(result=str)
|
|
def SendReport(self) -> str:
|
|
return self._service.run("send_report")
|
|
|
|
@pyqtSlot(result=str)
|
|
def ToggleMini(self) -> str:
|
|
"""Show or hide the floating mini timer."""
|
|
return self._service.run("toggle_mini")
|
|
|
|
@pyqtSlot(result=str)
|
|
def Raise(self) -> str:
|
|
"""Show the countdown (used by a second instance, and by `--show`).
|
|
|
|
There is no main window to raise any more — this app is an agent, and
|
|
the mini timer is the whole of its own interface.
|
|
"""
|
|
return self._service.run("raise_window")
|
|
|
|
@pyqtSlot(result=str)
|
|
def Settings(self) -> str:
|
|
"""Open the settings dialog."""
|
|
return self._service.run("settings")
|
|
|
|
|
|
def register(service: TimerDBusService) -> bool:
|
|
"""Own the name and export the adaptor. False means another instance has it."""
|
|
bus = QDBusConnection.sessionBus()
|
|
if not bus.registerService(SERVICE_NAME):
|
|
return False
|
|
bus.registerObject(OBJECT_PATH, service, QDBusConnection.RegisterOption.ExportAdaptors)
|
|
log.info("D-Bus service %s%s is up as %s", SERVICE_NAME, OBJECT_PATH, INTERFACE)
|
|
return True
|
|
|
|
|
|
def call_existing(method: str = "Raise") -> bool:
|
|
"""Poke the instance that already owns the name."""
|
|
from PyQt6.QtDBus import QDBusInterface
|
|
|
|
interface = QDBusInterface(SERVICE_NAME, OBJECT_PATH, INTERFACE, QDBusConnection.sessionBus())
|
|
if not interface.isValid():
|
|
return False
|
|
interface.call(method)
|
|
return True
|