From 7e89c874e83ea49a4808e037457ab7b3f50d6d89 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 14 Sep 2026 19:13:58 +0200 Subject: feat(desktop): KDE Connect module, tile and page State is polled from kdeconnect-state.sh because there is no D-Bus module to subscribe with. The poll runs only while the drawer is open: the tile is created when the panel loads and destroyed when it unloads, and it flips the module's polling flag. The laptop reports no battery plugin, so an absent charge is an empty field and renders as nothing, never as a zero. --- desktop/modules/kdeconnect/KdeConnectModule.qml | 163 ++++++++++++++++++++++++ desktop/modules/kdeconnect/KdeConnectPage.qml | 78 ++++++++++++ desktop/modules/kdeconnect/KdeConnectRow.qml | 71 +++++++++++ desktop/modules/kdeconnect/KdeConnectTile.qml | 45 +++++++ desktop/shell.qml | 2 + 5 files changed, 359 insertions(+) create mode 100644 desktop/modules/kdeconnect/KdeConnectModule.qml create mode 100644 desktop/modules/kdeconnect/KdeConnectPage.qml create mode 100644 desktop/modules/kdeconnect/KdeConnectRow.qml create mode 100644 desktop/modules/kdeconnect/KdeConnectTile.qml (limited to 'desktop') diff --git a/desktop/modules/kdeconnect/KdeConnectModule.qml b/desktop/modules/kdeconnect/KdeConnectModule.qml new file mode 100644 index 0000000..1175b81 --- /dev/null +++ b/desktop/modules/kdeconnect/KdeConnectModule.qml @@ -0,0 +1,163 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import QtQuick +import "../.." + +// Not always active: there is no push to listen to without a generic D-Bus +// module, so the state is polled while the drawer is open and not otherwise. +// The poll's lifetime is the tile's lifetime; see KdeConnectTile.qml. +Module { + id: mod + + name: "kdeconnect" + label: "KDE Connect" + alwaysActive: false + + // Rebuilt from kdeconnect-state.sh on every poll. See the script for the + // line protocol; the field order here must match it. + property var devices: [] + property var requests: [] + property string error: "" + + readonly property var paired: devices.filter(d => d.paired) + readonly property var unpaired: devices.filter(d => !d.paired) + readonly property var reachable: devices.filter(d => d.reachable) + readonly property bool anyReachable: reachable.length > 0 + + icon: "\uf10b" + active: mod.anyReachable + + function deviceName(id) { + const d = devices.find(x => x.id === id); + return d ? d.name : id; + } + + // The tile is created when the drawer panel loads, grid or page, and + // destroyed when it unloads, so its presence is the poll's on/off switch. + property bool polling: false + onPollingChanged: if (polling) refresh() + + function refresh() { + stateProc.devices = []; + stateProc.reqs = []; + stateProc.running = false; + stateProc.running = true; + } + + property Process stateProc: Process { + property var devices: [] + property var reqs: [] + command: ["bash", `${Quickshell.shellDir}/modules/kdeconnect/kdeconnect-state.sh`] + stdout: SplitParser { + onRead: line => { + const f = line.split("\t"); + if (f[0] === "request" && f[1]) + stateProc.reqs.push(f[1]); + else if (f[0] === "device" && f.length === 11) + stateProc.devices.push({ + id: f[1], name: f[2], type: f[3], + paired: f[4] === "1", reachable: f[5] === "1", + pairRequested: f[6] === "1", + pairRequestedByPeer: f[7] === "1", + key: f[8], charge: f[9], charging: f[10] === "1", + }); + } + } + onExited: code => { + if (code !== 0) { mod.error = "kdeconnectd is not responding"; return; } + mod.error = ""; + mod.devices = stateProc.devices; + mod.requests = stateProc.reqs; + } + } + + property Timer pollTimer: Timer { + interval: 5000 + repeat: true + running: mod.polling + onTriggered: mod.refresh() + } + + // --- actions --- + + // A failure after the drawer closed would otherwise go unseen, so it also + // raises a notification. Same cached-Process shape as the other modules. + function notify(title, body) { + notifyProc.command = ["notify-send", "--app-name=kdeconnect", + "--urgency=critical", "--icon=error", title, body]; + notifyProc.running = false; + notifyProc.running = true; + } + + property Process notifyProc: Process {} + + // One reusable action process. Actions are serialized; a second click + // while one runs replaces it rather than racing. A failure notifies. + function run(label, cmd) { + actProc.label = label; + actProc.command = cmd; + actProc.running = false; + actProc.running = true; + } + + property Process actProc: Process { + property string label: "" + stderr: StdioCollector { id: actErr } + onExited: code => { + if (code !== 0) + mod.notify(actProc.label + " failed", + actErr.text.trim() || ("exited " + code)); + mod.refresh(); + } + } + + function discover() { run("Refresh", ["kdeconnect-cli", "--refresh"]); } + function ring(id) { run("Ring", ["kdeconnect-cli", "--ring", "-d", id]); } + function clipboard(id) { + run("Clipboard", ["kdeconnect-cli", "--send-clipboard", "-d", id]); + } + function pair(id) { run("Pair", ["kdeconnect-cli", "--pair", "-d", id]); } + function unpair(id) { run("Unpair", ["kdeconnect-cli", "--unpair", "-d", id]); } + + // Accept and reject are the two actions kdeconnect-cli does not offer, so + // they go straight to the daemon. + function accept(id) { + run("Accept", ["qdbus6", "org.kde.kdeconnect", + `/modules/kdeconnect/devices/${id}`, + "org.kde.kdeconnect.device.acceptPairing"]); + } + function reject(id) { + run("Reject", ["qdbus6", "org.kde.kdeconnect", + `/modules/kdeconnect/devices/${id}`, + "org.kde.kdeconnect.device.cancelPairing"]); + } + + // --mount prints nothing and the mount point comes from --get-mount-point, + // so both steps run in one shell. The id is machine-generated hex; quoting + // it through JSON.stringify keeps the shell from being the injection path. + function mount(id) { + run("Mount", ["sh", "-c", + `kdeconnect-cli --mount -d ${JSON.stringify(id)} && ` + + `xdg-open "$(kdeconnect-cli --get-mount-point -d ${JSON.stringify(id)})"`]); + } + + tileContent: Component { KdeConnectTile { mod: mod } } + + page: Component { + Page { + title: "KDE Connect" + KdeConnectPage { width: parent.width; mod: mod } + } + } +} diff --git a/desktop/modules/kdeconnect/KdeConnectPage.qml b/desktop/modules/kdeconnect/KdeConnectPage.qml new file mode 100644 index 0000000..46a42f9 --- /dev/null +++ b/desktop/modules/kdeconnect/KdeConnectPage.qml @@ -0,0 +1,78 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick +import "../.." + +Column { + id: page + + required property var mod + + spacing: 14 + + Row { + width: page.width + spacing: 8 + + Button { + text: "Refresh" + onClicked: page.mod.discover() + } + } + + Text { + width: page.width + visible: page.mod.error !== "" + wrapMode: Text.Wrap + text: page.mod.error + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.red + } + + Text { + width: page.width + visible: page.mod.paired.length > 0 + text: "Paired" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.mod.paired + + KdeConnectRow { + required property var modelData + mod: page.mod + device: modelData + width: page.width + } + } + + Text { + width: page.width + visible: page.mod.unpaired.length > 0 + text: "Available" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.mod.unpaired + + KdeConnectRow { + required property var modelData + mod: page.mod + device: modelData + width: page.width + } + } +} diff --git a/desktop/modules/kdeconnect/KdeConnectRow.qml b/desktop/modules/kdeconnect/KdeConnectRow.qml new file mode 100644 index 0000000..45daae9 --- /dev/null +++ b/desktop/modules/kdeconnect/KdeConnectRow.qml @@ -0,0 +1,71 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick +import "../.." + +// One KDE Connect device. The type glyph is the phone for anything that is +// not a laptop or desktop, since the daemon's type strings are not a closed +// set. +Rectangle { + id: row + + required property var mod + required property var device + + implicitHeight: col.implicitHeight + 16 + radius: 8 + color: row.device.reachable ? Qt.alpha(Theme.accent, 0.10) : Qt.alpha(Theme.surface, 0.35) + + Column { + id: col + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } + spacing: 8 + + Item { + width: parent.width + implicitHeight: Math.max(name.implicitHeight, glyph.implicitHeight) + + Text { + id: glyph + anchors.verticalCenter: parent.verticalCenter + text: row.device.type === "desktop" ? "\uf109" : "\uf10b" + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + + Text { + id: name + anchors { left: glyph.right; leftMargin: 8; right: parent.right; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: row.device.name || row.device.id + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: row.device.reachable } + color: row.device.reachable ? Theme.accent : Theme.text + } + } + + Text { + id: status + width: parent.width + text: { + let s = row.device.paired + ? (row.device.reachable ? "Reachable" : "Not reachable") + : (row.device.reachable ? "Found" : "Not reachable"); + if (row.device.charge !== "") + s += " " + row.device.charge + "%" + (row.device.charging ? " charging" : ""); + return s; + } + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + elide: Text.ElideRight + } + } +} diff --git a/desktop/modules/kdeconnect/KdeConnectTile.qml b/desktop/modules/kdeconnect/KdeConnectTile.qml new file mode 100644 index 0000000..ae74fbc --- /dev/null +++ b/desktop/modules/kdeconnect/KdeConnectTile.qml @@ -0,0 +1,45 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick +import "../.." + +// The state line under the tile label, and the poll's lifetime: the tile +// exists exactly while the drawer panel is loaded, so it turns polling on and +// off. Without this the module would poll while the drawer is closed, which +// nothing needs. +Item { + id: tile + + required property var mod + + width: parent ? parent.width : implicitWidth + implicitHeight: state.implicitHeight + + Component.onCompleted: mod.polling = true + Component.onDestruction: mod.polling = false + + Text { + id: state + width: parent.width + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: tile.mod.anyReachable ? Theme.text : Theme.subtext + text: { + const r = tile.mod.reachable; + if (r.length === 1) + return r[0].charge !== "" ? r[0].name + " " + r[0].charge + "%" : r[0].name; + if (r.length > 1) return r.length + " connected"; + if (tile.mod.paired.length > 0) return "Offline"; + return "No devices"; + } + } +} diff --git a/desktop/shell.qml b/desktop/shell.qml index d191983..6a37827 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -14,6 +14,7 @@ import Quickshell.Io import Quickshell.Wayland import "modules/appearance" import "modules/bluetooth" +import "modules/kdeconnect" import "modules/mail" import "modules/network" import "modules/sound" @@ -38,6 +39,7 @@ ShellRoot { SoundModule {}, NetworkModule {}, BluetoothModule {}, + KdeConnectModule {}, MailModule {}, AppearanceModule {}, VmModule {}, -- cgit v1.2.3