Skip to content

Latest commit

 

History

History
386 lines (283 loc) · 19.3 KB

File metadata and controls

386 lines (283 loc) · 19.3 KB

kcd Architecture

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.


High-Level Overview

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
Loading

1. Discovery (internal/discovery)

Devices are found via two parallel mechanisms that run concurrently:

UDP Broadcast

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.

mDNS / Zeroconf (_kdeconnect._udp)

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.


2. Transport (internal/transport)

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

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.

Outbound connections

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.

Conn

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.

Certificate management (internal/cert)

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.


3. Protocol (internal/protocol)

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.


4. Device (internal/device)

Device struct

A Device wraps an active TCP connection with:

  • Identity fields (ID, Name, Type, CertFP)
  • A state field (Unpaired, PairRequested, PairRequestedByPeer, Paired) protected by sync.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 (device.Registry)

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.

State machine

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
Loading

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.


5. Plugin System (internal/plugin)

Interface

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
}

Routing

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.

Implemented plugins

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

6. Event Bus (internal/events)

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 types

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

7. IPC (internal/ipc)

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.

Request / Response

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": [...]}

Event streaming (watch)

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 routing

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.


8. CLI (cmd/kcd)

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

9. Daemon Lifecycle (internal/daemon)

daemon.Run orchestrates startup in this order:

  1. Validate config; ensure TLS certificate exists
  2. Load persisted device state from disk
  3. Start the event bus
  4. Register all enabled plugins
  5. Start the TCP listener (inbound connections)
  6. Start the IPC Unix socket server
  7. Start the UDP listener and mDNS browser (discovery listener — always on)
  8. Create the BroadcasterController in stopped state (broadcast is off by default)
  9. Block until context is cancelled (SIGINT / SIGTERM)
  10. Graceful shutdown: close listener, shutdown mDNS, stop broadcaster (if running)

10. File Layout

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

Design Decisions

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.