Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ linters:
- noctx
- unparam
- misspell
- depguard
disable:
- errcheck

Expand All @@ -19,6 +20,15 @@ linters:
disable:
- shadow
- fieldalignment
depguard:
rules:
logging-seam:
files:
- $all
- '!**/internal/log/**'
deny:
- pkg: go.uber.org/zap
desc: 'import internal/log instead of go.uber.org/zap directly'

exclusions:
rules:
Expand Down
4 changes: 2 additions & 2 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ release:
## kcd v{{ .Version }}

Headless KDE Connect daemon for Linux.

footer: |
### Installation

**Arch Linux:**
Expand Down Expand Up @@ -289,5 +289,5 @@ release:
curl -sSL https://github.com/bethropolis/kcd/releases/download/v{{ .Version }}/kcd_{{ .Version }}_x86_64.tar.gz | tar -xz
sudo mv kcd /usr/local/bin/
```
footer: |

**Full Changelog:** https://github.com/bethropolis/kcd/compare/{{ .PreviousTag }}...v{{ .Version }}
30 changes: 15 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Never violate these. If a change would break one, stop and reconsider the approa
7. **deviceId is permanent.** Generated once via `config.EnsureDeviceID`, stored in `kcd.toml`. Never regenerate. It is the stable identity used for cert fingerprint pairing.
8. **Self-signed TLS, `InsecureSkipVerify: true`.** Authentication happens via the SHA-256 fingerprint stored in `devices.json` after pairing — not via CA chain.
9. **`Plugin.Handle()` must return immediately.** Any D-Bus call, subprocess (`exec.Command`), or disk I/O must be spawned in a goroutine *inside* the plugin. Blocking `Handle()` stalls the entire TCP read loop for that device.
10. **No deprecated packages.** No `ioutil` (use `os`/`io`). No `log` (use `go.uber.org/zap`). No `cobra` (use `urfave/cli/v2`).
10. **No deprecated packages.** No `ioutil` (use `os`/`io`). No `log` and no direct `go.uber.org/zap` imports (use `internal/log` — enforced by depguard). No `cobra` (use `urfave/cli/v2`).
11. **Keep `Body` as `json.RawMessage` in the router.** Plugins unmarshal their own body types. The packet router never touches body content.
12. **One goroutine per connection.** Dispatch is sequential per device — one packet handled at a time. This is intentional; it removes the need for per-plugin locks.
13. **Cap incoming payload size with `io.LimitReader`.** Never trust the `payloadSize` field from the remote device without a cap.
Expand All @@ -39,7 +39,7 @@ These structural constraints must hold at all times:
| Invariant | Why |
|---|---|
| `internal/protocol/` has **zero external imports** (stdlib only) | Protocol types are used everywhere; external deps would create cycles |
| `internal/config/` only imports `github.com/BurntSushi/toml` | Config must stay lean and cycle-free |
| `internal/config/` only imports `github.com/BurntSushi/toml` and `internal/protocol` | Config must stay lean and cycle-free (`protocol` is stdlib-only, so no cycle is possible) |
| `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/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 |
Expand Down Expand Up @@ -67,7 +67,7 @@ These structural constraints must hold at all times:

`daemon.Run()` wires everything in this exact sequence. Preserve the order when modifying startup:

1. Build `zap.Logger` from config log level
1. Build `log.Logger` via `log.New(cfg.LogLevel)`
2. Load or generate TLS certificate (`cert.LoadOrGenerate`)
3. Create event bus (`events.NewBus`)
4. Create device registry (`device.NewRegistry`) and load persisted state from `devices.json`
Expand All @@ -86,7 +86,7 @@ These structural constraints must hold at all times:

```
⚠ CONSTRUCTORS: Every plugin now requires bus *events.Bus and
logger *zap.Logger. Never use struct literals (&battery.BatteryPlugin{})
logger log.Logger. Never use struct literals (&battery.BatteryPlugin{})
— always call the constructor. The compiler will catch this but the
error message may be confusing.
```
Expand All @@ -95,16 +95,16 @@ error message may be confusing.

| Plugin | Correct constructor signature |
|---|---|
| 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(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, 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` |
| Battery | `battery.NewBatteryPlugin(cfg config.BatteryConfig, bus *events.Bus, logger log.Logger) *BatteryPlugin` |
| Notification | `notification.NewNotificationPlugin(cfg config.NotificationPluginConfig, bus *events.Bus, tlsConfig *tls.Config, logger log.Logger) *NotificationPlugin` |
| Share | `share.NewSharePlugin(downloadDir string, cfg config.ShareConfig, tlsConfig *tls.Config, bus *events.Bus, logger log.Logger) *SharePlugin` |
| SFTP | `sftp.NewSftpPlugin(cfg config.SFTPConfig, bus *events.Bus, logger log.Logger) *SftpPlugin` |
| Ping | `ping.NewPingPlugin(cfg config.PingConfig, bus *events.Bus, logger log.Logger) *PingPlugin` |
| Pair | `pair.NewPairPlugin(devices *device.Registry, localCert *x509.Certificate, cfg config.PairingConfig, onStateChanged func(), bus *events.Bus, logger log.Logger) *PairPlugin` |
| Mousepad | `mousepad.NewMousepadPlugin(cfg config.MousepadConfig, logger log.Logger) *MousepadPlugin` |
| SystemVolume | `systemvolume.NewSystemVolumePlugin(bus *events.Bus, logger log.Logger) *SystemVolumePlugin` |
| SMS | `sms.NewSMSPlugin(cfg config.SMSConfig, bus *events.Bus, tlsConfig *tls.Config, logger log.Logger) *SMSPlugin` |
| Contacts | `contacts.NewContactsPlugin(bus *events.Bus, logger log.Logger) *ContactsPlugin` |

### Interface

Expand Down Expand Up @@ -274,7 +274,7 @@ bus.Publish(events.TypeBatteryUpdate, dev.ID(), map[string]any{
```

Rules:
- Subscriber channels have capacity 64. If a slow subscriber fills its channel, events are **dropped** (with a `zap.Warn`), never blocked.
- Subscriber channels have capacity 64. If a slow subscriber fills its channel, events are **dropped** (with a `log.Warn`), never blocked.
- Filters: `bus.Subscribe(events.TypeBatteryUpdate, events.TypeNotification)` — empty filter = all events.
- Always call `sub.Close()` when done to avoid goroutine leaks.

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
| **Find My Phone** | Ring the phone to locate it |
| **Telephony** | Get call and SMS notifications on the desktop |
| **SMS** | Send SMS messages via the phone |
| **Contacts** | Sync and browse the phone address book |
| **SFTP** | Browse the phone's filesystem |
| **Lock / Unlock** | Lock and unlock the desktop session |
| **Ping** | Simple connectivity check |
Expand All @@ -47,7 +48,6 @@ Install from the AUR using your preferred helper:

```bash
yay -S kcd-bin

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

Expand All @@ -60,7 +60,7 @@ cd kcd

### Binary releases

Download the latest pre-built binary from [GitHub Releases](https://github.com/bethropolis/kcd/releases).
Every [GitHub Release](https://github.com/bethropolis/kcd/releases) ships pre-built artifacts for Linux (amd64, arm64, armv7): `.deb` (Debian/Ubuntu), `.rpm` (Fedora/RHEL), `.tar.gz` archives, and a Homebrew cask — see the release notes for per-format install commands.


---
Expand Down Expand Up @@ -376,8 +376,10 @@ kcd connect 192.168.1.100

| Document | Description |
|---|---|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | System architecture, plugin system, event bus, IPC protocol |
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | System architecture, plugin system, event bus |
| [`docs/CLI.md`](docs/CLI.md) | Full CLI reference and sub-commands |
| [`docs/IPC_PROTOCOL.md`](docs/IPC_PROTOCOL.md) | Daemon socket protocol: commands, events, packet reference |
| [`docs/CLIENT_GUIDE.md`](docs/CLIENT_GUIDE.md) | Building clients over the IPC socket (Python examples) |
| [`docs/CONTAINER.md`](docs/CONTAINER.md) | Running kcd in Docker / Podman |
| [`packaging/kcd.example.toml`](packaging/kcd.example.toml) | Annotated configuration reference |

Expand Down
24 changes: 24 additions & 0 deletions cmd/kcd/cli_contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,29 @@ var contactsCmd = &cli.Command{
return nil
},
},
{
Name: "clear",
Usage: "Delete cached contacts for a device (re-sync restores them)",
ArgsUsage: "<device-id>",
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
return fmt.Errorf("missing device ID")
}
cl, err := getClient(c)
if err != nil {
return err
}
id := c.Args().Get(0)
list, err := cl.ContactsList(id)
if err != nil {
return err
}
if err := cl.ContactsClear(id); err != nil {
return err
}
fmt.Printf("Cleared %d cached contact(s) for %s.\n", len(list), id)
return nil
},
},
},
}
2 changes: 1 addition & 1 deletion cmd/testid/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func main() {
"test_device_id_with_underscores",
"TestDevice",
"desktop",
1716,
protocol.DefaultTCPPort,
[]string{"kdeconnect.ping"},
[]string{"kdeconnect.ping"},
)
Expand Down
26 changes: 26 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,32 @@ type Plugin interface {

Plugin execution is wrapped in a context with the plugin's declared `Timeout()` deadline.

### Subprocess policy

All subprocess spawns go through `internal/plugin/exec.go` unless a site
needs something the seam cannot express:

| Helper | Use when |
|---|---|
| `RunCommandAsync(logger, name, args...)` | Fire-and-forget, output only matters on failure (10s bound, warns once). The helper owns the goroutine — do not wrap it in another. |
| `RunCommandSync(ctx, name, args...)` | Caller needs the exit status or combined output; caller owns the timeout via ctx. |
| `RunCommandOutput(ctx, name, args...)` | Caller parses stdout; stderr is discarded so it cannot corrupt the parse. |

Hand-rolled `exec.CommandContext` stays only where the seam is
inexpressive — do not "migrate" these without replacing the missing
capability:

- **stdin piping**: share clipboard `wl-copy`/`xclip`, sftp `sshfs` (password on stdin).
- **`Start()`-without-`Wait` detach**: sftp auto-open of the mount (waiting would block on the file manager).
- **custom `Env` / `WaitDelay`**: clipboard runners (Wayland env injection, anti-`wl-copy`-fork hang, `/dev/null` fds so `Wait()` isn't pinned).
- **injectable constructor**: notification `newExec` field (tests stub `notify-send`).
- **stdout purity + stored IDs**: notification send/close (`--print-id` output becomes the next `-r` replace ID).
- **long-lived streaming child**: `wl-paste --watch` supervisor loop in the CLI.

Every `exec.CommandContext` outside `internal/plugin/exec.go` must carry
an explicit timeout — `context.Background()` with no deadline is a
goroutine leak when the child wedges.

### Implemented plugins

| Package | Types handled | Notes |
Expand Down
9 changes: 9 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,15 @@ unknown).
kcd contacts list <device-id> [--json]
```

### contacts clear

Delete a device's cached contacts. Works offline (the cache is local
state); re-sync restores everything from the phone.

```
kcd contacts clear <device-id>
```

---

## volume
Expand Down
3 changes: 3 additions & 0 deletions docs/CLIENT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,9 @@ if resp["ok"]:
for c in resp["data"]:
print(c["name"], c.get("phones", []))
# empty list = never synced (unknown, not zero contacts)

# delete the cached address book (offline-capable; re-sync restores it)
ipc_request(sock, "contacts_clear", {"deviceId": dev_id})
```

### 5.7 Lock/Unlock
Expand Down
13 changes: 13 additions & 0 deletions docs/IPC_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,19 @@ Empty when never synced — absent means unknown. Example:
[{"uid": "1", "name": "Ada Lovelace", "phones": ["+1-555-0100"], "timestamp": 973486597}]
```

#### `contacts_clear`

Delete a device's cached contacts. Offline-capable (the cache is local
state); re-sync restores everything from the phone.

**Request payload:**

```json
{"deviceId": "a1b2c3d4e5f6_..."}
```

**Response data:** none.

#### `call_mute`

Mute an incoming phone call.
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
src = ./.;

# Update when go.sum changes: nix build 2>&1 | grep 'got:' | awk '{print $2}'
vendorHash = "sha256-rnI60JzB8vtFC4iIVoHGi9um7f0mV7jbIJTNVu8ytVY=";
vendorHash = "sha256-6zwzWlboTQeZcBiiHU7Jt+vDn2FYCrQ8CzGgCntKRGo=";

subPackages = [ "cmd/kcd" ];

Expand Down
6 changes: 4 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Package config handles loading and validating the kcd daemon configuration.
// It has zero external imports except github.com/BurntSushi/toml.
// It imports only github.com/BurntSushi/toml and internal/protocol
// (stdlib-only, so no import cycle is possible).
package config

import (
Expand All @@ -8,6 +9,7 @@ import (
"path/filepath"

"github.com/BurntSushi/toml"
"github.com/bethropolis/kcd/internal/protocol"
)

// Config holds all daemon configuration.
Expand Down Expand Up @@ -63,7 +65,7 @@ func Defaults() *Config {
c.KeyFile = configPath("key.pem", false)
c.SocketPath = DefaultSocketPath()
c.DownloadDir = filepath.Join(home, "Downloads", "kcd")
c.TCPPort = 1716
c.TCPPort = protocol.DefaultTCPPort
c.LogLevel = "info"

c.Network = NetworkConfig{DialTimeout: "5s", HandshakeTimeout: "10s", SidechannelTimeout: "15s", TransferIdleTimeout: "60s"}
Expand Down
9 changes: 9 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/BurntSushi/toml"
"github.com/bethropolis/kcd/internal/protocol"
)

func TestDefaults(t *testing.T) {
Expand All @@ -28,6 +29,14 @@ func TestDefaults(t *testing.T) {
if cfg.Pairing.IntentTTL != "5m" || cfg.Pairing.ListenTimeout != "60s" || cfg.Pairing.TimeoutSecs != 30 {
t.Errorf("pairing defaults: %+v", cfg.Pairing)
}
if cfg.TCPPort != protocol.DefaultTCPPort {
t.Errorf("tcp_port default = %d, want protocol.DefaultTCPPort (%d)", cfg.TCPPort, protocol.DefaultTCPPort)
}
if cfg.Share.PortMin != protocol.DefaultSidechannelPortMin || cfg.Share.PortMax != protocol.DefaultSidechannelPortMax {
t.Errorf("share port range default = %d-%d, want %d-%d",
cfg.Share.PortMin, cfg.Share.PortMax,
protocol.DefaultSidechannelPortMin, protocol.DefaultSidechannelPortMax)
}
if cfg.Cache != (CacheConfig{}) || cfg.Ping.AppName != "" || cfg.Notifications.AppName() != "KDE Connect" {
t.Fatal("default cache paths or notification inheritance changed")
}
Expand Down
6 changes: 4 additions & 2 deletions internal/config/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package config
import (
"os"
"path/filepath"

"github.com/bethropolis/kcd/internal/protocol"
)

type PluginConfig struct {
Expand Down Expand Up @@ -150,8 +152,8 @@ func (c *ClipboardConfig) Defaults() {
}

func (c *ShareConfig) Defaults() {
c.PortMin = 1739
c.PortMax = 1764
c.PortMin = protocol.DefaultSidechannelPortMin
c.PortMax = protocol.DefaultSidechannelPortMax
c.AcceptTimeoutSecs = 120
c.OpenCommand = "xdg-open"
}
Expand Down
6 changes: 3 additions & 3 deletions internal/daemon/configurability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import (
"encoding/json"
"github.com/bethropolis/kcd/internal/config"
"github.com/bethropolis/kcd/internal/device"
"github.com/bethropolis/kcd/internal/log"
"github.com/bethropolis/kcd/internal/plugin"
"github.com/bethropolis/kcd/internal/protocol"
"go.uber.org/zap"
"net"
"testing"
)

func TestReconnectConfiguredPort(t *testing.T) {
dev := device.NewDevice("peer", "Peer", "phone", zap.NewNop())
dev := device.NewDevice("peer", "Peer", "phone", log.Nop())
if got := reconnectPort(dev, 1816); got != 1816 {
t.Fatalf("fallback = %d", got)
}
Expand Down Expand Up @@ -41,6 +41,6 @@ func TestDialConfiguredPortCanceled(t *testing.T) {
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
logger := zap.NewNop()
logger := log.Nop()
DialDevice(ctx, net.IPv4(127, 0, 0, 1), cfg.TCPPort, "peer", protocol.ProtocolVersion, pkt, nil, device.NewRegistry(nil), plugin.NewRegistry(logger), "local", logger, true, cfg)
}
Loading
Loading