// 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 QtQuick.Dialogs 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 never interrupt one another: a // second action while one runs is queued until the first exits, so each // exit is reported under the label it was started with. Killing an // in-flight process to start a new one would fire onExited with the new // label and raise a false failure. property var queuedAction: null function run(label, cmd) { if (actProc.running) { queuedAction = { label: label, cmd: cmd }; return; } launch(label, cmd); } function launch(label, cmd) { actProc.label = label; actProc.command = cmd; 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(); if (mod.queuedAction !== null) { const q = mod.queuedAction; mod.queuedAction = null; mod.launch(q.label, q.cmd); } } } 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)})"`]); } // --- share --- // The dialog is held on the module, not the page, so closing the drawer // mid-pick does not destroy it under the user. property string shareTarget: "" function share(id) { mod.shareTarget = id; shareDialog.open(); } // A FileDialog hands back a file:// URL; kdeconnect-cli wants a path. function pathOf(u) { return decodeURIComponent(u.toString().replace(/^file:\/\//, "")); } property FileDialog shareDialog: FileDialog { title: "Share with device" fileMode: FileDialog.OpenFile onAccepted: mod.run("Share", ["kdeconnect-cli", "--share", mod.pathOf(selectedFile), "-d", mod.shareTarget]) } tileContent: Component { KdeConnectTile { kc: mod } } page: Component { Page { title: "KDE Connect" KdeConnectPage { width: parent.width; kc: mod } } } }