This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

@@ -0,0 +1,6 @@
"""Desktop-environment integration.
Each module here wraps one real system facility over D-Bus (or a file in the
right place), and every one of them degrades to a no-op when the running desktop
does not provide it — so the same build works on KDE, GNOME and a bare WM.
"""
@@ -0,0 +1,140 @@
"""Starting with the session, the way the desktop expects.
Two mechanisms, because the two desktops disagree about which is canonical:
* a `.desktop` file in `~/.config/autostart` — the XDG standard, honoured by
KDE, GNOME, XFCE and friends;
* a **systemd user unit**, which is what you actually want on a modern box: it
restarts the app if it dies and keeps its logs in the journal.
Either can be switched on alone; the app only ever touches files under the
user's own config, never system paths.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
log = logging.getLogger(__name__)
DESKTOP_FILE_NAME = "kadence.desktop"
SERVICE_NAME = "kadence-desktop.service"
def _autostart_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
path = Path(base) / "autostart"
path.mkdir(parents=True, exist_ok=True)
return path
def _systemd_user_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
path = Path(base) / "systemd" / "user"
path.mkdir(parents=True, exist_ok=True)
return path
def launch_command() -> str:
"""How to start this app again, whether installed or run from a checkout."""
executable = shutil.which("kadence-desktop")
if executable:
return executable
return f"{sys.executable} -m kadence_desktop"
# --------------------------------------------------------------------------
# XDG autostart
# --------------------------------------------------------------------------
def autostart_enabled() -> bool:
return (_autostart_dir() / DESKTOP_FILE_NAME).exists()
def set_autostart(enabled: bool, *, minimized: bool = True) -> None:
path = _autostart_dir() / DESKTOP_FILE_NAME
if not enabled:
path.unlink(missing_ok=True)
return
command = launch_command() + (" --minimized" if minimized else "")
path.write_text(
"\n".join(
[
"[Desktop Entry]",
"Type=Application",
"Name=Kadence",
"Comment=Pomodoro timer, task list and focus guard",
f"Exec={command}",
"Icon=kadence",
"Terminal=false",
"Categories=Utility;Office;",
"X-GNOME-Autostart-enabled=true",
"",
]
),
"utf-8",
)
log.info("autostart entry written to %s", path)
# --------------------------------------------------------------------------
# systemd user service
# --------------------------------------------------------------------------
def service_installed() -> bool:
return (_systemd_user_dir() / SERVICE_NAME).exists()
def service_enabled() -> bool:
if not shutil.which("systemctl"):
return False
result = subprocess.run(
["systemctl", "--user", "is-enabled", SERVICE_NAME],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip() == "enabled"
def install_service(*, enable: bool = True) -> str:
"""Write (and optionally enable) the user unit. Returns a status line."""
path = _systemd_user_dir() / SERVICE_NAME
path.write_text(
"\n".join(
[
"[Unit]",
"Description=Kadence desktop client",
"PartOf=graphical-session.target",
"After=graphical-session.target",
"",
"[Service]",
"Type=simple",
f"ExecStart={launch_command()} --minimized",
"Restart=on-failure",
"RestartSec=5",
"",
"[Install]",
"WantedBy=graphical-session.target",
"",
]
),
"utf-8",
)
if not shutil.which("systemctl"):
return f"unit written to {path} (systemctl not found)"
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
if enable:
subprocess.run(["systemctl", "--user", "enable", SERVICE_NAME], check=False)
return f"unit written to {path}" + (" and enabled" if enable else "")
def remove_service() -> None:
if shutil.which("systemctl"):
subprocess.run(["systemctl", "--user", "disable", "--now", SERVICE_NAME], check=False)
(_systemd_user_dir() / SERVICE_NAME).unlink(missing_ok=True)
if shutil.which("systemctl"):
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
@@ -0,0 +1,122 @@
"""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.
"""
from __future__ import annotations
import logging
from typing import Callable
from PyQt6.QtCore import QObject, pyqtSlot
from PyQt6.QtDBus import QDBusConnection
log = logging.getLogger(__name__)
SERVICE_NAME = "org.kadence.Desktop"
OBJECT_PATH = "/Timer"
INTERFACE = "org.kadence.Timer"
class TimerDBusService(QObject):
"""The exported object. Every slot is a verb you can bind to a key."""
def __init__(self, actions: dict[str, Callable[[], object]], parent: QObject | None = None) -> None:
super().__init__(parent)
self._actions = actions
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}"
@pyqtSlot(result=str)
def StartWork(self) -> str: # noqa: N802 - D-Bus methods are PascalCase
"""Start a focus interval."""
return self._run("start_work")
@pyqtSlot(result=str)
def StartBreak(self) -> str: # noqa: N802
return self._run("start_break")
@pyqtSlot(result=str)
def Toggle(self) -> str: # noqa: N802
"""Start if idle, pause if running, resume if paused — one hotkey."""
return self._run("toggle")
@pyqtSlot(result=str)
def Pause(self) -> str: # noqa: N802
return self._run("pause")
@pyqtSlot(result=str)
def Resume(self) -> str: # noqa: N802
return self._run("resume")
@pyqtSlot(result=str)
def Complete(self) -> str: # noqa: N802
return self._run("complete")
@pyqtSlot(result=str)
def Stop(self) -> str: # noqa: N802
return self._run("stop")
@pyqtSlot(result=str)
def Extend(self) -> str: # noqa: N802
return self._run("extend")
@pyqtSlot(result=str)
def Interrupt(self) -> str: # noqa: N802
"""Count a distraction without stopping the clock."""
return self._run("interrupt")
@pyqtSlot(result=str)
def Status(self) -> str: # noqa: N802
"""One line: what is running and how much is left."""
return self._run("status")
@pyqtSlot(result=str)
def SendReport(self) -> str: # noqa: N802
return self._run("send_report")
@pyqtSlot(result=str)
def Raise(self) -> str: # noqa: N802
"""Show the window (used by the second instance)."""
return self._run("raise_window")
def register(service: TimerDBusService) -> bool:
"""Own the name and export the object. False means another instance has it."""
bus = QDBusConnection.sessionBus()
if not bus.registerService(SERVICE_NAME):
return False
bus.registerObject(
OBJECT_PATH,
service,
QDBusConnection.RegisterOption.ExportAllSlots | QDBusConnection.RegisterOption.ExportAllInvokables,
)
log.info("D-Bus service %s%s is up", SERVICE_NAME, OBJECT_PATH)
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
@@ -0,0 +1,106 @@
"""Two things a focus app owes you: the screen stays on, and the world shuts up.
* **Screensaver inhibit** — `org.freedesktop.ScreenSaver.Inhibit`, honoured by
KDE, GNOME and anything XDG-ish. Without it a 50-minute interval spent reading
ends with a lock screen.
* **Do Not Disturb** — Plasma (and any daemon implementing the spec's optional
`Inhibit`) silences other apps' banners while you focus. GNOME has no such
method, so there we flip the `show-banners` gsetting and put it back after.
Both are strictly paired: every inhibit is released on exit, including a crash,
because the cookie dies with the D-Bus connection.
"""
from __future__ import annotations
import logging
import shutil
import subprocess
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
log = logging.getLogger(__name__)
_SCREENSAVER = ("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver")
_NOTIFICATIONS = ("org.freedesktop.Notifications", "/org/freedesktop/Notifications", "org.freedesktop.Notifications")
class ScreensaverInhibitor:
"""Keeps the session awake for as long as `hold()` is in effect."""
def __init__(self, app_name: str = "Kadence") -> None:
self._app_name = app_name
self._interface = QDBusInterface(*_SCREENSAVER, QDBusConnection.sessionBus())
self._cookie: int | None = None
@property
def active(self) -> bool:
return self._cookie is not None
def hold(self, reason: str = "Focus interval in progress") -> None:
if self._cookie is not None or not self._interface.isValid():
return
reply = self._interface.call("Inhibit", self._app_name, reason)
arguments = reply.arguments()
if arguments:
self._cookie = int(arguments[0])
log.debug("screensaver inhibited (cookie %s)", self._cookie)
def release(self) -> None:
if self._cookie is None:
return
self._interface.call("UnInhibit", self._cookie)
log.debug("screensaver released")
self._cookie = None
class DoNotDisturb:
"""Silences other applications' notifications during focus."""
def __init__(self, app_name: str = "Kadence") -> None:
self._app_name = app_name
self._interface = QDBusInterface(*_NOTIFICATIONS, QDBusConnection.sessionBus())
self._cookie: int | None = None
self._gnome_restored = True
@property
def active(self) -> bool:
return self._cookie is not None or not self._gnome_restored
def hold(self, reason: str = "Focus interval in progress") -> None:
if self.active:
return
if self._interface.isValid():
reply = self._interface.call("Inhibit", self._app_name, reason, {})
arguments = reply.arguments()
if arguments and not reply.errorName():
self._cookie = int(arguments[0])
return
self._gnome_dnd(True)
def release(self) -> None:
if self._cookie is not None:
self._interface.call("UnInhibit", self._cookie)
self._cookie = None
if not self._gnome_restored:
self._gnome_dnd(False)
def _gnome_dnd(self, enable: bool) -> None:
"""GNOME fallback: toggle the banner gsetting, and remember to undo it."""
if not shutil.which("gsettings"):
return
try:
subprocess.run(
[
"gsettings",
"set",
"org.gnome.desktop.notifications",
"show-banners",
"false" if enable else "true",
],
check=False,
timeout=5,
)
self._gnome_restored = not enable
except (OSError, subprocess.SubprocessError) as exc: # pragma: no cover
log.debug("gsettings DND failed: %s", exc)
@@ -0,0 +1,43 @@
"""Progress on the dock icon, via `com.canonical.Unity.LauncherEntry`.
A signal, not a service: anything listening (Plasma's task manager, Dash to
Dock, Latte) picks it up, and nothing breaks where nobody listens. The result is
a progress bar filling under the icon as the interval runs and a badge with the
pomodoro count for the day — the timer stays readable with the window closed.
"""
from __future__ import annotations
from PyQt6.QtCore import QVariant
from PyQt6.QtDBus import QDBusConnection, QDBusMessage
_PATH = "/org/kadence/Desktop"
_IFACE = "com.canonical.Unity.LauncherEntry"
_APP_URI = "application://kadence.desktop"
class LauncherEntry:
def __init__(self) -> None:
self._bus = QDBusConnection.sessionBus()
def update(self, *, progress: float | None = None, count: int | None = None, urgent: bool = False) -> None:
"""Push a new state. `progress` is 0..1; `count` is the badge number."""
properties: dict[str, QVariant] = {}
if progress is None:
properties["progress-visible"] = QVariant(False)
else:
properties["progress"] = QVariant(max(0.0, min(1.0, progress)))
properties["progress-visible"] = QVariant(True)
if count is None:
properties["count-visible"] = QVariant(False)
else:
properties["count"] = QVariant(int(count))
properties["count-visible"] = QVariant(True)
properties["urgent"] = QVariant(bool(urgent))
message = QDBusMessage.createSignal(_PATH, _IFACE, "Update")
message.setArguments([_APP_URI, properties])
self._bus.send(message)
def clear(self) -> None:
self.update(progress=None, count=None, urgent=False)
@@ -0,0 +1,69 @@
"""Pause whatever is playing when focus starts — and only that.
Every modern player (Spotify, VLC, mpv, browsers, Elisa) speaks MPRIS, so this
needs no per-app support: list the `org.mpris.MediaPlayer2.*` names on the
session bus, pause the ones that are actually playing, and remember exactly
which ones so the break can hand the music back.
"""
from __future__ import annotations
import logging
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
log = logging.getLogger(__name__)
_PREFIX = "org.mpris.MediaPlayer2."
_PATH = "/org/mpris/MediaPlayer2"
_PLAYER_IFACE = "org.mpris.MediaPlayer2.Player"
_PROPS_IFACE = "org.freedesktop.DBus.Properties"
class MediaController:
def __init__(self) -> None:
self._bus = QDBusConnection.sessionBus()
self._paused_by_us: list[str] = []
def _players(self) -> list[str]:
interface = QDBusInterface(
"org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", self._bus
)
reply = interface.call("ListNames")
arguments = reply.arguments()
names = list(arguments[0]) if arguments else []
return [name for name in names if name.startswith(_PREFIX)]
def _status(self, service: str) -> str:
properties = QDBusInterface(service, _PATH, _PROPS_IFACE, self._bus)
reply = properties.call("Get", _PLAYER_IFACE, "PlaybackStatus")
arguments = reply.arguments()
return str(arguments[0]) if arguments else ""
def pause_all(self) -> list[str]:
"""Pause every playing player; returns the ones that were paused."""
paused: list[str] = []
for service in self._players():
try:
if self._status(service) != "Playing":
continue
QDBusInterface(service, _PATH, _PLAYER_IFACE, self._bus).call("Pause")
paused.append(service)
except Exception as exc: # pragma: no cover - a rude player is not our problem
log.debug("could not pause %s: %s", service, exc)
self._paused_by_us = paused
if paused:
log.info("paused %s media player(s)", len(paused))
return paused
def resume_paused(self) -> None:
"""Resume only what we paused, and only if it is still around."""
alive = set(self._players())
for service in self._paused_by_us:
if service not in alive:
continue
try:
QDBusInterface(service, _PATH, _PLAYER_IFACE, self._bus).call("Play")
except Exception as exc: # pragma: no cover
log.debug("could not resume %s: %s", service, exc)
self._paused_by_us = []
@@ -0,0 +1,133 @@
"""Desktop notifications with buttons, over org.freedesktop.Notifications.
Qt's `QSystemTrayIcon.showMessage` can only show text. The real spec — which
KDE, GNOME and anything using a freedesktop daemon implement — takes *actions*,
so "Pomodoro finished" can carry [Start break] [Skip] [+5 min] and act without
you finding the window. Notifications are also replaced in place (the same id),
so a running timer never stacks up a column of toasts.
"""
from __future__ import annotations
import logging
from typing import Callable
from PyQt6.QtCore import QMetaType, QObject, QVariant, pyqtSlot
from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage
log = logging.getLogger(__name__)
_SERVICE = "org.freedesktop.Notifications"
_PATH = "/org/freedesktop/Notifications"
_IFACE = "org.freedesktop.Notifications"
# Urgency levels from the freedesktop spec.
URGENCY_LOW, URGENCY_NORMAL, URGENCY_CRITICAL = 0, 1, 2
class Notifier(QObject):
"""Sends notifications and routes their button presses back to callables."""
def __init__(self, app_name: str = "Kadence", parent: QObject | None = None) -> None:
super().__init__(parent)
self._app_name = app_name
self._bus = QDBusConnection.sessionBus()
self._interface = QDBusInterface(_SERVICE, _PATH, _IFACE, self._bus)
self._handlers: dict[int, dict[str, Callable[[], None]]] = {}
self._last_id = 0
self._available = self._interface.isValid()
if self._available:
self._bus.connect(_SERVICE, _PATH, _IFACE, "ActionInvoked", self._on_action)
self._bus.connect(_SERVICE, _PATH, _IFACE, "NotificationClosed", self._on_closed)
@property
def available(self) -> bool:
return self._available
def capabilities(self) -> list[str]:
if not self._available:
return []
reply = self._interface.call("GetCapabilities")
arguments = reply.arguments()
return list(arguments[0]) if arguments else []
def notify(
self,
summary: str,
body: str = "",
*,
actions: dict[str, tuple[str, Callable[[], None]]] | None = None,
urgency: int = URGENCY_NORMAL,
timeout_ms: int = 8000,
replace: bool = True,
icon: str = "kadence",
sound: bool = True,
) -> int:
"""Show a notification. `actions` maps key -> (label, callback)."""
if not self._available:
return 0
action_list: list[str] = []
handlers: dict[str, Callable[[], None]] = {}
for key, (label, callback) in (actions or {}).items():
action_list += [key, label]
handlers[key] = callback
hints = {
"urgency": QVariant(urgency),
"desktop-entry": QVariant("kadence"),
"x-kde-origin-name": QVariant(self._app_name),
}
if not sound:
hints["suppress-sound"] = QVariant(True)
# The spec's signature is `susssasa{sv}i`: `replaces_id` is a uint32 and
# `actions` is an array of strings. Python ints marshal as int32 and a
# Python list as an array of variants, so both need spelling out or the
# daemon rejects the call as "no such method".
reply = self._interface.call(
"Notify",
self._app_name,
QDBusArgument(self._last_id if replace else 0, QMetaType.Type.UInt.value),
icon,
summary,
body,
QDBusArgument(action_list, QMetaType.Type.QStringList.value),
hints,
timeout_ms,
)
if reply.errorName():
log.warning("notification refused: %s — %s", reply.errorName(), reply.errorMessage())
return 0
arguments = reply.arguments()
try:
notification_id = int(arguments[0]) if arguments else 0
except (TypeError, ValueError):
log.warning("unexpected Notify reply: %r", arguments)
return 0
self._last_id = notification_id
if handlers:
self._handlers[notification_id] = handlers
return notification_id
def close(self, notification_id: int) -> None:
if self._available and notification_id:
self._interface.call("CloseNotification", notification_id)
@pyqtSlot(QDBusMessage)
def _on_action(self, message: QDBusMessage) -> None:
arguments = message.arguments()
if len(arguments) < 2:
return
notification_id, key = int(arguments[0]), str(arguments[1])
handler = self._handlers.get(notification_id, {}).get(key)
if handler:
handler()
@pyqtSlot(QDBusMessage)
def _on_closed(self, message: QDBusMessage) -> None:
arguments = message.arguments()
if arguments:
self._handlers.pop(int(arguments[0]), None)
@@ -0,0 +1,119 @@
"""Knowing when you walked away.
Two signals, both from the session itself rather than from guesswork:
* **logind** emits `Lock`/`Unlock` on your session object, and `PrepareForSleep`
when the machine suspends. Locking the screen is the clearest possible "I am
not at this desk" — the timer pauses, and resumes when you come back.
* **Idle time** comes from KDE's `org.kde.screensaver.GetSessionIdleTime` or
GNOME's `org.gnome.Mutter.IdleMonitor`, so an interval you forgot to stop does
not quietly bank half an hour of "focus" you spent in the kitchen.
"""
from __future__ import annotations
import logging
import os
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
from PyQt6.QtDBus import QDBusConnection, QDBusInterface, QDBusMessage
log = logging.getLogger(__name__)
_LOGIND = "org.freedesktop.login1"
_MANAGER_PATH = "/org/freedesktop/login1"
_MANAGER_IFACE = "org.freedesktop.login1.Manager"
_SESSION_IFACE = "org.freedesktop.login1.Session"
class SessionWatcher(QObject):
"""Emits lock/unlock/sleep, and answers "how long have they been idle?"."""
locked = pyqtSignal()
unlocked = pyqtSignal()
sleeping = pyqtSignal()
woke = pyqtSignal()
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._system_bus = QDBusConnection.systemBus()
self._session_bus = QDBusConnection.sessionBus()
self._session_path = self._find_session_path()
if self._session_path:
for signal_name, slot in (("Lock", self._on_lock), ("Unlock", self._on_unlock)):
self._system_bus.connect(_LOGIND, self._session_path, _SESSION_IFACE, signal_name, slot)
log.debug("watching logind session %s", self._session_path)
self._system_bus.connect(_LOGIND, _MANAGER_PATH, _MANAGER_IFACE, "PrepareForSleep", self._on_sleep)
# Plasma's own lock signal, for setups where logind's does not arrive.
self._session_bus.connect(
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver",
"ActiveChanged",
self._on_screensaver,
)
def _find_session_path(self) -> str:
"""Ask logind for *this* session's object path."""
manager = QDBusInterface(_LOGIND, _MANAGER_PATH, _MANAGER_IFACE, self._system_bus)
if not manager.isValid():
return ""
session_id = os.environ.get("XDG_SESSION_ID", "")
if session_id:
reply = manager.call("GetSession", session_id)
arguments = reply.arguments()
if arguments and not reply.errorName():
return str(arguments[0].path() if hasattr(arguments[0], "path") else arguments[0])
reply = manager.call("GetSessionByPID", os.getpid())
arguments = reply.arguments()
if arguments and not reply.errorName():
value = arguments[0]
return str(value.path() if hasattr(value, "path") else value)
return ""
def idle_seconds(self) -> int:
"""Seconds since the last input, or -1 when nothing can tell us."""
kde = QDBusInterface(
"org.freedesktop.ScreenSaver", "/ScreenSaver", "org.kde.screensaver", self._session_bus
)
if kde.isValid():
reply = kde.call("GetSessionIdleTime")
arguments = reply.arguments()
if arguments and not reply.errorName():
return int(arguments[0]) // 1000
mutter = QDBusInterface(
"org.gnome.Mutter.IdleMonitor",
"/org/gnome/Mutter/IdleMonitor/Core",
"org.gnome.Mutter.IdleMonitor",
self._session_bus,
)
if mutter.isValid():
reply = mutter.call("GetIdletime")
arguments = reply.arguments()
if arguments and not reply.errorName():
return int(arguments[0]) // 1000
return -1
@pyqtSlot(QDBusMessage)
def _on_lock(self, _message: QDBusMessage) -> None:
self.locked.emit()
@pyqtSlot(QDBusMessage)
def _on_unlock(self, _message: QDBusMessage) -> None:
self.unlocked.emit()
@pyqtSlot(QDBusMessage)
def _on_sleep(self, message: QDBusMessage) -> None:
arguments = message.arguments()
going_to_sleep = bool(arguments[0]) if arguments else False
(self.sleeping if going_to_sleep else self.woke).emit()
@pyqtSlot(QDBusMessage)
def _on_screensaver(self, message: QDBusMessage) -> None:
arguments = message.arguments()
if not arguments:
return
(self.locked if bool(arguments[0]) else self.unlocked).emit()