Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3d853d9
feat: switch Docker image to Alpine with entrypoint for permission ha…
bethropolis May 27, 2026
db1cc20
feat: switch Docker image to Alpine with entrypoint for permission ha…
bethropolis May 27, 2026
7ea1b27
sftp: add mountWithBody volume path parameter and RequestAndMountVolu…
bethropolis May 27, 2026
57394c2
cli: add kcd sftp browse subcommand
bethropolis May 27, 2026
b4e275a
docs: add IPC protocol and client developer guides, update CLI docs
bethropolis May 27, 2026
b5694ff
ci: remove kcd-bin.install from AUR config
bethropolis May 27, 2026
867e034
feat: auto-prune stale unpaired devices with configurable threshold
bethropolis Jun 8, 2026
024b131
fix: clean Nix build artifacts before GoReleaser
bethropolis Jun 8, 2026
41cb267
fix: restore flake.lock from HEAD instead of index
bethropolis Jun 8, 2026
327a090
fix: resolve merge conflict in release.yml
bethropolis Jun 8, 2026
3cd24c3
refactor: move Nix verification to post-release workflow
bethropolis Jun 8, 2026
839aae6
fix: resolve merge conflict — remove Nix steps from release.yml
bethropolis Jun 8, 2026
99143e9
style: fix gofmt alignment in config.go
bethropolis Jun 8, 2026
a7970d5
fix: prevent CPU spin from auto-reconnect backoff overflow
bethropolis Jun 17, 2026
e64c60b
fix: detect clipboard backend at runtime instead of wayland-1
bethropolis Jun 17, 2026
6907335
feat: add RemoteSystemVolume plugin (kcd volume)
bethropolis Jun 17, 2026
fec0429
fix: clipboard backend detection when WAYLAND_DISPLAY is unset
bethropolis Jul 14, 2026
8366d53
feat: make clipboard push-on-connect opt-in (default off)
bethropolis Jul 14, 2026
4ed2015
Merge branch 'main' into dev/next
bethropolis Jul 14, 2026
099a120
feat(mpris): keep now-playing fresh and cache album art
bethropolis Aug 7, 2026
14b5165
feat(notification): collapse re-posted popups and default to icon-less
bethropolis Aug 7, 2026
2eed0dd
fix: watch json-mode reconnect + clipboard nil-logger guard
bethropolis Aug 7, 2026
4bb4680
feat(notification): debounce cancel/re-post to stop popup flicker
bethropolis Aug 8, 2026
3f55c55
fix(clipboard): re-probe backend and bind unit to graphical session
bethropolis Aug 8, 2026
73b32fa
fix(daemon): escalate reconnect backoff across connection flaps
bethropolis Aug 8, 2026
c084773
fix(clipboard): keep subprocess deadline alive and surface real tool …
bethropolis Aug 8, 2026
0945a61
fix(clipboard): apply inbound clips and stop echoing them back to phone
bethropolis Aug 9, 2026
3faf285
Merge branch 'main' into dev/next
bethropolis Aug 12, 2026
adb1bf8
fix(clipboard): bound WaitDelay so forked clipboard helpers can't han…
bethropolis Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ These structural constraints must hold at all times:
| Device state (persisted pairs) | `~/.local/state/kcd/devices.json` (`$XDG_STATE_HOME/kcd/devices.json`) |
| TLS cert / key | `~/.config/kcd/cert.pem`, `~/.config/kcd/key.pem` |
| IPC Unix socket | `/run/user/<uid>/kcd/kcd.sock` (`$XDG_RUNTIME_DIR/kcd/kcd.sock`) |
| Album art cache | `~/.cache/kcd/art/` (`$XDG_CACHE_HOME/kcd/art`) — resolved `kdeconnect://` art URIs, keyed by `kdeArtHash` |
| Downloaded files | `~/Downloads/kcd/` (overridable via `download_dir` in config) |
| systemd user unit | `~/.config/systemd/user/kcd.service` |

Expand Down
2 changes: 1 addition & 1 deletion cmd/kcd/cli_watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ var watchCmd = &cli.Command{
err := cl.Watch(ctx, c.StringSlice("events"), ch)

// If err != nil, the connection failed or disconnected
if !isJSON && err != nil {
if err != nil {
if err == context.Canceled {
return nil
}
Expand Down
9 changes: 8 additions & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,14 @@ kcd watch --events=mpris.update
# bindsym XF86AudioNext exec kcd mpris next
```

> **Note:** The `mpris.update` event fires whenever the phone sends a now-playing state change (track change, play/pause toggle). Subscribe with `kcd watch --events=mpris.update`.
> **Note:** The `mpris.update` event fires whenever the phone sends a now-playing state change (track change, play/pause toggle). Subscribe with `kcd watch --events=mpris.update`. The daemon also re-requests now-playing every 5 seconds from devices with an **actively-playing** player, so state stays fresh for pure-push clients without polling — but events are deduplicated, so `mpris.update` only fires on real changes. Stopped/paused players are not polled (a stopped-but-alive track stays listed so it can be resumed), and when the phone removes a player from its `playerList` (session destroyed) the cached state is dropped and an empty `mpris.update` is emitted so the widget falls back to "no media playing".
>
> **Note:** Phone album art URIs (`kdeconnect:/artUri?...`) are resolved by the
> daemon: it fetches the art bytes from the phone, caches them to
> `$XDG_CACHE_HOME/kcd/art/<kdeArtHash>.<ext>`, and emits a loadable
> `file://` URL in `albumArtUrl` (both in `kcd mpris status --json` and in
> `mpris.update` events). A second `mpris.update` is published when the art
> arrives.

---

Expand Down
20 changes: 18 additions & 2 deletions docs/CLIENT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,6 @@ def on_mpris(device_id, payload):
print(f'Now playing: {payload["title"]} by {payload["artist"]}')
else:
print("Paused/stopped")

w.start()

# Keep main thread alive
Expand All @@ -267,11 +266,28 @@ except KeyboardInterrupt:
| `notification` | Push notification from device |
| `share.progress` | File transfer progress update |
| `share.complete` | File transfer finished |
| `mpris.update` | Now-playing state changed |
| `mpris.update` | Now-playing state changed (deduplicated — only on real changes) |
| `sms.incoming` | SMS/MMS received |
| `pair.requested` | Remote device wants to pair |
| `ping.received` | Ping from device |

> **Freshness:** the daemon re-requests now-playing from devices with an
> actively-playing player every 5 seconds, so a pure-push client (a widget watching the
> event stream, with no polling) receives the current track within one poll
> interval of subscribing — including mid-track mount, thanks to the initial
> event dump. Events are deduplicated: `mpris.update` only fires when the
> state actually changed, so the stream stays quiet between track changes.
> Stopped/paused players are not polled, and when the phone removes a player
> from its `playerList` (session destroyed) the cached state is dropped with
> an empty `mpris.update` — showing "no media playing".

> **Album art:** `mpris.update` payloads (and `kcd mpris status --json`)
> expose `albumArtUrl` as a loadable `file://` path once the daemon has
> fetched the art from the phone into `$XDG_CACHE_HOME/kcd/art/`. If the
> art is still being fetched or fails, the raw `kdeconnect:/artUri?...`
> URI is emitted instead — treat anything that isn't `http(s)://` or
> `file://` as "no art available" and show a placeholder.

See [`IPC_PROTOCOL.md §5`](IPC_PROTOCOL.md#5-event-types) for the full list.

---
Expand Down
65 changes: 61 additions & 4 deletions docs/IPC_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,14 @@ are delivered.
{"type":"mpris.update","deviceId":"...","timestamp":"...","payload":{...NowPlaying...}}
```

The daemon keeps now-playing state fresh by re-requesting it every 5
seconds from devices with an **actively-playing** player (see the
`mpris.update` section below), so this initial dump fires reliably for
mid-track state — a pure-push client can mount and see the current track
without polling. Stopped/paused players are deliberately not polled, so
they age past the 10-second gate and no ghost track is shown after a
reconnect.

3. **Live stream:** All matching events are streamed as they occur, one per
line, until the client disconnects or the daemon shuts down.

Expand Down Expand Up @@ -822,6 +830,25 @@ A notification was received from a device.
| `requestReplyId` | string | Present if the notification supports inline replies |
| `id` | string | Notification identifier |

> **Desktop popups:** the daemon shows each phone notification via `notify-send`.
> Popups render **without an icon by default** (`show_icons = false`); set
> `show_icons = true` to display the phone's app icon (downloaded via
> `fetch_icons` and reused across re-posts). Because Android re-posts a
> notification on every update with a stable `id` (e.g. a scrobbler's
> now-playing notification), the daemon replaces the existing desktop popup
> in place (`--replace-id`) so repeated updates collapse to one popup instead
> of flooding the screen — mirroring the reference desktop's
> `Notification::update()`. Disable with `replace_notifications = false`.
>
> When the phone **cancels** a notification (e.g. a scrobbler's now-playing
> popup torn down on pause), the daemon defers closing the desktop popup by
> `cancel_grace_ms` (default `1500`). If the same notification id is re-posted
> within that window (rapid play/pause toggling), the popup is updated in
> place instead of flickering closed and open. A real dismissal — no re-post —
> closes the popup after the grace window. Set `cancel_grace_ms = 0` to close
> immediately on cancel. The `notification.canceled` event is always emitted
> immediately on the cancel packet, regardless of the grace window.

#### `notification.canceled`

A notification was dismissed by the device.
Expand Down Expand Up @@ -1057,6 +1084,32 @@ this daemon to ring).

Now-playing state from a device's media player.

> **Album art:** when the phone advertises a `kdeconnect:/artUri?...` URI,
> the daemon requests the art bytes over a side channel and caches them to
> `$XDG_CACHE_HOME/kcd/art/<kdeArtHash>.<ext>`. Once fetched, `albumArtUrl`
> is emitted as a loadable `file://` path. A second `mpris.update` event is
> published when the art arrives. If the fetch is still in flight or fails,
> the original URI is left untouched — clients should fall back to a
> placeholder.

> **Freshness:** the daemon re-requests now-playing from every connected
> device with an **actively-playing** player every 5 seconds
> (`kdeconnect.mpris.request` with `requestNowPlaying: true`). Responses are
> deduplicated — an event is only emitted when the state actually changes.
> This keeps `pos`/state current for pure-push clients (widgets, Waybar)
> that never poll the CLI. Devices that haven't reported a player yet, or
> whose player is stopped/paused, are not polled — a stopped-but-alive
> player keeps its last pushed track (so it can be resumed), but stops being
> refreshed and ages out of the 10-second initial-dump gate.

> **Session teardown:** when the phone reports a `playerList` that no longer
> contains the device's currently-tracked player (the media session was
> destroyed, e.g. the app was swiped away), the daemon drops the cached
> state and publishes an empty `mpris.update` (`{...NowPlaying...}` with no
> title) so watchers fall back to "no media playing". An empty `playerList`
> (all sessions destroyed) has the same effect. A stopped-but-alive session
> is **not** cleared — only sessions removed from `playerList` are.

**Payload:**

```json
Expand All @@ -1065,7 +1118,7 @@ Now-playing state from a device's media player.
"title": "Song Title",
"artist": "Artist Name",
"album": "Album Name",
"albumArtUrl": "https://i.scdn.co/image/...",
"albumArtUrl": "file:///home/user/.cache/kcd/art/1141556203.jpg",
"url": "spotify:track:...",
"length": 240000,
"pos": 45000,
Expand All @@ -1083,6 +1136,10 @@ Now-playing state from a device's media player.
}
```

The extension is sniffed from the received bytes (JPEG/PNG/GIF/WebP),
defaulting to `.jpg`. The cache directory holds at most 500 files and is
cleared when it overflows.

---

## 6. Outbound Packet Reference
Expand All @@ -1102,7 +1159,7 @@ who may want to implement a full network-level implementation.
| `kdeconnect.clipboard.connect` | Clipboard | Push clipboard with timestamp on connect |
| `kdeconnect.mousepad.keyboardstate` | Mousepad | Advertise keyboard capability on connect |
| `kdeconnect.mpris` | MPRIS | Player list, NowPlaying state, seek positions, album art (broadcast + request-reply) |
| `kdeconnect.mpris.request` | MPRIS | Request player list, now-playing, volume; send control actions |
| `kdeconnect.mpris.request` | MPRIS | Request player list, now-playing, volume, album art; send control actions |
| `kdeconnect.notification.reply` | Notification | Reply to a notification with inline reply support |
| `kdeconnect.notification` | RunCommand | Command output notification pushed to phone |
| `kdeconnect.runcommand` | RunCommand | Send command list to phone |
Expand Down Expand Up @@ -1148,8 +1205,8 @@ plugin processes it and a link to the body struct definition.
| `kdeconnect.clipboard.file` | Clipboard | `ClipboardFileBody{Filename string}` |
| `kdeconnect.lock` | LockDevice | `LockBody{RequestLocked, SetLocked, IsLocked}` |
| `kdeconnect.lock.request` | LockDevice | `LockBody{}` (triggers lock/unlock) |
| `kdeconnect.mpris` | MPRIS | `MPRISRequest{RequestPlayerList, RequestNowPlaying, RequestVolume, Player, Action, ...}` |
| `kdeconnect.mpris.request` | MPRIS | `MPRISRequest{}` (same struct, different semantics) |
| `kdeconnect.mpris` | MPRIS | `MPRISRequest{RequestPlayerList, RequestNowPlaying, RequestVolume, Player, Action, AlbumArtUrl, TransferringAlbumArt, ...}` — inbound packets with `transferringAlbumArt: true` + `payloadTransferInfo` carry album art bytes (side channel) that the daemon caches to `$XDG_CACHE_HOME/kcd/art/` |
| `kdeconnect.mpris.request` | MPRIS | `MPRISRequest{}` (same struct, different semantics) — an outbound `kdeconnect.mpris.request` with `player` + `albumArtUrl` asks the phone to stream art back |
| `kdeconnect.runcommand.request` | RunCommand | `RequestBody{RequestCommandList bool, Key string}` |
| `kdeconnect.presenter` | Presenter | `PresenterBody{Dx, Dy *float64, Stop *bool}` |
| `kdeconnect.systemvolume` | RemoteSystemVolume | `VolumeBody{SinkList, Name, Volume, Muted}` |
Expand Down
15 changes: 15 additions & 0 deletions internal/config/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ type NotificationPluginConfig struct {
MaxBodyLength int `toml:"max_body_length"`
ExpireMS int `toml:"expire_ms"`
SkipNonClearable bool `toml:"skip_non_clearable"`
// ReplaceNotifications replaces the previous desktop popup when the phone
// re-posts a notification with the same id (e.g. media/scrobble updates),
// instead of creating a new popup each time. On by default.
ReplaceNotifications bool `toml:"replace_notifications"`
// ShowIcons shows the app icon on desktop popups. Off by default —
// kcd notifications render without an icon. When enabled, the phone's
// icon is downloaded (subject to FetchIcons) and reused across re-posts.
ShowIcons bool `toml:"show_icons"`
// CancelGraceMS defers closing the desktop popup after the phone cancels
// a notification. If the same notification is re-posted within the window
// (e.g. media now-playing toggling play/pause), the popup is updated in
// place instead of closing and re-opening. 0 closes immediately.
CancelGraceMS int `toml:"cancel_grace_ms"`
}

type ShareConfig struct {
Expand Down Expand Up @@ -118,6 +131,8 @@ func (c *NotificationPluginConfig) Defaults() {
c.FetchIcons = true
c.MaxBodyLength = 0
c.ExpireMS = -1
c.ReplaceNotifications = true
c.CancelGraceMS = 1500
}

type ClipboardConfig struct {
Expand Down
21 changes: 20 additions & 1 deletion internal/daemon/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ import (
"go.uber.org/zap"
)

// reconnectFlapThreshold is the minimum connection lifetime for it to count
// as genuinely stable. A connection that drops sooner is treated as a flap
// (e.g. a peer that keeps dying), so the auto-reconnect backoff continues
// escalating instead of resetting to the 2s floor on every drop.
const reconnectFlapThreshold = 15 * time.Second

// DialDevice manually connects to a device at the given IP and port.
func DialDevice(ctx context.Context, targetIP net.IP, targetPort int, targetID string, targetProto int, identity *protocol.Packet, cfg *tls.Config, devices *device.Registry, plugins *plugin.Registry, localDeviceID string, logger *zap.Logger) {
addr := fmt.Sprintf("%s:%d", targetIP, targetPort)
Expand Down Expand Up @@ -227,6 +233,14 @@ func handleNewConnection(ctx context.Context, conn *transport.Conn, identity *pr
if lastIP == nil {
return
}
// A connection that lasted long enough was genuinely stable, so the
// next drop should start the backoff over. A flap (connection dies
// shortly after a successful dial, e.g. a dying peer) keeps the
// counter so the backoff keeps escalating instead of hammering at the
// 2s floor forever.
if sender.ConnectionAge() >= reconnectFlapThreshold {
sender.ResetReconnectAttempt()
}
// Prevent multiple concurrent reconnect goroutines for the same device.
if !sender.TryReconnect() {
logger.Debug("auto-reconnect: already reconnecting, skipping",
Expand Down Expand Up @@ -260,7 +274,7 @@ func reconnectWithBackoff(
logger *zap.Logger,
) {
const maxBackoff = 5 * time.Minute
attempt := 0
attempt := dev.ReconnectAttempt()

defer dev.ReconnectDone()

Expand Down Expand Up @@ -321,6 +335,11 @@ func reconnectWithBackoff(
zap.String("device_id", dev.ID()),
zap.Int("attempts", attempt+1),
)
// Persist the counter before returning: if this connection flaps,
// the next reconnect cycle continues backing off rather than
// resetting to the 2s floor. onDisconnect resets it if the
// connection proved stable.
dev.SetReconnectAttempt(attempt + 1)
return
}

Expand Down
52 changes: 52 additions & 0 deletions internal/device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ type Device struct {
// auto-reconnect goroutines for this device.
reconnecting atomic.Bool

// reconnectAttempt persists the auto-reconnect backoff counter across
// disconnect cycles. A connection that flaps (drops shortly after a
// successful dial) keeps the counter so the backoff escalates instead of
// resetting to the 2s floor; a stable connection resets it on drop.
reconnectAttempt int

// connectStarted is when the most recent connection was established,
// used to detect flaps (connections that die too quickly to count as
// genuinely stable).
connectStarted time.Time

// pluginDispatch routes incoming packets to registered plugins
pluginDispatch func(ctx context.Context, dev *Device, pkt *protocol.Packet) bool
onConnect func(dev *Device)
Expand Down Expand Up @@ -109,6 +120,12 @@ func (d *Device) Connect(ctx context.Context, conn *transport.Conn, dispatch fun
d.mu.Unlock()
}

// Record when the connection was established so a quick drop can be
// distinguished from a genuinely stable connection.
d.mu.Lock()
d.connectStarted = time.Now()
d.mu.Unlock()

go d.readLoop(ctx, conn)
go d.writerLoop(ctx, conn)
}
Expand Down Expand Up @@ -314,3 +331,38 @@ func (d *Device) TryReconnect() bool {
func (d *Device) ReconnectDone() {
d.reconnecting.Store(false)
}

// ReconnectAttempt returns the persisted auto-reconnect backoff counter.
func (d *Device) ReconnectAttempt() int {
d.mu.RLock()
defer d.mu.RUnlock()
return d.reconnectAttempt
}

// SetReconnectAttempt stores the auto-reconnect backoff counter so the next
// reconnect cycle (spawned when this connection drops) continues backing off
// instead of resetting to the initial floor.
func (d *Device) SetReconnectAttempt(n int) {
d.mu.Lock()
d.reconnectAttempt = n
d.mu.Unlock()
}

// ResetReconnectAttempt clears the backoff counter after a stable connection
// (one that stayed up long enough to count as genuinely healthy) drops.
func (d *Device) ResetReconnectAttempt() {
d.mu.Lock()
d.reconnectAttempt = 0
d.mu.Unlock()
}

// ConnectionAge returns how long the current connection has been established,
// or 0 if the device has never connected in this process.
func (d *Device) ConnectionAge() time.Duration {
d.mu.RLock()
defer d.mu.RUnlock()
if d.connectStarted.IsZero() {
return 0
}
return time.Since(d.connectStarted)
}
Loading
Loading