kcd is a headless, concurrent, event-driven implementation of the KDE Connect v8 protocol written in Go. This document describes every major subsystem, how they fit together, and the design decisions behind them.
flowchart TD
Phone["📱 Android / iOS App<br/>(KDE Connect)"]
subgraph Daemon["kcd Daemon"]
direction TB
Discovery["🔍 Discovery<br/>(UDP + mDNS)"]
Transport["🔒 Transport<br/>(TCP/TLS)"]
Registry[("💾 Device Registry")]
Router{"🔀 Plugin Router"}
subgraph Plugins["Plugins"]
direction LR
P_Bat["🔋 Battery"]
P_Clip["📋 Clipboard"]
P_Share["📁 Share"]
P_Other["... others"]
end
EventBus[["📢 Event Bus<br/>(Pub/Sub)"]]
IPC["⚙️ IPC Server<br/>(Unix Socket)"]
end
CLI["💻 kcd CLI<br/>(kcdctl / watch)"]
Phone <-->|"UDP 1716"| Discovery
Phone <-->|"TCP 1716"| Transport
Discovery -.->|"Triggers dial"| Transport
Transport <-->|"JSON Packets"| Registry
Registry <--> Router
Router <--> Plugins
Plugins -->|"Publish"| EventBus
EventBus -->|"Stream"| IPC
IPC <--> CLI
style Daemon fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
style Phone fill:#cba6f7,stroke:#11111b,color:#11111b
style CLI fill:#a6e3a1,stroke:#11111b,color:#11111b
Devices are found via two parallel mechanisms that run concurrently:
Broadcaster sends the local identity packet to 255.255.255.255:1716 on a timer. For multi-homed machines it additionally sends directed broadcasts to each interface's subnet broadcast address, improving reliability on complex network setups.
Listener binds to 0.0.0.0:1716 and parses every incoming UDP packet. Packets whose type is not kdeconnect.identity or whose deviceId matches the local device are silently dropped.
The broadcast interval is adaptive: when shouldReduce() returns true (all known devices already connected) the interval steps up to 60 seconds. Broadcast is controlled by a BroadcasterController which is off by default — it only starts during kcd pair (listen mode) and stops when pairing completes. The UDP listener is always active, so paired devices reconnect without any broadcast.
At startup the Broadcaster registers the local device as a Zeroconf service with the libp2p/zeroconf/v2 library. TXT records carry id, name, type, and protocol fields per the KDE Connect spec.
Listener.runMdnsDiscovery browses _kdeconnect._udp.local. and synthesises a protocol.Packet for every discovered peer — feeding it through the same onDeviceFound callback used by UDP. This makes mDNS transparent to the rest of the stack.
Why both? UDP broadcast covers the common case instantly. mDNS handles restricted networks (Docker bridges, enterprise Wi-Fi, newer Android versions) where broadcast is filtered.
The KDE Connect TLS handshake is non-standard: the TCP initiator acts as TLS server, and the acceptor acts as TLS client. This is the opposite of conventional TLS and must be handled correctly.
Listener accepts inbound TCP connections on port 1716. Each accepted connection is handed off to a goroutine that reads the initial plaintext identity packet, upgrades the connection to TLS (as client), and calls the registered OnConnect callback.
When a device is discovered via UDP/mDNS, the daemon dials out to the device's TCP port. The dialling side acts as TLS server (providing the local certificate) and the accepting side acts as TLS client — the reverse of conventional roles.
transport.Conn wraps a net.Conn with buffered JSON framing. Packets are newline-delimited JSON. The ReadPacket / WritePacket methods acquire from the protocol.PacketPool to minimise allocations on the hot path.
On first run, cert.go generates a 2048-bit RSA self-signed certificate stored at the paths given in config (cert_file, key_file). On subsequent runs the certificate is loaded from disk. The SHA-256 fingerprint of the peer's certificate is compared during pairing to establish trust.
All on-wire data is JSON. The top-level envelope is:
{
"id": 1234567890,
"type": "kdeconnect.battery",
"body": { ... }
}protocol.Packet models this envelope. The Body field is kept as json.RawMessage to defer parsing until the routing plugin claims it, avoiding unnecessary allocations.
A sync.Pool (PacketPool) is used for Packet objects; callers must call ReleasePacket after use.
protocol.IdentityBody models the identity exchange that every device sends on first contact. It advertises the device's ID, name, type, protocol version, TCP port, and the list of incoming/outgoing plugin types it supports.
A Device wraps an active TCP connection with:
- Identity fields (ID, Name, Type, CertFP)
- A
statefield (Unpaired, PairRequested, PairRequestedByPeer, Paired) protected bysync.RWMutex - A
sender.go— a goroutine-safe write queue that serialises all outbound packets, preventing concurrent writes to the underlying TCP connection IsConnected()— true when the underlying connection is alive
Registry holds all known devices (both connected and remembered-from-disk). It is safe for concurrent reads and writes. On disk, device state is persisted to $XDG_STATE_HOME/kcd/devices.json so that paired devices survive daemon restarts.
stateDiagram-v2
direction TB
[*] --> Unpaired
Unpaired --> PairRequested : Local initiates
PairRequested --> Paired : Peer accepts
PairRequested --> Unpaired : Peer rejects / timeout
Unpaired --> PairRequestedByPeer : Peer initiates
PairRequestedByPeer --> Paired : Local accepts
PairRequestedByPeer --> Unpaired : Local rejects
Paired --> Unpaired : Unpair
Auto-reconnect: on Disconnect, if State == Paired and LastIP != nil, reconnectWithBackoff is spawned. It dials LastIP with exponential backoff (1s → 5min), stopping when the device reconnects, is unpaired, or the daemon shuts down.
Every feature implements Plugin:
type Plugin interface {
Name() string
IncomingTypes() []string // packet types this plugin handles
OutgoingTypes() []string // packet types it sends
Handle(ctx context.Context, dev device.Sender, pkt *protocol.Packet) error
OnConnect(dev device.Sender)
OnDisconnect(dev device.Sender)
Timeout() time.Duration
}plugin.Registry.Dispatch receives every inbound packet and calls Handle on the registered plugin for that pkt.Type. Dispatch is sequential per device — one packet at a time per connection. Any plugin that performs I/O (disk writes, subprocess execution, D-Bus calls) must spawn a goroutine internally, so it doesn't block the TCP read loop.
Plugin execution is wrapped in a context with the plugin's declared Timeout() deadline.
| Package | Types handled | Notes |
|---|---|---|
battery |
kdeconnect.battery |
Requests battery on connect; handles thresholdEvent (low/full) with notify-send + battery.threshold event; requires bus and logger in constructor |
clipboard |
kdeconnect.clipboard, kdeconnect.clipboard.connect |
Requests phone clipboard on connect via kdeconnect.clipboard.connect; echo-back prevention via separate lastPushedContent field; lastTimestamp and lastContent protected by same mutex |
findmyphone |
kdeconnect.findmyphone.request |
Runs paplay or aplay |
lockdevice |
kdeconnect.lock.request |
Calls loginctl lock/unlock-session |
mousepad |
kdeconnect.mousepad.request |
ydotool (Wayland) / xdotool (X11) |
mpris |
kdeconnect.mpris.request, kdeconnect.mpris |
Native D-Bus via godbus; controls any MPRIS2 player. Discovers players via D-Bus NameOwnerChanged signals. Receives phone NowPlaying via kdeconnect.mpris (requires Android notification access). Dual-sends mpris.request + mpris for legacy compatibility. |
notification |
kdeconnect.notification |
Downloads icon payload over TLS side-channel; per-app filter via SetFilters(); notify-send --help probe for --print-id support; tlsConfig + logger required in constructor |
pair |
kdeconnect.pair |
Manages the pairing handshake and certificate fingerprint verification |
ping |
kdeconnect.ping |
Fires ping.received; can be sent outbound |
runcommand |
kdeconnect.runcommand |
Executes commands from the [commands] config table |
sms |
kdeconnect.sms.messages, kdeconnect.sms.attachment_file |
Sends kdeconnect.sms.request, kdeconnect.sms.request_conversations, kdeconnect.sms.request_conversation, kdeconnect.sms.request_attachment |
sftp |
kdeconnect.sftp |
Parses multiPaths, pathNames, and errorMessage from the phone's response. Info() returns cached credentials + StorageVolume slices; Volumes() lists storage roots with human-readable names. Handle() logs errors when the phone returns errorMessage (e.g. missing storage permission). Mounts at server root to avoid chroot double-path bug; tracks mounts in mountPoints map; Unmount() calls fusermount3/fusermount |
share |
kdeconnect.share.request |
Streaming file receive + URL/text handling; fires progress events |
systemvolume |
kdeconnect.systemvolume |
Accepts bus; publishes volume.update on volume/mute changes |
telephony |
kdeconnect.telephony |
Fires telephony.ringing, .missed, .canceled |
connectivity |
kdeconnect.connectivity_report |
Fires connectivity.update |
Bus is a non-blocking, fan-out pub/sub system. Plugins publish typed events; IPC streams and external watchers subscribe.
bus.Publish(events.TypeBatteryUpdate, deviceID, map[string]any{
"charge": 85,
"charging": true,
})Each Subscriber holds a buffered channel (capacity 64). If a subscriber falls behind, events are dropped (with a warning) rather than blocking the publisher. Subscribers can filter by event type; an empty filter receives all events.
| Event | Trigger |
|---|---|
device.added |
New device seen for the first time |
device.removed |
Device unpaired and removed from registry |
device.connected |
TCP connection established |
device.disconnected |
TCP connection closed |
pair.requested |
Incoming pair request from peer |
pair.accepted |
Pairing completed |
pair.rejected |
Pairing denied or cancelled |
battery.update |
New battery reading received |
battery.threshold |
Low (event=1) or full (event=2) battery threshold reached |
notification |
Notification forwarded from phone |
notification.canceled |
Phone dismissed a notification |
share.progress |
File transfer progress update |
share.complete |
File transfer finished |
share.text |
Plain text received via Share plugin |
share.url |
URL received via Share plugin |
ping.received |
Ping packet arrived |
telephony.ringing |
Incoming call |
telephony.missed |
Missed call |
telephony.canceled |
Call ended |
connectivity.update |
Signal strength report |
volume.update |
Desktop volume changed from phone |
sftp.mount |
SFTP credentials received |
sms.incoming |
SMS message received from phone (batch, one event per message) |
sms.attachment |
MMS attachment file downloaded to cache directory |
The daemon opens a Unix domain socket at $XDG_RUNTIME_DIR/kcd/kcd.sock (typically /run/user/1000/kcd/kcd.sock). The CLI (cmd/kcd) connects to this socket as a client.
Commands are single JSON objects followed by a newline:
{"cmd": "pair", "payload": {"deviceId": "a1b2..."}}Responses are also JSON:
{"ok": true}
{"ok": false, "error": "device not found"}
{"ok": true, "data": [...]}The CmdWatch command keeps the connection open and streams NDJSON events as they arrive on the event bus. The CLI's watch command applies optional filters sent in the WatchPayload and pipes the stream to stdout — making it composable with jq, grep, Waybar, etc.
Handler.HandleRequest dispatches built-in commands (devices, pair, unpair, ping). Additional commands are registered at startup via Handler.Register, keeping IPC extensible without modifying the core handler.
The kcd binary is both daemon and client. Sub-commands that don't require the daemon (just kcd daemon) connect to the Unix socket via pkg/client.Client.
kcd daemon — start the background daemon
kcd devices — list known/connected devices
kcd connect <ip> — manually connect by IP
kcd pair [<id>] — pair: with an ID sends a request; without one, listen mode (auto-accepts + Ctrl+C)
kcd unpair <id> — revoke trust
kcd ping <id> — send a ping
kcd battery <id> — fetch battery status
kcd share <id> <file> — send a file
kcd clipboard [id] — push local clipboard to phone
kcd sftp request <id> — request SFTP credentials
kcd sftp info <id> — show cached credentials and volumes
kcd sftp volumes <id> — list storage roots (multiPaths)
kcd sftp mount <id> — mount via sshfs
kcd sftp unmount <id> — unmount a previous mount
kcd run list <id> — list remote commands
kcd run exec <id> <key> — execute a remote command
kcd reply <id> <reply-id> <msg> — reply to a notification
kcd call mute <id> — mute an incoming call
kcd findmyphone <id> — ring the phone
kcd lock <id> — lock the desktop
kcd unlock <id> — unlock the desktop
kcd sms send <id> <number> <msg> — send an SMS
kcd sms conversations <id> — request all conversations (results via `kcd watch`)
kcd sms conversation <id> <thread> — request a specific conversation thread
kcd sms attachment <id> <part> <uid> — request an MMS attachment file
kcd watch [--events=...] [--json] — stream live events
daemon.Run orchestrates startup in this order:
- Validate config; ensure TLS certificate exists
- Load persisted device state from disk
- Start the event bus
- Register all enabled plugins
- Start the TCP listener (inbound connections)
- Start the IPC Unix socket server
- Start the UDP listener and mDNS browser (discovery listener — always on)
- Create the
BroadcasterControllerin stopped state (broadcast is off by default) - Block until context is cancelled (SIGINT / SIGTERM)
- Graceful shutdown: close listener, shutdown mDNS, stop broadcaster (if running)
kcd/
├── cmd/kcd/main.go — CLI entry point (daemon + client sub-commands)
├── pkg/client/client.go — Public client library (wraps Unix socket calls)
├── internal/
│ ├── cert/ — TLS certificate generation and loading
│ ├── config/ — TOML config loading, defaults, validation
│ ├── daemon/ — Startup orchestration; transport wiring
│ ├── device/ — Device struct, Registry, state machine, sender
│ ├── discovery/ — UDP broadcaster + listener; mDNS register + browse
│ ├── events/ — Non-blocking fan-out event bus
│ ├── ipc/ — Unix socket server, request handler, protocol types
│ ├── plugin/ — Plugin interface, registry, dispatcher
│ ├── plugins/ — One package per KDE Connect plugin
│ │ ├── battery/
│ │ ├── clipboard/
│ │ ├── connectivity/
│ │ ├── findmyphone/
│ │ ├── lockdevice/
│ │ ├── mousepad/
│ │ ├── mpris/
│ │ ├── notification/
│ │ ├── pair/
│ │ ├── ping/
│ │ ├── runcommand/
│ │ ├── sftp/
│ │ ├── share/
│ │ ├── sms/
│ │ ├── systemvolume/
│ │ └── telephony/
│ ├── protocol/ — Packet pool, identity, pair packet helpers
│ └── transport/ — TLS conn wrapper, TCP listener, plaintext bootstrap
├── packaging/ — systemd units, firewall rules, example config
├── desktop-integration/ — Waybar script, config, stylesheet + integration guide
├── scripts/ — install / uninstall / post-install hooks
└── Dockerfile
No CGo. The entire daemon is pure Go. This keeps cross-compilation trivial and the binary fully static-linkable.
No GUI / D-Bus session bus dependency. D-Bus is used optionally by the MPRIS and SystemVolume plugins (both fail gracefully if the session bus is unavailable). The MPRIS plugin uses native Go D-Bus bindings (godbus) — not the playerctl CLI — for full media player discovery and control. Other plugins use subprocess calls (wpctl, notify-send, wl-copy, ydotool, etc.) so the daemon can run in a headless session or inside a container.
Pool-based packet allocation. protocol.PacketPool eliminates per-packet heap allocation on the TCP read loop — important when receiving high-frequency events like mousepad movement.
Sequential plugin dispatch per device. Plugins handle packets one at a time per connection. This avoids data races in plugin state without requiring locks in individual plugins. Heavy operations (disk I/O, subprocesses) are explicitly goroutine-spawned inside the plugin.
Separate inbound/outbound clipboard dedup fields. ClipboardPlugin maintains lastContent (set by inbound packets from the phone) and lastPushedContent (set by outbound Push calls) as separate fields. Using one field would cause an inbound packet to reset the outbound dedup state, creating an echo-back bug where the phone's content gets sent straight back to it.
Progress event throttling. Share plugin progress events are rate-limited to one per 500ms via progressThrottle. Without this, a 1 GB transfer over a fast LAN fires ~32,000 events (one per 32 KB io.Copy buffer), flooding kcd watch consumers and consuming measurable CPU on the bus mutex.
Adaptive broadcast interval. Once all known devices are connected the broadcast cadence drops to 60 seconds. Broadcast is off by default (only active during kcd pair listen mode) so the daemon sits at 0.0% idle CPU in normal operation.
NDJSON event stream. The kcd watch event stream is newline-delimited JSON, making it directly composable with standard Unix tools (jq, grep, while read) and Waybar's exec module without any custom parsing.