aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-14-kdeconnect-design.md
blob: 20b6c7c5789da74ed03214a141a8b9551ed3038e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# KDE Connect module: design

A `kdeconnect` module under `desktop/`, absorbing the job of the
`kdeconnect-indicator` tray icon into the drawer. Status plus basic actions:
reachability, battery, pairing, ping, clipboard send, file share, refresh and
filesystem mount.

## Why

The drawer is now the only network and Bluetooth UI: `nm-applet` and the waybar
bluetooth module were removed in the previous project. `kdeconnect-indicator`
is the same class of loose indicator, a tray icon outside the shell that shows
device state nothing else shows. This module moves that state and the actions
worth having into the drawer and removes the indicator.

## Confirmed facts

- `kdeconnectd` runs from a system-wide autostart entry,
  `/etc/xdg/autostart/org.kde.kdeconnect.daemon.desktop`. It is not started by
  the indicator, so removing the indicator does not stop the daemon.
- `kdeconnect-indicator` is started from the live Hyprland config,
  `~/.config/hypr/sections/autostart.lua` (its parent process is Hyprland). That
  one line is the removal.
- Quickshell 0.3.1 exposes **no generic D-Bus QML module**, only
  `Quickshell.DBusMenu`. Everything here goes through the `kdeconnect-cli` and
  `qdbus6` binaries, the same ceiling as the bluetooth module's `bluetoothctl`
  pairing.
- The daemon API is complete for this: `org.kde.kdeconnect.daemon.devices()`
  lists device ids, `daemon.pairingRequests` is the incoming request list,
  each device object carries `name`, `type`, `isPaired`, `isReachable`,
  `isPairRequested`, `isPairRequestedByPeer` and `verificationKey`, and
  `.../battery` carries `charge` and `isCharging`. A full read over the two
  paired devices measured about 5ms.
- The clipboard plugin is loaded and enabled in `kdeconnectd`, and links
  `libKF6GuiAddons` (`KSystemClipboard`) and `Qt6Gui`. Phone to PC clipboard is
  the daemon's, not the indicator's, and is untouched by this work.

## Scope

In:

- A drawer tile with device state, and a page listing devices with actions.
- Pairing, unpairing, and accepting an incoming pairing request.
- Ping/find, send clipboard, share a file or URL, refresh/discovery, mount the
  device filesystem.
- Removing the indicator from autostart.

Out, documented as ceilings:

- SMS read or send, notification forwarding, remote input (keyboard, mouse,
  presenter, digitizer), media remote control, find-this-device, run-command.
  The daemon supports all of them and the indicator surfaced some. The drawer
  does not.
- Noticing an incoming pairing request while the drawer is closed. The poll
  only runs while the drawer is open; the request is still there when it opens.
- Phone to PC clipboard. It is the daemon's own plugin, nothing to build. If it
  fails on Hyprland it is because the compositor refuses `set_selection` from a
  daemon holding no keyboard focus, which no code in this module can fix.

## Architecture

Three layers, mirroring the existing modules.

**State, in `kdeconnect-state.sh`.** One helper script queries the daemon and
prints a line protocol on stdout. It does not watch: it is run on demand and on
a timer. It calls `qdbus6` for the device id list, then per device the scalar
properties and the battery properties, and prints one record per line,
tab-separated:

```
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>
request<TAB><id>
```

`request` lines come first, one per incoming pairing request. Tabs and newlines
inside a device name are replaced with spaces, so a hostile name cannot break
the protocol. The verification key is only queried for a device with a pairing
in flight, since it means nothing otherwise.

A daemon query that fails is a non-zero exit with no output, so the module
keeps the last list it had rather than rendering a clean empty one. A
successful query that returns no devices is a valid empty list and also prints
nothing, but exits zero; the exit code is the distinction. This is the notmuch
lesson from AGENTS.md: validate the shape rather than trusting the absence of
output.

**The module, in `KdeConnectModule.qml`.** `alwaysActive: false`. A `Process`
runs the script on a `SplitParser` and rebuilds a JS array of device objects
from the lines. A 5s `Timer` drives it. The timer is active only
while the drawer is open.

The tile drives that lifetime. The tile content is instantiated whenever the
drawer panel is loaded, whether the grid or a page is showing, and destroyed
when the drawer closes (the drawer's `LazyLoader` unloads its `PanelWindow`).
So the tile sets a `polling` flag on the module from `Component.onCompleted`
and clears it from `Component.onDestruction`, and the timer runs on that flag.
This refreshes once as the drawer opens and then every 5s while it is open,
with no polling while it is closed. Same spirit as the vm module gating its
stats poll on the page.

**The page, in `KdeConnectPage.qml`.** Full height, the standard `Page` header
with a back arrow, a refresh control, an incoming-pairing-request banner, and
the device list. `KdeConnectRow.qml` is one device.

## Tile

`KdeConnectTile.qml`. A phone glyph, verified present in the Inconsolata Nerd
Font cmap, rendered with `Theme.iconFamily` per the glyph rule in AGENTS.md.
Active accent while any device is reachable. `tileContent` shows the reachable
device name and its battery where there is one, the reachable count where there
are several, `Offline` where devices are paired but none reachable, and
`No devices` where none are paired.

## Page and rows

One row per device, paired first and unpaired discovered devices after. Row
content: a type glyph (phone or laptop), the name, a status line (Reachable /
Not reachable / Paired), and the battery percentage when the battery plugin
reports one. An absent battery renders nothing for that field, never a
substituted number, the libvirt rule.

Actions are per row, as small buttons:

| action | shown when | command |
|---|---|---|
| ping / find | reachable | `kdeconnect-cli --ring -d <id>` |
| send clipboard | reachable | `kdeconnect-cli --send-clipboard -d <id>` |
| share | reachable | file dialog, then `kdeconnect-cli --share <path> -d <id>` |
| mount | reachable | `kdeconnect-cli --mount -d <id>`, then open the `--get-mount-point` path |
| unpair | paired | `kdeconnect-cli --unpair -d <id>` |
| pair | unpaired and reachable | `kdeconnect-cli --pair -d <id>` |

Refresh is a page-level control, not per row: `kdeconnect-cli --refresh`.

## Pairing

Pairing is the reason the indicator cannot simply be deleted. The flow:

- Outgoing: a `pair` button on an unpaired device runs `kdeconnect-cli --pair`.
  The daemon asks the phone, the user confirms there, and the next poll shows
  the device paired.
- Incoming: `daemon.pairingRequests` lists ids that asked to pair with this
  machine. The page shows a banner naming the device with Accept and Reject.
  Accept calls `qdbus6 org.kde.kdeconnect
  /modules/kdeconnect/devices/<id> org.kde.kdeconnect.device.acceptPairing`,
  Reject calls `cancelPairing()`.
- The verification key (`verificationKey`) is shown on the row while a pairing
  is in flight, so the two keys can be compared.
- Unpair calls `unpair()` on the device object.

`isPairRequested` and `isPairRequestedByPeer` are read for display. The raw
`pairState` integer is not mapped in QML, since the booleans carry the meaning.

## File sharing and the dialog risk

`share` opens a `QtQuick.Dialogs` `FileDialog` and, on accept, runs
`kdeconnect-cli --share <path> -d <id>`. This is the one unproven piece: the
dialog is a separate top-level window, and the drawer holds the keyboard with
`WlrKeyboardFocus.Exclusive` on a layer surface. It is plausible the dialog
renders but takes no focus, or does not render at all. This is probed during
implementation. If it fails, the fallback is a text field for a path or URL,
which is one rung lazier and already good enough; the design does not hinge on
the dialog.

## Failure handling

Every action that fails raises `notify-send` with `--urgency=critical`, the
pattern the vm and bluetooth modules use, because the drawer may have closed by
the time the command returns. Pairing failure is read from the `kdeconnect-cli`
exit code and shown the same way; the `pairingFailed` signal is not watched,
since there is no generic D-Bus module to subscribe with. A failed `--mount`
notifies rather than silently opening nothing.

## Files

```
desktop/modules/kdeconnect/
  KdeConnectModule.qml
  KdeConnectTile.qml
  KdeConnectPage.qml
  KdeConnectRow.qml
  kdeconnect-state.sh
  test-kdeconnect-state.sh
  README.md
```

`desktop/shell.qml` gains the module in the registry. `desktop/README.md` gains
it in the module list, in grid order. With this tile the grid holds seven of
its ceiling of nine.

## Changes outside this repo

| file | change |
|---|---|
| `~/.config/hypr/sections/autostart.lua` | remove the `kdeconnect-indicator` line |

The system `kdeconnectd` autostart stays. No new blur rule is needed: the
drawer already has `blur-desktop`.

## Verification

The only headless oracle is `test-kdeconnect-state.sh`. It puts a stub `qdbus6`
on `PATH` that returns canned property dumps for a fixed two-device set, runs
`kdeconnect-state.sh` against it, and asserts the exact tab-separated output,
including a name carrying a tab to prove the sanitiser, a device with no
battery plugin to prove the empty field, and a device list of zero to prove the
error case emits nothing rather than a clean empty list. No shell, no daemon
needed.

Everything else is visual and is the user's to look at, per AGENTS.md. The
specifics to walk: the tile on open before and after a poll, a reachable phone
showing name and battery, an offline device, ping, a clipboard send landing on
the phone, a file share, a mount opening the folder, an outgoing pair, and an
incoming request accepted from the banner.

The phone to PC clipboard direction is checked once, by sending a clipboard
from the phone and pasting on the PC, to confirm whether Hyprland accepts the
daemon's selection. It is expected to work but is not guaranteed, and if it
does not, it is a Wayland focus limit rather than a fault in this module.

Process checks follow AGENTS.md: the binary is `qs`, `pkill -x qs` and
`pgrep -cx qs`, never `pkill -f`; a detached `qs` does not survive an agent's
tool call, so it is started so the harness owns it and confirmed from the log.