Files
kadence/desktop/README.md
T
timka123 36af1e9e83 desktop: route wildcard zones to a local DNS stub
A hosts file cannot express a wildcard, so '*.youtube.com' was only ever the
base domain plus the guessed labels. The patterns the server now hands over
as unexpandable are answered by a stub on 127.0.0.1, and the helper owns one
systemd-resolved drop-in that points just those zones at it:

  * the stub is listening before the drop-in is written, and the drop-in is
    removed before the stub stops, so a zone's queries never land on a dead
    port;
  * stub and route live as long as the app, not the interval: between
    intervals they forward everything, instead of restarting resolved (and
    dropping its cache) on every pomodoro;
  * a crashed run leaves a world-readable drop-in the next start adopts, and
    the stub comes back before anything else;
  * masks with nothing routable after their last '*' ('*gram*') are reported
    as unenforceable, not silently dropped.

The Blocker page now reports what is really in place — the hosts region, the
routed zones, and the masks this machine cannot do — rather than the whole
expansion. 39 desktop tests, including the ordering invariants.
2026-10-19 18:45:42 +03:00

227 lines
13 KiB
Markdown

# Kadence — the native desktop app
Not Electron. **PyQt6 + QML with Qt Quick Controls' Material style**: the window
is a native Wayland/X11 surface drawn by Qt, using 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 —
is the Python standard library.
It is also the piece that *enforces* things, because it is the only part running
on your machine. The interface is the same warm, hairline-ruled design as the
web app — the `Theme` singleton in `qml/` holds the same tokens as the web
app's `tokens.ts`.
```bash
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`.
---
## 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 with **Ctrl+M**, the tray menu, or `kadence-desktop --mini`; it also appears by itself while an interval runs and the main window is away. Dragging moves it (through the compositor, so it works on Wayland) and double-clicking opens the main window. 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. |
| **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`, `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 raises the first window and exits. |
| **Launcher actions** | Right-click the icon: *Start a pomodoro*, *Start a break*, *Start / pause*, *Send today's report*. |
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.
```bash
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
```bash
./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:
```bash
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:
```bash
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):
```bash
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"*.
In-window shortcuts: **Space** start/pause · **Ctrl+Enter** finish ·
**Ctrl+E** +5 min · **Ctrl+R** refresh sources · **Ctrl+M** mini timer ·
**Ctrl+1…5** pages · **Ctrl+W** hide to tray.
> **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, 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. The **Settings** page edits both, clearly separated.
## 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/ # Material UI: Timer, Tasks, Blocker, Report, Settings
helper/ # the privileged helper + polkit action + installer
packaging/ # .desktop entry, icon, user installer
```