Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
601d8fa
fix(discovery): stop auto-dialling unpaired strangers, pair on demand
bethropolis Sep 12, 2026
3163c77
fix(pair): confirm before accepting listen-mode requests, decode esca…
bethropolis Sep 12, 2026
11878e0
fix(discovery): ephemeral dials replace the auto-dial gate
bethropolis Sep 12, 2026
3ff9a40
fix(cli): devices --connected lists paired devices even when offline
bethropolis Sep 12, 2026
e29e8e0
feat(connectivity): expose cached network status via watch dump and CLI
bethropolis Sep 12, 2026
0786bf4
feat(clients): snapshot bootstrap, anchors, enriched devices, json gaps
bethropolis Sep 12, 2026
92b5499
fix(ipc): report connected devices as seen now in summaries
bethropolis Sep 12, 2026
701b24d
fix(discovery): harden paired dials, intent lifetime, and device names
bethropolis Sep 14, 2026
4774ae6
fix(mpris): only stamp album art onto the track that requested it
bethropolis Sep 14, 2026
620246f
fix(plugins): cap SMS downloads, sanitize icon IDs, end option parsing
bethropolis Sep 14, 2026
ea1c71e
fix(plugins): validate sshfs args, lock xdg-open to http(s), block ex…
bethropolis Sep 14, 2026
2b2752a
fix(plugins): verify side-channel peer against the paired certificate
bethropolis Sep 15, 2026
5e20038
feat(pair): constrain headless auto-accept with fingerprint and known…
bethropolis Sep 15, 2026
c36de48
fix(battery): omit battery state until the first real packet arrives
bethropolis Sep 15, 2026
9fa4a64
feat(systemd): start the daemon on first client connection via socket…
bethropolis Sep 15, 2026
7db56a1
fix(reconnect): persist paired dial targets across restarts
bethropolis Sep 15, 2026
ee01fb4
fix(reconnect): dial the sighted address for paired devices
bethropolis Sep 15, 2026
935da82
fix(reconnect): restart backoff escalation when the peer roams
bethropolis Sep 15, 2026
1bdc6d1
feat(discovery): advertise mDNS for the daemon lifetime, not just whi…
bethropolis Sep 15, 2026
a788bff
feat(discovery): broadcast UDP while a paired device is offline
bethropolis Sep 15, 2026
230a96c
docs: describe on-demand discovery and persisted dial targets
bethropolis Sep 15, 2026
7da8867
feat(contacts): sync and browse the phone address book
bethropolis Sep 15, 2026
9b3bf1e
feat(packaging): ship socket activation in distro packages
bethropolis Sep 15, 2026
38001ba
fix(test): synchronize fakeNotifier reads with the async notify path
bethropolis Sep 15, 2026
5a520af
fix(packaging): drop deprecated homebrew_casks url.verified key
bethropolis Sep 15, 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
9 changes: 7 additions & 2 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ archives:
- LICENSE
- packaging/kcd.example.toml
- packaging/kcd-user.service
- packaging/kcd-user.socket
- packaging/kcd-pkg.service
- packaging/kcd-pkg.socket
- packaging/kcd-system.service
- packaging/ufw-kcd
- packaging/firewalld-kcd.xml
Expand Down Expand Up @@ -91,6 +93,7 @@ aurs:
install -Dm755 "./kcd" "${pkgdir}/usr/bin/kcd"

install -Dm644 "./packaging/kcd-pkg.service" "${pkgdir}/usr/lib/systemd/user/kcd.service"
install -Dm644 "./packaging/kcd-pkg.socket" "${pkgdir}/usr/lib/systemd/user/kcd.socket"
install -Dm644 "./packaging/kcd-system.service" "${pkgdir}/usr/lib/systemd/system/kcd@.service"

install -Dm644 "./packaging/kcd.example.toml" "${pkgdir}/usr/share/doc/kcd/kcd.example.toml"
Expand Down Expand Up @@ -135,8 +138,6 @@ homebrew_casks:
owner: bethropolis
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
url:
verified: github.com/bethropolis/kcd
binaries:
- kcd
completions:
Expand Down Expand Up @@ -170,6 +171,9 @@ nfpms:
- src: packaging/kcd-pkg.service
dst: /usr/lib/systemd/user/kcd.service
type: config|noreplace
- src: packaging/kcd-pkg.socket
dst: /usr/lib/systemd/user/kcd.socket
type: config|noreplace
- src: packaging/kcd-system.service
dst: /usr/lib/systemd/system/kcd@.service
type: config|noreplace
Expand Down Expand Up @@ -255,6 +259,7 @@ release:
**Arch Linux:**
```bash
yay -S kcd-bin
systemctl --user enable --now kcd.socket
```

**Debian / Ubuntu:**
Expand Down
18 changes: 14 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ These structural constraints must hold at all times:
| `internal/config/` only imports `github.com/BurntSushi/toml` | Config must stay lean and cycle-free |
| `internal/plugin/plugin.go` only imports `internal/protocol` and `internal/device` | Plugins never import each other |
| Plugins are registered in `daemon.go`, never in their own `init()` | Explicit, ordered, conditional on config |
| `pkg/client/` only imports `internal/ipc` from the `internal/` tree | Public client API must not depend on internals beyond the IPC protocol |
| `pkg/client/` only imports `internal/device`, `internal/events`, `internal/ipc`, and `internal/plugins/contacts` from the `internal/` tree | Public client API must not depend on internals beyond the IPC protocol and the types it surfaces |
| All binaries built with `CGO_ENABLED=0` | Required for static distribution |

---
Expand Down Expand Up @@ -97,13 +97,14 @@ error message may be confusing.
|---|---|
| Battery | `battery.NewBatteryPlugin(cfg config.BatteryConfig, bus *events.Bus, logger *zap.Logger) *BatteryPlugin` |
| Notification | `notification.NewNotificationPlugin(cfg config.NotificationPluginConfig, bus *events.Bus, tlsConfig *tls.Config, logger *zap.Logger) *NotificationPlugin` |
| Share | `share.NewSharePlugin(cfg config.ShareConfig, tlsConfig *tls.Config, logger *zap.Logger) *SharePlugin` |
| Share | `share.NewSharePlugin(downloadDir string, cfg config.ShareConfig, tlsConfig *tls.Config, bus *events.Bus, logger *zap.Logger) *SharePlugin` |
| SFTP | `sftp.NewSftpPlugin(cfg config.SFTPConfig, bus *events.Bus, logger *zap.Logger) *SftpPlugin` |
| Ping | `ping.NewPingPlugin(cfg config.PingConfig, bus *events.Bus, logger *zap.Logger) *PingPlugin` |
| Pair | `pair.NewPairPlugin(devices *device.Registry, localCert *x509.Certificate, autoAccept bool, cfg config.PairingConfig, onStateChanged func(), bus *events.Bus, logger *zap.Logger) *PairPlugin` |
| Pair | `pair.NewPairPlugin(devices *device.Registry, localCert *x509.Certificate, cfg config.PairingConfig, onStateChanged func(), bus *events.Bus, logger *zap.Logger) *PairPlugin` |
| Mousepad | `mousepad.NewMousepadPlugin(cfg config.MousepadConfig, logger *zap.Logger) *MousepadPlugin` |
| SystemVolume | `systemvolume.NewSystemVolumePlugin(bus *events.Bus, logger *zap.Logger) *SystemVolumePlugin` |
| SMS | `sms.NewSMSPlugin(cfg config.SMSConfig, bus *events.Bus, tlsConfig *tls.Config, logger *zap.Logger) *SMSPlugin` |
| Contacts | `contacts.NewContactsPlugin(bus *events.Bus, logger *zap.Logger) *ContactsPlugin` |

### Interface

Expand Down Expand Up @@ -248,7 +249,16 @@ Discovery is dual-mode and runs concurrently. Both paths call the same `onDevice
| UDP broadcast | Sends identity to `255.255.255.255:1716` + per-interface directed broadcasts | Same LAN, simple home networks |
| mDNS / Zeroconf | Registers `_kdeconnect._udp.local.` via `libp2p/zeroconf/v2`; browses for peers | Restricted networks, Docker, corporate Wi-Fi, newer Android |

The broadcast interval is adaptive. When `shouldReduce()` returns true (all paired devices already connected), the UDP interval increases to 60 seconds. Setting `enable_broadcast = false` in config disables UDP entirely — the daemon reaches 0.0% idle CPU. Paired phones reconnect automatically via remembered IP.
mDNS advertisement runs for the daemon lifetime (responder-only, negligible
idle cost). UDP broadcast is on demand and reference-counted per owner
(`pairing` for `kcd pair` listen mode, `reconnect` for offline pairs):
it runs while any paired device is disconnected so roamed phones can find
us back, and stops fully when all pairs are connected — connected steady
state keeps zero timers. The 30s/60s interval only ticks while a reconnect
is actually wanted. Dial targets (`LastIP`/`LastPort`) persist in
`devices.json`; paired sightings dial the sighted address (authenticated
post-connect by CN + pinned fingerprint), with `LastIP` as the fallback
for silent peers.

---

Expand Down
40 changes: 20 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,26 +41,22 @@ Discovery is dual-mode: **UDP broadcast** (port 1716) and **mDNS/Zeroconf** (`_k

## Installation


### From source (Recommended)
```bash
git clone https://github.com/bethropolis/kcd.git
cd kcd
./scripts/install.sh
```


### Arch Linux

Install from the AUR using your preferred helper:

```bash
yay -S kcd-bin
```

### Container
systemctl --user enable --now kcd.socket
```

Multi-arch images on GHCR: [`docs/CONTAINER.md`](docs/CONTAINER.md)
### From source
```bash
git clone https://github.com/bethropolis/kcd.git
cd kcd
./scripts/install.sh
```

### Binary releases

Expand Down Expand Up @@ -109,12 +105,15 @@ sudo ufw allow 1739:1764/tcp
### 1. Start the daemon

If you installed via the script, `.deb`, `.rpm`, or AUR, the systemd user service
is already set upenable and start it:
is already set up, you need to enable the socket and the daemon starts on first use:

```bash
systemctl --user enable --now kcd
systemctl --user enable --now kcd.socket
```

Run any `kcd` command once after login to wake the daemon; from then on
a paired phone reconnects by itself within seconds.

Check that it's running:

```bash
Expand Down Expand Up @@ -277,11 +276,11 @@ kcd fits into WM setups without pulling in KDE Plasma — single binary,
systemd user unit alongside your compositor. All `mpris` commands auto-
discover your phone, no device ID needed.

**Hyprland** (`~/.config/hypr/hyprland.conf`):
```
bind = SUPER, F9, exec, kcd mpris toggle
bind = SUPER, F10, exec, kcd mpris previous
bind = SUPER, F11, exec, kcd mpris next
**Hyprland** (`~/.config/hypr/hyprland.lua` personal overrides file):
```lua
hl.bind("SUPER + F9", hl.dsp.exec_cmd("kcd mpris toggle"), { description = "MPRIS toggle" })
hl.bind("SUPER + F10", hl.dsp.exec_cmd("kcd mpris previous"), { description = "MPRIS previous" })
hl.bind("SUPER + F11", hl.dsp.exec_cmd("kcd mpris next"), { description = "MPRIS next" })
```

**Sway / i3** (`~/.config/sway/config` or `~/.config/i3/config`):
Expand Down Expand Up @@ -319,6 +318,7 @@ done

| Event type | Payload fields | Description |
|---|---|---|
| `state.snapshot` | `devices[]` (enriched summaries, online and offline) | Full-state bootstrap, once per watch connection |
| `device.added` | `name`, `type` | New device seen for the first time |
| `device.removed` | — | Device unpaired and removed |
| `device.connected` | `name`, `type` | TCP connection established |
Expand All @@ -339,7 +339,7 @@ done
| `telephony.missed` | `contactName`, `phoneNumber` | Missed call |
| `telephony.canceled` | — | Call ended |
| `connectivity.update` | `signal`, `networkType` | Signal strength report |
| `mpris.update` | `player`, `title`, `artist`, `album`, `isPlaying`, `pos`, `length`, `volume` | Phone now playing state changed |
| `mpris.update` | `player`, `title`, `artist`, `album`, `isPlaying`, `pos`, `posAnchorMs`, `length`, `volume`, `albumArtUrl`, `artPending` | Phone now playing state changed |
| `volume.update` | `name`, `volume`, `muted` | Desktop volume changed from phone |
| `sftp.mount` | `uri`, `ip`, `port`, `user`, `password`, `path` | SFTP credentials received |

Expand Down
16 changes: 16 additions & 0 deletions cmd/kcd/cli_battery.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"fmt"

"github.com/urfave/cli/v2"
Expand All @@ -10,6 +11,12 @@ var batteryCmd = &cli.Command{
Name: "battery",
Usage: "Fetch battery level and charging status",
ArgsUsage: "<device-id>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "json",
Usage: "Output raw JSON",
},
},
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
return fmt.Errorf("missing device ID")
Expand All @@ -22,6 +29,15 @@ var batteryCmd = &cli.Command{
if err != nil {
return err
}
if c.Bool("json") {
out, _ := json.Marshal(map[string]interface{}{
"deviceId": c.Args().First(),
"charge": charge,
"charging": charging,
})
fmt.Println(string(out))
return nil
}
state := "discharging"
if charging {
state = "charging"
Expand Down
5 changes: 3 additions & 2 deletions cmd/kcd/cli_clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/exec"

"github.com/bethropolis/kcd/internal/device"
"github.com/urfave/cli/v2"
)

Expand Down Expand Up @@ -39,13 +40,13 @@ var clipboardCmd = &cli.Command{
return err
}
for _, d := range devs {
if d.Connected {
if d.Connected && d.State == device.StatePaired {
targetID = d.ID
break
}
}
if targetID == "" {
return fmt.Errorf("no connected devices found")
return fmt.Errorf("no paired connected devices found")
}
}

Expand Down
115 changes: 115 additions & 0 deletions cmd/kcd/cli_connectivity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"encoding/json"
"fmt"
"sort"
"strings"

"github.com/bethropolis/kcd/internal/device"
"github.com/bethropolis/kcd/internal/plugins/connectivity"
"github.com/urfave/cli/v2"
)

var connectivityCmd = &cli.Command{
Name: "connectivity",
Usage: "Show cellular signal strength and network type",
ArgsUsage: "[device-id]",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "json",
Usage: "Output the raw report as JSON",
},
},
Action: func(c *cli.Context) error {
cl, err := getClient(c)
if err != nil {
return err
}

targetID := c.Args().First()
if targetID == "" {
devs, err := cl.Devices()
if err != nil {
return err
}
for _, d := range devs {
if d.Connected && d.State == device.StatePaired {
targetID = d.ID
break
}
}
if targetID == "" {
return fmt.Errorf("no paired connected devices found")
}
}

raw, err := cl.Connectivity(targetID)
if err != nil {
return err
}
if c.Bool("json") {
fmt.Println(string(raw))
return nil
}

var body connectivity.ConnectivityBody
if err := json.Unmarshal(raw, &body); err != nil {
return fmt.Errorf("decode connectivity report: %w", err)
}
for _, line := range formatConnectivity(body) {
fmt.Println(line)
}
return nil
},
}

// formatConnectivity renders one line per SIM, e.g. "LTE [███░] (3/4)".
// The primary SIM ("0", else lowest key) comes first; keys are sorted for
// stable output. Level is clamped to 0-4 so the bar always parses.
func formatConnectivity(body connectivity.ConnectivityBody) []string {
if len(body.SignalStrengths) == 0 {
return []string{"No signal data reported"}
}
keys := make([]string, 0, len(body.SignalStrengths))
for k := range body.SignalStrengths {
keys = append(keys, k)
}
sort.Strings(keys)
// Primary SIM first.
if _, ok := body.SignalStrengths["0"]; ok {
ordered := []string{"0"}
for _, k := range keys {
if k != "0" {
ordered = append(ordered, k)
}
}
keys = ordered
}

lines := make([]string, 0, len(keys))
for _, k := range keys {
sig := body.SignalStrengths[k]
level := sig.SignalStrength
if level < 0 {
level = 0
}
if level > 4 {
level = 4
}
netType := sig.NetworkDetailedType
if netType == "" {
netType = sig.NetworkType
}
if netType == "" {
netType = "CELL"
}
bar := strings.Repeat("█", level) + strings.Repeat("░", 4-level)
if len(keys) == 1 {
lines = append(lines, fmt.Sprintf("%s [%s] (%d/4)", strings.ToUpper(netType), bar, level))
} else {
lines = append(lines, fmt.Sprintf("SIM %s: %s [%s] (%d/4)", k, strings.ToUpper(netType), bar, level))
}
}
return lines
}
Loading
Loading