Files
kadence/desktop
timka123andClaude Opus 5 8e659cd315 desktop: an agent and a mini timer, not an application window
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>
2026-10-22 18:00:52 +03:00
..

Kadence — the desktop agent

Not Electron, and not an application window either. This piece is an agent: it lives in the tray, enforces the server's blocking policy on this machine, pauses your music, holds the screen awake, answers D-Bus, posts notifications with buttons — and shows a floating countdown. PyQt6 + QML with Qt Quick Controls' Material style, so what it does draw is a native Wayland/X11 surface with the session's own fonts, scaling and colour scheme. The only dependency is PyQt6, which on Arch is the system package python-pyqt6; everything else — HTTP, config, process scanning, DNS — is the Python standard library.

There used to be a five-page window here (Timer, Tasks, Blocker, Report, Settings) and it is gone. Every one of those pages was a second view of the server's data, which made it a second place to be wrong and a second place to style — and the styling is what it never earned. The web app does lists, rules and reports properly, in a browser, on any device. What is left on the desktop is the part only a program on your machine can do, plus the two surfaces worth having in front of you:

the tray menu the whole timer: start, pause, finish, +5, abandon, report, view-only, mini timer, settings
the mini timer a floating pill with the countdown, the task, and the controls
the settings dialog the handful of settings that belong to this machine
sudo pacman -S --needed python-pyqt6 qt6-declarative     # Arch
./packaging/install-desktop.sh                            # menu entry, icon, launcher
sudo ./helper/install.sh                                  # privileged helper (website blocking)
kadence-desktop

Run it straight from the checkout instead: python3 -m kadence_desktop. Point it at the backend from the tray's Settings…, or kadence-desktop --settings.

The usual way to run it is with no window at all — --minimized, which is what the autostart entry and the systemd user unit use. The mini timer then appears by itself when an interval starts.


What "native" buys you

Feature How
A floating mini timer A small pill with the countdown, the task you are on, and the controls — Pause / Resume / Finish / +5, or Start focus / Break when idle — all on their own rows and all visible without hovering. Open it from the tray menu or with kadence-desktop --mini; it also appears by itself while an interval runs. Dragging moves it (through the compositor, so it works on Wayland) and double-clicking opens the web app. It is not a Qt.Tool window — Qt hides those whenever the application is deactivated, which made it vanish on every click in another app — and it refuses keyboard focus instead. The countdown is set in the display serif on a fixed advance: Fraunces has no tabular figures and ships no tnum, so each glyph is laid in a box as wide as the widest digit, or the clock jitters sideways on every tick.
Where it appears Settings → Mini timer appears: any of the four corners, the top/bottom edge centres, the centre of the screen, or where I last dragged it, with a configurable gap from the edges. It is placed on the screen the app is on, inside the usable area (panels excluded), and it returns to that spot every time it comes up. On X11 the app positions the window; on Wayland — where a client may not — the point is written into the same KWin rule.
Above everything, on Wayland Wayland gives an application no way to ask for "always on top", so on KDE the app installs a KWin window rule (keep above, skip task bar, skip switcher, skip pager) and asks KWin to re-read its config over D-Bus. The rule matches the window title as a substring: Qt appends the application's display name, so KWin sees Kadence mini — Kadence, and an exact match silently never fires. The app backs the file up first, leaves your other rules alone, re-writes the rule when an upgrade changes it, and Settings → Keep the mini timer above everything removes it. Verify with busctl --user call org.kde.KWin /Scripting org.kde.kwin.Scripting loadScript … or simply: the mini timer should stay on top of a maximised window.
Tray icon that is the timer QSystemTrayIcon (a StatusNotifierItem on KDE). The icon is painted at runtime: a ring that drains as the interval runs, tinted by focus / break / idle, with a pause glyph when held. Middle-click toggles the timer.
Real notifications with buttons org.freedesktop.Notifications over D-Bus, with actions: Start break · Another pomodoro · +5 min. Replaced in place, so a day of pomodoros never stacks a column of toasts.
Scriptable over D-Bus org.kadence.Desktop exports StartWork, Toggle, Pause, Complete, Extend, Interrupt, Status, SendReport, ToggleMini, Settings, Raise. Bind any of them to a global shortcut in System Settings — no key-grabbing hacks.
Screen stays awake org.freedesktop.ScreenSaver.Inhibit while you focus, released the moment the interval ends.
Do Not Disturb Other apps' banners are suppressed during focus (Plasma's Notifications.Inhibit; on GNOME the show-banners gsetting, put back afterwards).
Your music pauses Any MPRIS player (Spotify, VLC, mpv, browsers) is paused when focus starts, and only the ones we paused resume on the break.
Taskbar progress com.canonical.Unity.LauncherEntry — a progress bar under the dock icon and a badge with today's count.
Knows when you walked away logind Lock/Unlock and PrepareForSleep pause and resume the timer; optional idle-pause uses KDE's or Mutter's idle monitor.
Starts with the session XDG autostart entry, or a systemd user unit that restarts on failure and logs to the journal.
Single instance Owning the D-Bus name is the lock; a second launch brings the first one's countdown up and exits.
Launcher actions Right-click the icon: Start a pomodoro, Start a break, Start / pause, Send today's report, Settings.

Every one of these degrades to a no-op where the desktop does not provide it, so the same build runs on KDE, GNOME or a bare window manager.


Blocking

The rules (which sites, which apps, when) live on the backend so the web app can edit them and a second machine obeys the same list. This app enforces them with two mechanisms, because no single one covers the masks people actually write:

  • Websites — the managed region of /etc/hosts is kept in sync with the policy. Writing it needs root, which a desktop app must not have, so it calls kadence-helper through pkexec.
  • Wildcards a hosts file cannot express — a hosts file is a list of exact names, so *.google.com is expanded server-side into real hostnames. A bare * in the subdomain expansion list changes what that means: *.google.com then covers every subdomain, not just the configured labels, and those are answered by a small DNS stub on 127.0.0.1 (port 5353, falling back to 5453/5553/5354) which the helper points systemd-resolved at with one drop-in for just the zones that need it (~google.com). Every other name flows to the machine's normal resolver untouched. The stub is unprivileged, speaks plain DNS over UDP and TCP, answers blocked A records with the redirect address and other record types with NODATA (so HTTPS/SVCB hints cannot smuggle an address past it), and returns SERVFAIL when no upstream can answer.
  • What cannot be routed — the zone is whatever follows the last *, and it needs at least two labels: *gram.com routes gram.com, while *gram* has nothing after its last * and *.site has a single label. Those stay honestly reported as "cannot be blocked on this computer" instead of pretending; the phone still matches them against the URLs it sees.
  • Ordering — the stub is listening before the drop-in is written, and the drop-in is removed before the stub stops, so resolved never has a moment with nowhere to send a zone's queries. The stub and the route belong to the app's lifetime, not to one interval: between intervals they stay up and forward everything, because repointing resolved costs a restart and a dropped DNS cache. If a crash left the drop-in behind, the next start sees it (the file is world-readable), brings its stub back first, and carries on.
  • Applications — /proc is scanned every few seconds for your own processes matching the list; they are terminated (SIGTERM, then SIGKILL) or merely reported, and every attempt is sent to the backend for the evening report. List entries may be globs — steam*, *discord*, telegram-? — matched against both the process name and its command line.
  • Everything is released when the interval ends — and on exit, including a crash, because shutdown() clears the block and the inhibits.

The privileged helper

helper/kadence-helper is the entire privileged surface, and it is deliberately dull. It has two jobs — the hosts region and the resolved drop-in:

  • every hostname must match a strict pattern, the redirect must parse as an IP;
  • only the region between its own markers is rewritten — your own entries above and below survive byte for byte;
  • the input list is opened O_NOFOLLOW and must be a regular file;
  • the write is atomic (temp file + rename), and the first run backs up /etc/hosts to /etc/hosts.kadence-backup;
  • localhost can never be blocked;
  • resolved-on accepts only real zone names (two or more labels, no *, no ports or junk), deduplicates them, refuses privileged ports, and writes the drop-in 0644 atomically; resolved-off removes it. Both then restart systemd-resolved and flush its cache; on a machine that does not run resolved, they say so and the hosts half carries on alone.
sudo ./helper/install.sh                  # polkit asks once per session
sudo ./helper/install.sh --passwordless   # or never, for your user only
sudo ./helper/install.sh --uninstall      # unblocks, removes the drop-in, then removes itself

Application menu and rofi

./packaging/install-desktop.sh

That writes ~/.local/share/applications/kadence.desktop, the icon, and a ~/.local/bin/kadence-desktop launcher, then refreshes the caches each launcher keeps (update-desktop-database, gtk-update-icon-cache, kbuildsycoca6 for KDE's menu).

Two details that decide whether an entry shows up at all:

  • Exec/TryExec are written as absolute paths. Launchers run with the session PATH, which usually does not include ~/.local/bin; an entry whose TryExec cannot be resolved is silently hidden. Absolute paths cannot be.
  • One main category. Categories=Office;ProjectManagement; — listing both Utility and Office makes the app appear twice in some menus.

It then shows up in KDE's launcher under Office and in rofi -show drun (search for pomodoro, timer or focus), with the right-click actions Start a pomodoro / Start a break / Start / pause / Send today's report.

If your rofi runs in run mode rather than drun, it lists binaries from PATH, so ~/.local/bin has to be on the session PATH:

printf 'PATH=$HOME/.local/bin:$PATH\n' > ~/.config/environment.d/10-local-bin.conf
# for the session you are already in:
systemctl --user set-environment PATH="$HOME/.local/bin:$PATH"
dbus-update-activation-environment --systemd PATH

To see the entry (and its actions) in rofi with its icon instead, add drun: modes: "drun,run"; in ~/.config/rofi/config.rasi, plus drun-show-actions: true; if you want the actions listed too.


Global shortcuts

System Settings → Shortcuts → Custom Shortcuts → New → D-Bus command:

Field Value
Service org.kadence.Desktop
Path /Timer
Method org.kadence.Timer.Toggle

Or bind a command: kadence-desktop --toggle. The verbs talk to the running instance instead of starting a second one:

kadence-desktop --toggle      # start → pause → resume
kadence-desktop --start       # start a focus interval
kadence-desktop --break
kadence-desktop --complete
kadence-desktop --mini        # show/hide the floating mini timer
kadence-desktop --status      # "Focus 18:42 · Write the docs · 3/8 today"
kadence-desktop --report      # send today's report to Telegram

On Qt 6 the command-line D-Bus tool is qdbus6 (plain qdbus is the Qt 5 one and may not be installed):

qdbus6 org.kadence.Desktop /Timer org.kadence.Timer.Status
busctl --user introspect org.kadence.Desktop /Timer   # lists every verb

The verbs live on a QDBusAbstractAdaptor carrying pyqtClassInfo("D-Bus Interface", "org.kadence.Timer"). That detail matters: export a plain QObject instead and Qt names the interface after the class (local.kadence.TimerDBusService), so every documented command fails with "No such interface".

There are no in-window shortcuts any more — there is no window to be focused in. Bind the D-Bus verbs (or kadence-desktop --toggle) to real global shortcuts instead, which is what they were always better as.

Wayland note. The mini timer's placement and "always on top" are a KWin rule written to ~/.config/kwinrulesrc (backed up once as kwinrulesrc.kadence-backup); you can see it in System Settings → Window Management → Window Rules as Kadence mini timer, and the switch in Settings removes it. Its position is remembered on X11; on Wayland the compositor places it and dragging goes through startSystemMove().


Configuration

~/.config/kadence/desktop.json holds only what is about this machine — the backend URL and token, where the mini timer sits, and which integrations this box may use. Timer lengths, task sources, block lists and Telegram all live on the server, so they are the same everywhere and are edited in the web app.

The Settings… dialog (tray menu, or kadence-desktop --settings) edits that file and nothing else, with one exception that has to be server-side: view-only mode writes pomodoro.timer_enabled, because the other clients have to honour it too.

Layout

kadence_desktop/
  app.py                 # QApplication, QML engine, tray, D-Bus, single instance
  controller.py          # the object QML binds to; wires transitions to integrations
  worker.py              # the network thread (every HTTP call happens here)
  api.py                 # stdlib HTTP client for the backend
  config.py              # ~/.config/kadence/desktop.json
  tray.py                # the painted countdown icon and its menu
  integrations/          # notifications, inhibit/DND, MPRIS, launcher, logind, D-Bus, autostart
  blocking/              # hosts region, DNS stub + resolved route, /proc scanning, the manager that syncs policy
  qml/                   # MiniTimer, SettingsDialog, the Theme singleton, five components
helper/                  # the privileged helper + polkit action + installer
packaging/               # .desktop entry, icon, systemd user unit, user installer