aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-14 19:10:14 +0200
committerDanilo M. <danix@danix.xyz>2026-09-14 19:10:14 +0200
commitbdfbcb869b18b084d57008c9a80b2c7d5843b2f4 (patch)
tree678d8153e0d34654a31e40ad7dc3cdc9c4a0f23e /docs/superpowers/plans
parent94da5c8c5d6b7b592eaf885a50c8167a10513d9c (diff)
downloadquickshell-bdfbcb869b18b084d57008c9a80b2c7d5843b2f4.tar.gz
quickshell-bdfbcb869b18b084d57008c9a80b2c7d5843b2f4.zip
docs: KDE Connect module design and plan
Absorbs the kdeconnect-indicator into the drawer: status, battery, pairing, ping, clipboard send, file share, refresh and mount. State is polled from a qdbus6 helper script while the drawer is open, since quickshell 0.3.1 has no generic D-Bus module. The spec notes the phone-to-PC clipboard is the daemon's plugin, not the indicator's, so removing the indicator does not touch it.
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-09-14-kdeconnect.md1224
1 files changed, 1224 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-14-kdeconnect.md b/docs/superpowers/plans/2026-09-14-kdeconnect.md
new file mode 100644
index 0000000..82b0ba5
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-14-kdeconnect.md
@@ -0,0 +1,1224 @@
+# KDE Connect Module Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a `kdeconnect` module to the `desktop/` drawer, absorbing the `kdeconnect-indicator` tray icon: device status and battery, pairing, ping, clipboard send, file share, refresh and filesystem mount.
+
+**Architecture:** One `Module` under `desktop/modules/kdeconnect/` with a tile and a full-height page, following the existing contract. Quickshell 0.3.1 has no generic D-Bus module, so all state and actions go through two binaries: `qdbus6` for reading the daemon and for pair accept/reject, and `kdeconnect-cli` for the user actions. State is a helper script emitting tab-separated lines, polled every 5s while the drawer is open and not while it is closed.
+
+**Tech Stack:** Quickshell 0.3.1, Qt6 QML, the `org.kde.kdeconnect` D-Bus daemon via `qdbus6`, `kdeconnect-cli`, `QtQuick.Dialogs` for the share picker.
+
+**Spec:** `docs/superpowers/specs/2026-09-14-kdeconnect-design.md`
+
+## Global Constraints
+
+- Quickshell 0.3.1, Qt6 QML. Run configs with `qs -p ./desktop`. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f` (it matches the calling shell and kills it).
+- GPLv2 only. Every new `.qml` file begins with this exact header, no exceptions:
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// 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.
+```
+
+- Every new `.sh` file begins with this exact header, no exceptions:
+
+```bash
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# 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.
+```
+
+- Module files live under `desktop/modules/kdeconnect/` and reference root types (`Module`, `Page`, `Button`, `Theme`), so each uses `import "../.."`.
+- Shell-outs only through `qdbus6` and `kdeconnect-cli`. Quickshell 0.3.1 has no generic D-Bus module (only `Quickshell.DBusMenu`); do not look for a native binding.
+- `alwaysActive: false`. Polling runs only while the drawer is open.
+- Grid order in `shell.qml`: `Sound, Network, Bluetooth, KDE Connect, Mail, Appearance, Machines`.
+- No em dashes anywhere. No home paths in committed files. Nerd Font glyphs are written as `\uXXXX` and their bytes verified with `git diff`.
+- Reusing a `Process` needs `running = false` immediately before `running = true`.
+- Hot reload does not pick up a new component file. After a task that adds a `.qml` file, ask the user to restart the drawer shell, or make a content change to `desktop/shell.qml`; touching a file is not enough.
+- The phone-to-PC clipboard is the daemon's own plugin and is not built here. Do not add a code path for it.
+
+## Verified API Facts (probed on this machine, 2026-09-14)
+
+Do not re-probe these; they are measured, not assumed.
+
+- `qdbus6 org.kde.kdeconnect /modules/kdeconnect org.kde.kdeconnect.daemon.devices` prints one device id per line, and only an id. Zero devices prints nothing and exits 0. A daemon that is down exits non-zero.
+- Per device, `qdbus6 org.kde.kdeconnect /modules/kdeconnect/devices/<id> org.kde.kdeconnect.device.<prop>` returns the scalar: `name`, `type` (`phone` or `desktop`), `isPaired`, `isReachable`, `isPairRequested`, `isPairRequestedByPeer` (all lowercase `true`/`false`). `verificationKey` is a method with the same call shape.
+- The battery object is not present on every device. `qdbus6 .../devices/<id>/battery org.freedesktop.DBus.Properties.Get org.kde.kdeconnect.device.battery charge` prints an error to stderr and exits non-zero on a device without it (observed on `kalilaptop`, a desktop with no battery). Suppress stderr and treat empty stdout as absent, never as zero.
+- Incoming pairing requests: `qdbus6 org.kde.kdeconnect /modules/kdeconnect org.freedesktop.DBus.Properties.Get org.kde.kdeconnect.daemon pairingRequests`, a list of ids, empty when there are none, exit 0.
+- A full read over the two paired devices measured about 5ms.
+- `Quickshell.shellDir` is a string holding the full path to the shell root (the directory of `shell.qml`), so a script path is `` `${Quickshell.shellDir}/modules/kdeconnect/kdeconnect-state.sh` ``.
+- Glyph codepoints confirmed present in the Inconsolata Nerd Font cmap (the family `Theme.iconFamily` resolves to): phone `\uf10b`, laptop `\uf109`.
+- Smoke-check command, harness owns the process and the log is read, never a later `pgrep`:
+
+```bash
+timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean"
+```
+
+Expected: `clean`. The trailing `|| echo clean` is deliberate: grep exits 1 when it finds nothing. This briefly starts a second drawer instance; it dies with the timeout.
+
+---
+
+### Task 1: State script and its test
+
+**Files:**
+- Create: `desktop/modules/kdeconnect/kdeconnect-state.sh`
+- Create: `desktop/modules/kdeconnect/test-kdeconnect-state.sh`
+
+**Interfaces:**
+- Consumes: nothing from other tasks.
+- Produces: `kdeconnect-state.sh`, invoked as `bash kdeconnect-state.sh`, printing to stdout one `request<TAB><id>` line per incoming pairing request, then one line per device:
+ `device<TAB><id><TAB><name><TAB><type><TAB><paired 0|1><TAB><reachable 0|1><TAB><pairRequested 0|1><TAB><pairRequestedByPeer 0|1><TAB><verificationKey or empty><TAB><charge or empty><TAB><charging 0|1>`
+ Eleven tab-separated fields on a device line. Exit 0 on success, non-zero with no output when the daemon query fails. Task 2 parses this exact shape.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `desktop/modules/kdeconnect/test-kdeconnect-state.sh`:
+
+```bash
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# 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.
+#
+# The one runnable check for kdeconnect-state.sh. It puts a stub qdbus6 on
+# PATH and runs the real script against it, so nothing here touches the live
+# daemon and the whole thing runs in milliseconds.
+#
+# Fixture: idA a reachable phone with a battery, idB a laptop whose name
+# contains a tab and which has no battery plugin, idC an unpaired phone with
+# an incoming pairing request and a verification key.
+#
+# Usage: ./test-kdeconnect-state.sh (exit 0 = all passed)
+
+set -u
+
+here="$(cd "$(dirname "$0")" && pwd)"
+stub="$(mktemp -d)"
+trap 'rm -rf "$stub"' EXIT
+
+cat > "$stub/qdbus6" <<'STUB'
+#!/bin/bash
+path="$2"; m="$3"; a="$4"; b="$5"
+
+if [[ "${FAIL_DEVICES:-}" == 1 && "$m" == "org.kde.kdeconnect.daemon.devices" ]]; then
+ exit 1
+fi
+if [[ "${ZERO_DEVICES:-}" == 1 && "$m" == "org.kde.kdeconnect.daemon.devices" ]]; then
+ exit 0
+fi
+
+case "$path:$m" in
+ "/modules/kdeconnect:org.kde.kdeconnect.daemon.devices")
+ printf '%s\n' idA idB idC ;;
+ "/modules/kdeconnect:org.freedesktop.DBus.Properties.Get")
+ if [[ "$a" == "org.kde.kdeconnect.daemon" && "$b" == "pairingRequests" ]]; then
+ [[ "${ZERO_DEVICES:-}" == 1 ]] || printf '%s\n' idC
+ fi ;;
+ "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.name") printf 'Pixel 6 Pro\n' ;;
+ "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.type") printf 'phone\n' ;;
+ "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.isPaired") printf 'true\n' ;;
+ "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.isReachable") printf 'true\n' ;;
+ "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.isPairRequested") printf 'false\n' ;;
+ "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.isPairRequestedByPeer") printf 'false\n' ;;
+ "/modules/kdeconnect/devices/idA/battery:org.freedesktop.DBus.Properties.Get")
+ [[ "$b" == charge ]] && printf '50\n' || printf 'true\n' ;;
+ "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.name") printf 'kali\tlaptop\n' ;;
+ "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.type") printf 'desktop\n' ;;
+ "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.isPaired") printf 'true\n' ;;
+ "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.isReachable") printf 'false\n' ;;
+ "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.isPairRequested") printf 'false\n' ;;
+ "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.isPairRequestedByPeer") printf 'false\n' ;;
+ "/modules/kdeconnect/devices/idB/battery:org.freedesktop.DBus.Properties.Get") exit 1 ;;
+ "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.name") printf 'New Phone\n' ;;
+ "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.type") printf 'phone\n' ;;
+ "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.isPaired") printf 'false\n' ;;
+ "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.isReachable") printf 'true\n' ;;
+ "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.isPairRequested") printf 'false\n' ;;
+ "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.isPairRequestedByPeer") printf 'true\n' ;;
+ "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.verificationKey") printf '1826C6D4\n' ;;
+ "/modules/kdeconnect/devices/idC/battery:org.freedesktop.DBus.Properties.Get") exit 1 ;;
+esac
+STUB
+chmod +x "$stub/qdbus6"
+
+pass=0
+fail=0
+
+check() {
+ local name="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ pass=$((pass + 1))
+ else
+ fail=$((fail + 1))
+ printf 'FAIL: %s\n want: %q\n got: %q\n' "$name" "$want" "$got"
+ fi
+}
+
+# The full line protocol for the fixture. The name with a tab is one field
+# after sanitizing; the two battery-less devices leave the charge and
+# charging fields empty and zero respectively.
+want="$(printf 'request\tidC')
+$(printf 'device\tidA\tPixel 6 Pro\tphone\t1\t1\t0\t0\t\t50\t1')
+$(printf 'device\tidB\tkali laptop\tdesktop\t1\t0\t0\t0\t\t\t0')
+$(printf 'device\tidC\tNew Phone\tphone\t0\t1\t0\t1\t1826C6D4\t\t0')"
+
+got="$(PATH="$stub:$PATH" bash "$here/kdeconnect-state.sh")"
+check "the line protocol" "$want" "$got"
+
+# A daemon that is down is not an empty device list.
+got="$(PATH="$stub:$PATH" FAIL_DEVICES=1 bash "$here/kdeconnect-state.sh")"
+rc=$?
+check "a failed query prints nothing" "" "$got"
+check "a failed query exits non-zero" "1" "$([[ $rc -ne 0 ]] && echo 1 || echo 0)"
+
+# A reachable daemon with no devices is a valid empty list.
+got="$(PATH="$stub:$PATH" ZERO_DEVICES=1 bash "$here/kdeconnect-state.sh")"
+rc=$?
+check "zero devices prints nothing" "" "$got"
+check "zero devices exits zero" "0" "$rc"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `bash desktop/modules/kdeconnect/test-kdeconnect-state.sh`
+Expected: FAIL, the script under test does not exist yet.
+
+- [ ] **Step 3: Write `kdeconnect-state.sh`**
+
+Create `desktop/modules/kdeconnect/kdeconnect-state.sh`:
+
+```bash
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# 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.
+#
+# One state dump for the KDE Connect module: the daemon's devices and any
+# pending pairing requests, as tab-separated lines on stdout.
+#
+# device<TAB>id<TAB>name<TAB>type<TAB>paired<TAB>reachable<TAB>pairRequested<TAB>pairRequestedByPeer<TAB>key<TAB>charge<TAB>charging
+# request<TAB>id
+#
+# request lines come first. A daemon query that fails exits non-zero with no
+# output, so the caller keeps its last list rather than showing an empty one;
+# a query that succeeds with no devices exits zero and also prints nothing.
+#
+# The test stubs qdbus6 on PATH; see test-kdeconnect-state.sh.
+
+set -u
+
+BUS=org.kde.kdeconnect
+ROOT=/modules/kdeconnect
+QDBUS=qdbus6
+
+# A scalar property or method value for a device, empty on any failure.
+prop() {
+ "$QDBUS" "$BUS" "$ROOT/devices/$1" "org.kde.kdeconnect.device.$2" 2>/dev/null
+}
+
+bool() {
+ case "$(prop "$1" "$2")" in
+ true) printf 1 ;;
+ *) printf 0 ;;
+ esac
+}
+
+# A device name is attacker-adjacent text; a tab or newline in it must not
+# break the line protocol.
+sanitize() {
+ printf '%s' "$1" | tr '\t\n' ' '
+}
+
+emit_device() {
+ local id="$1" name type paired reachable pr pbp key charge charging
+ name="$(sanitize "$(prop "$id" name)")"
+ type="$(sanitize "$(prop "$id" type)")"
+ paired="$(bool "$id" isPaired)"
+ reachable="$(bool "$id" isReachable)"
+ pr="$(bool "$id" isPairRequested)"
+ pbp="$(bool "$id" isPairRequestedByPeer)"
+
+ # The key only means anything while a pairing is in flight.
+ key=""
+ if [[ "$pr" == 1 || "$pbp" == 1 ]]; then
+ key="$(sanitize "$(prop "$id" verificationKey)")"
+ fi
+
+ # The battery plugin is not on every device: a desktop without a battery
+ # object already returned qdbus an error. An absent query is an empty
+ # field, never a zero.
+ charge="$("$QDBUS" "$BUS" "$ROOT/devices/$id/battery" \
+ org.freedesktop.DBus.Properties.Get \
+ org.kde.kdeconnect.device.battery charge 2>/dev/null)"
+ [[ "$charge" =~ ^[0-9]+$ ]] || charge=""
+
+ charging=0
+ if [[ -n "$charge" ]]; then
+ case "$("$QDBUS" "$BUS" "$ROOT/devices/$id/battery" \
+ org.freedesktop.DBus.Properties.Get \
+ org.kde.kdeconnect.device.battery isCharging 2>/dev/null)" in
+ true) charging=1 ;;
+ esac
+ fi
+
+ printf 'device\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
+ "$id" "$name" "$type" "$paired" "$reachable" "$pr" "$pbp" \
+ "$key" "$charge" "$charging"
+}
+
+main() {
+ local ids
+ # Command substitution carries qdbus's exit status; a failed list is the
+ # error case, not an empty device list.
+ ids="$("$QDBUS" "$BUS" "$ROOT" org.kde.kdeconnect.daemon.devices 2>/dev/null)" || exit 1
+
+ local reqs r
+ reqs="$("$QDBUS" "$BUS" "$ROOT" org.freedesktop.DBus.Properties.Get \
+ org.kde.kdeconnect.daemon pairingRequests 2>/dev/null)" || exit 1
+ for r in $reqs; do
+ printf 'request\t%s\n' "$r"
+ done
+
+ local id
+ for id in $ids; do
+ [[ -n "$id" ]] && emit_device "$id"
+ done
+}
+
+main "$@"
+```
+
+- [ ] **Step 4: Make both scripts executable**
+
+Run: `chmod +x desktop/modules/kdeconnect/kdeconnect-state.sh desktop/modules/kdeconnect/test-kdeconnect-state.sh`
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `bash desktop/modules/kdeconnect/test-kdeconnect-state.sh`
+Expected: `5 passed, 0 failed`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/modules/kdeconnect/kdeconnect-state.sh desktop/modules/kdeconnect/test-kdeconnect-state.sh
+git commit -m "feat(desktop): KDE Connect state script
+
+Quickshell 0.3.1 has no generic D-Bus module, so device state comes from
+qdbus6 shell-outs. The script emits a tab line protocol for the QML side
+and is checked by a stub-daemon test, since the live daemon is not an
+oracle an agent can rely on.
+
+A failed daemon query exits non-zero with no output so the module keeps
+its last list, which is not the same as a valid empty device list."
+```
+
+---
+
+### Task 2: Module, tile, and a read-only page
+
+**Files:**
+- Create: `desktop/modules/kdeconnect/KdeConnectModule.qml`
+- Create: `desktop/modules/kdeconnect/KdeConnectTile.qml`
+- Create: `desktop/modules/kdeconnect/KdeConnectPage.qml`
+- Create: `desktop/modules/kdeconnect/KdeConnectRow.qml`
+- Modify: `desktop/shell.qml` (import and module registry)
+
+**Interfaces:**
+- Consumes: `kdeconnect-state.sh` from Task 1; the `Module`, `Page`, `Button`, `Theme` types.
+- Produces: `KdeConnectModule` exposing `devices` (array of `{id, name, type, paired, reachable, pairRequested, pairRequestedByPeer, key, charge, charging}`), `requests` (array of id strings), `error`, `paired`, `unpaired`, `reachable`, `anyReachable`, `polling`, `refresh()`, `notify(title, body)`, `discover()`, `deviceName(id)`, `run(label, cmd)`, and (Task 4) `pair`, `unpair`, `ring`, `clipboard`, `mount`, `accept`, `reject`, and (Task 5) `share`. Tile type `KdeConnectTile { mod: ... }`, page type `KdeConnectPage { mod: ... }`, row type `KdeConnectRow { mod: ..., device: ... }`.
+
+- [ ] **Step 1: Create `KdeConnectModule.qml`**
+
+In this task the action functions are present but only `discover()` is called by the page; Tasks 3 and 4 add the buttons that use the rest.
+
+```qml
+// <GPLv2 header>
+
+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 }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Create `KdeConnectTile.qml`**
+
+```qml
+// <GPLv2 header>
+
+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";
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Create `KdeConnectRow.qml` (read-only identity and status; actions are Task 3)**
+
+```qml
+// <GPLv2 header>
+
+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
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Create `KdeConnectPage.qml` (list only; actions and pairing land in Tasks 3 and 4)**
+
+```qml
+// <GPLv2 header>
+
+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
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Register the module in `shell.qml`**
+
+Add the import beside the others:
+
+```qml
+import "modules/kdeconnect"
+```
+
+Add the module to the registry between Bluetooth and Mail:
+
+```qml
+ modules: [
+ SoundModule {},
+ NetworkModule {},
+ BluetoothModule {},
+ KdeConnectModule {},
+ MailModule {},
+ AppearanceModule {},
+ VmModule {},
+ ]
+```
+
+- [ ] **Step 6: Smoke-check the config loads**
+
+Run the Global Constraints smoke-check. Expected: `clean`.
+
+- [ ] **Step 7: Ask the user to confirm visually**
+
+Because hot reload does not pick up a new component file, ask the user to restart the drawer shell first. Then: the grid shows a seventh tile, a phone glyph labelled KDE Connect, whose line reads `Pixel 6 Pro 50%`, or `Offline` when the phone is away. Clicking it opens a page listing the paired devices and any discovered ones. The battery-less laptop shows no percentage rather than a zero. Confirm the glyph is a phone, not a box, and the laptop glyph is a laptop.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add desktop/modules/kdeconnect/KdeConnectModule.qml desktop/modules/kdeconnect/KdeConnectTile.qml desktop/modules/kdeconnect/KdeConnectPage.qml desktop/modules/kdeconnect/KdeConnectRow.qml desktop/shell.qml
+git commit -m "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."
+```
+
+---
+
+### Task 3: Row actions
+
+**Files:**
+- Modify: `desktop/modules/kdeconnect/KdeConnectRow.qml` (add the action row)
+
+**Interfaces:**
+- Consumes: `KdeConnectModule.run`, `KdeConnectModule.ring`, `clipboard`, `mount`, `unpair`, `discover`, `refresh` (all Task 2).
+- Produces: nothing new for later tasks except the populated row; Task 4 adds the Pair button and the verification key line to the same file.
+
+- [ ] **Step 1: Append the action row to `KdeConnectRow.qml`**
+
+Inside the `Column`, after the `status` Text, add:
+
+```qml
+ Row {
+ width: parent.width
+ spacing: 6
+
+ Button {
+ visible: row.device.reachable
+ text: "Ring"
+ onClicked: row.mod.ring(row.device.id)
+ }
+
+ Button {
+ visible: row.device.reachable
+ text: "Clipboard"
+ onClicked: row.mod.clipboard(row.device.id)
+ }
+
+ Button {
+ visible: row.device.reachable
+ text: "Mount"
+ onClicked: row.mod.mount(row.device.id)
+ }
+
+ Button {
+ visible: row.device.paired
+ text: "Unpair"
+ danger: true
+ onClicked: row.mod.unpair(row.device.id)
+ }
+ }
+```
+
+- [ ] **Step 2: Smoke-check the config loads**
+
+Run the Global Constraints smoke-check. Expected: `clean`. This edits an existing file, so hot reload picks it up; no restart needed.
+
+- [ ] **Step 3: Ask the user to confirm visually**
+
+Ask the user: on the reachable phone row, Ring makes the phone ring, Clipboard pushes the PC clipboard to the phone, Mount opens the phone's filesystem in the file manager, and Unpair removes the device after confirmation on the phone. Trigger one failure (for example Ring a device then immediately switch the phone's wifi off) and confirm it raises a notification. The buttons are absent on the offline laptop except Unpair.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/modules/kdeconnect/KdeConnectRow.qml
+git commit -m "feat(desktop): KDE Connect device actions
+
+Ring, clipboard, mount and unpair, all shell-outs since there is no D-Bus
+module. Actions share one serialized Process and a failure notifies, for
+the case where the drawer has closed by the time the command returns.
+
+Mount runs --mount and then --get-mount-point in one shell, because
+--mount prints nothing and the path is a second call."
+```
+
+---
+
+### Task 4: Pairing
+
+**Files:**
+- Modify: `desktop/modules/kdeconnect/KdeConnectRow.qml` (Pair button and the verification key line)
+- Modify: `desktop/modules/kdeconnect/KdeConnectPage.qml` (incoming request banner)
+
+**Interfaces:**
+- Consumes: `KdeConnectModule.pair`, `unpair`, `accept`, `reject`, `requests`, `deviceName`, `run` (Tasks 2 and 3).
+- Produces: nothing new; completes the pairing flow.
+
+- [ ] **Step 1: Add the Pair button to `KdeConnectRow.qml`**
+
+In the action `Row`, after the Unpair button, add:
+
+```qml
+ Button {
+ visible: !row.device.paired && row.device.reachable
+ text: "Pair"
+ onClicked: row.mod.pair(row.device.id)
+ }
+```
+
+- [ ] **Step 2: Add the verification key to the status line in `KdeConnectRow.qml`**
+
+Replace the `status` Text's `text` binding with:
+
+```qml
+ 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" : "");
+ if (row.device.key !== "")
+ s += " key " + row.device.key;
+ return s;
+ }
+```
+
+- [ ] **Step 3: Add the incoming request banner to `KdeConnectPage.qml`**
+
+Insert after the error Text and before the Paired heading:
+
+```qml
+ // Incoming pairing requests. The property persists while the drawer is
+ // closed, so a request raised then is still here when it opens.
+ Repeater {
+ model: page.mod.requests
+
+ Rectangle {
+ required property var modelData
+
+ width: page.width
+ implicitHeight: reqCol.implicitHeight + 16
+ radius: 8
+ color: Qt.alpha(Theme.accent, 0.12)
+ border.width: 1
+ border.color: Qt.alpha(Theme.accent, 0.4)
+
+ Column {
+ id: reqCol
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 }
+ spacing: 8
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: page.mod.deviceName(modelData) + " wants to pair"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.text
+ }
+
+ Row {
+ spacing: 8
+
+ Button {
+ text: "Accept"
+ onClicked: page.mod.accept(modelData)
+ }
+
+ Button {
+ text: "Reject"
+ danger: true
+ onClicked: page.mod.reject(modelData)
+ }
+ }
+ }
+ }
+ }
+```
+
+- [ ] **Step 4: Smoke-check the config loads**
+
+Run the Global Constraints smoke-check. Expected: `clean`.
+
+- [ ] **Step 5: Ask the user to confirm visually**
+
+Ask the user: on the laptop (paired but offline, shown under Paired) press Unpair, then Refresh and Pair on it while it is in range; confirm the laptop's own confirmation prompt appears and the device returns to Paired and reachable. Then from the phone, ask to pair with this PC, and confirm the drawer shows a `wants to pair` banner with Accept and Reject, that Accept completes it, and that during the flow the row shows a `key` you can compare with the phone.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/modules/kdeconnect/KdeConnectRow.qml desktop/modules/kdeconnect/KdeConnectPage.qml
+git commit -m "feat(desktop): KDE Connect pairing
+
+Outgoing pair and unpair go through kdeconnect-cli; accepting and
+rejecting an incoming request go straight to the daemon, since the CLI
+has no verb for them. The verification key is shown on the row while a
+pairing is in flight so the two ends can be compared.
+
+An incoming request is not noticed while the drawer is closed, because
+the poll only runs when it is open; the daemon keeps the request, so it
+is there on the next open."
+```
+
+---
+
+### Task 5: File share
+
+**Files:**
+- Modify: `desktop/modules/kdeconnect/KdeConnectModule.qml` (the share dialog and `share(id)`)
+- Modify: `desktop/modules/kdeconnect/KdeConnectRow.qml` (the Share button)
+
+**Interfaces:**
+- Consumes: `KdeConnectModule.run` (Task 2).
+- Produces: `KdeConnectModule.share(id)`, `shareTarget`, `pathOf(url)`.
+
+- [ ] **Step 1: Add the share dialog to `KdeConnectModule.qml`**
+
+Add the import beside the others:
+
+```qml
+import QtQuick.Dialogs
+```
+
+Add before `tileContent`:
+
+```qml
+ // --- 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])
+ }
+```
+
+- [ ] **Step 2: Add the Share button to `KdeConnectRow.qml`**
+
+In the action `Row`, after Clipboard, add:
+
+```qml
+ Button {
+ visible: row.device.reachable
+ text: "Share"
+ onClicked: row.mod.share(row.device.id)
+ }
+```
+
+- [ ] **Step 3: Smoke-check the config loads**
+
+Run the Global Constraints smoke-check. Expected: `clean`. A load failure here means the `FileDialog` type is not usable on a `QtObject`; if that happens, say so and use the fallback in the next step instead of debugging it.
+
+- [ ] **Step 4: Ask the user to confirm the dialog works**
+
+Ask the user: press Share on the reachable phone. Confirm a file picker appears, takes focus, a file chosen is sent, and the phone receives it.
+
+If the picker does not appear or cannot take focus while the drawer holds the keyboard, replace the dialog with the fallback and re-check. Fallback, a text field in the page or an inline row field, modelled on `NetworkRow.qml`'s PSK prompt:
+
+```qml
+ // Replace the FileDialog with this in KdeConnectModule.qml: a path or URL
+ // the user pastes. The drawer's Exclusive keyboard grab is the reason the
+ // native dialog was dropped.
+ function sharePath(id, path) {
+ if (path === "") return;
+ mod.run("Share", ["kdeconnect-cli", "--share", path, "-d", id]);
+ }
+```
+
+and in `KdeConnectRow.qml`, a `TextInput` revealed by the Share button, whose `Keys.onReturnPressed` calls `row.mod.sharePath(row.device.id, text)`. Record the outcome in the commit message either way.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/kdeconnect/KdeConnectModule.qml desktop/modules/kdeconnect/KdeConnectRow.qml
+git commit -m "feat(desktop): share a file to a KDE Connect device
+
+A QtQuick.Dialogs FileDialog held on the module, not the page, so a
+closed drawer does not destroy it mid-pick. The path is decoded from the
+file:// URL before it reaches kdeconnect-cli."
+```
+
+---
+
+### Task 6: Documentation
+
+**Files:**
+- Create: `desktop/modules/kdeconnect/README.md`
+- Modify: `desktop/README.md` (module list, grid order, alwaysActive enumeration)
+
+**Interfaces:**
+- Consumes: nothing. Documentation only.
+- Produces: nothing executable.
+
+- [ ] **Step 1: Create `desktop/modules/kdeconnect/README.md`**
+
+```markdown
+# kdeconnect
+
+KDE Connect devices in the drawer, replacing the `kdeconnect-indicator` tray
+icon. Status, battery, pairing, ping, clipboard send, file share, refresh and
+filesystem mount.
+
+The tile names the reachable device and its battery when the battery plugin
+reports one, a count when several are reachable, `Offline` when paired devices
+exist but none is reachable, and `No devices` otherwise.
+
+The page lists paired devices, then any discovered unpaired ones, with a
+refresh control at the top and an incoming-pairing banner. A reachable row
+offers Ring, Clipboard, Share and Mount; a paired row offers Unpair; an
+unpaired reachable one offers Pair.
+
+## The daemon does the clipboard
+
+Quickshell 0.3.1 has no generic D-Bus module, only `Quickshell.DBusMenu`, so
+nothing here binds to `org.kde.kdeconnect`. State is read by `qdbus6` and
+actions run through `kdeconnect-cli`, with `qdbus6` for pair accept and reject,
+which the CLI does not expose.
+
+The phone-to-PC clipboard is not this module's. `kdeconnectd` loads the
+clipboard plugin and writes the system clipboard itself (through
+`KSystemClipboard`), and the indicator was never part of that path, so removing
+the indicator does not break it. If it fails on Hyprland it is the compositor
+refusing `set_selection` from a daemon with no keyboard focus, which no code
+here can fix.
+
+## Service lifetime
+
+`alwaysActive: false`. There is no push to listen to, so the state is polled
+every 5s while the drawer is open. The tile is created when the drawer panel
+loads and destroyed when it unloads, and it is the tile that turns polling on
+and off, so nothing polls while the drawer is closed. A consequence: an
+incoming pairing request that arrives while the drawer is closed is not noticed
+until it is opened, though the daemon keeps the request.
+
+## Not built
+
+SMS, notification forwarding, remote input, media control, presenter mode,
+find-this-device and remote commands. The daemon supports them and the
+indicator surfaced some; this module does not. Pairing failure comes from the
+`kdeconnect-cli` exit code, not the `pairingFailed` signal, since there is no
+D-Bus module to subscribe with.
+```
+
+- [ ] **Step 2: Update `desktop/README.md`**
+
+Add the module line in grid order, after the bluetooth line:
+
+```markdown
+ modules/kdeconnect/ devices, battery, pairing, ping, clipboard, share, mount
+```
+
+Change the geometry paragraph's grid order to include it:
+
+```markdown
+The bottom is a fixed, never-scrolled `Flow` grid: three columns at 180px
+minimum, wrapping and adding rows up to a 3x3 ceiling for the modules that
+exist, in the order Sound, Network, Bluetooth, KDE Connect, Mail, Appearance,
+Machines.
+```
+
+Then fix the sentences this module makes stale, in the same file:
+
+- the line "Sound, mail, vm, network and bluetooth each carry their own README" becomes "Sound, mail, vm, network, bluetooth and kdeconnect each carry their own README"
+- the line "Sound, mail, vm, network and bluetooth each add a service and a page on top of that same shape" becomes "Sound, mail, vm, network, bluetooth and kdeconnect each add a service and a page on top of that same shape"
+
+Leave the `alwaysActive` list line as it stands: kdeconnect is false, like vm, so the existing "Sound, mail, network and bluetooth are `alwaysActive: true`; vm is false." gains a companion. Change it to:
+
+```markdown
+Sound, mail, network and bluetooth are `alwaysActive: true`; vm and kdeconnect
+are false.
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/modules/kdeconnect/README.md desktop/README.md
+git commit -m "docs(desktop): document the kdeconnect module
+
+Records that the phone-to-PC clipboard is the daemon's plugin and not the
+indicator's, so removing the indicator does not break it, and that polling
+stops when the drawer closes."
+```
+
+---
+
+### Task 7: Remove the indicator, record the traps, final check
+
+**Files:**
+- Modify: `desktop/README.md` (the indicator removal note)
+- Modify: `AGENTS.md` (per-component notes)
+- Modify outside the repo: `~/.config/hypr/sections/autostart.lua` (user applies)
+
+**Interfaces:**
+- Consumes: the finished module.
+- Produces: nothing executable in the repo.
+
+- [ ] **Step 1: Ask the user to remove the indicator**
+
+The live Hyprland config is not in this repo. Ask the user to remove the `kdeconnect-indicator` line from `~/.config/hypr/sections/autostart.lua` and kill the running indicator. The system-wide `kdeconnectd` autostart stays, so the daemon and the phone-to-PC clipboard keep working. Record in the task report what was removed and from which file.
+
+- [ ] **Step 2: Add the removal note to `desktop/README.md`**
+
+Append to the "Hyprland and waybar" section:
+
+```markdown
+The drawer is the only KDE Connect surface, so the `kdeconnect-indicator`
+autostart line was removed in the same change. The `kdeconnectd` daemon is a
+separate system autostart and stays; the phone-to-PC clipboard is the daemon's,
+not the indicator's.
+```
+
+- [ ] **Step 3: Add the traps to `AGENTS.md`**
+
+Append these bullets to the per-component notes list:
+
+```markdown
+- **Quickshell 0.3.1 has no generic D-Bus module**, only `Quickshell.DBusMenu`.
+ KDE Connect state therefore comes from `qdbus6` shell-outs, not a binding,
+ which means there is no push and the drawer polls 5s while it is open. The
+ poll is gated on the tile's lifetime, since the tile exists exactly while the
+ drawer panel is loaded.
+- **`kdeconnectd` and `kdeconnect-indicator` are separate.** The daemon runs
+ from a system autostart; the indicator was started from the live Hyprland
+ config. The phone-to-PC clipboard is the daemon's clipboard plugin
+ (`KSystemClipboard`), so removing the indicator does not touch it.
+- **The KDE Connect battery plugin is not on every device.** `qdbus6` errors on
+ `.../devices/<id>/battery` for a device without it, so an absent battery is an
+ empty field, never a zero.
+- **`qdbus` resolves a bare `org.kde.kdeconnect.device.<name>` as either a
+ property or a method**, so `name`, `isReachable` and the `verificationKey()`
+ method all use the same call shape.
+```
+
+- [ ] **Step 4: Full smoke check and process count**
+
+```bash
+timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' ; echo "rc=$?"
+pgrep -cx qs
+```
+
+Expected: no error lines; `pgrep` reports the shells the user has running, checked only after the smoke check returns. Never conclude anything from a `pgrep` issued in a later tool call against a detached process.
+
+- [ ] **Step 5: Run both script tests**
+
+```bash
+bash desktop/modules/kdeconnect/test-kdeconnect-state.sh
+bash desktop/modules/mail/test-mail-notify.sh
+```
+
+Expected: `5 passed, 0 failed`, then the mail suite unchanged at 16 of 16.
+
+- [ ] **Step 6: Verify the glyph bytes**
+
+```bash
+grep -nP '[\x{E000}-\x{F8FF}]' desktop/modules/kdeconnect/*.qml ; echo "rc=$?"
+grep -n '\\u' desktop/modules/kdeconnect/*.qml
+```
+
+Expected: the first grep finds nothing (`rc=1`), so no raw private-use byte landed in the source; the second shows the two ASCII escapes `\uf10b` and `\uf109`. The committed source must contain the escape, not the codepoint.
+
+- [ ] **Step 7: Ask the user for the final visual pass**
+
+Ask the user to confirm: seven tiles in order with KDE Connect fourth; the tile shows the phone and battery, `Offline` when it is away; the page lists devices, refreshes, rings, sends the clipboard, shares a file and mounts the filesystem; pairing works in both directions with the key shown; and the phone-to-PC clipboard direction, tested once by sending from the phone and pasting on the PC.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add desktop/README.md AGENTS.md
+git commit -m "docs: record the KDE Connect traps
+
+No D-Bus module means shell-outs and no push; the daemon and the
+indicator are separate; the battery plugin is not on every device; and
+qdbus reads properties and methods through one call shape."
+```