# 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 request ``` `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 ` | | send clipboard | reachable | `kdeconnect-cli --send-clipboard -d ` | | share | reachable | file dialog, then `kdeconnect-cli --share -d ` | | mount | reachable | `kdeconnect-cli --mount -d `, then open the `--get-mount-point` path | | unpair | paired | `kdeconnect-cli --unpair -d ` | | pair | unpaired and reachable | `kdeconnect-cli --pair -d ` | 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/ 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 -d `. 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.