init
This commit is contained in:
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()
|
||||
Reference in new issue
Block a user