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
3183ab5
fix(nix): verify vendorHash in container, commit flake.lock
bethropolis Sep 21, 2026
2a047c1
feat(mpris): poll position only while playing, anchor local broadcasts
bethropolis Sep 21, 2026
04a49de
feat(mpris): gate remote state poller on watch subscribers
bethropolis Sep 22, 2026
68c64b5
feat(reconnect): park redial on discovery sightings with stale horizon
bethropolis Sep 22, 2026
f260062
feat(discovery): run mDNS browse under broadcast ownership
bethropolis Sep 22, 2026
586b706
feat(network): keepalive_idle knob + idle-behavior docs
bethropolis Sep 22, 2026
dd16d6a
fix(mpris): reconcile heals state, expose posAnchorMs on mpris raw
bethropolis Sep 22, 2026
8b2a33f
docs: post-release touch-ups (keepalive knob, MPRIS row, broadcast ow…
bethropolis Sep 22, 2026
28f9c64
feat(aur): add kcd source package via aur_sources
bethropolis Sep 22, 2026
e745842
docs(release): mention Arch source package in install notes
bethropolis Sep 22, 2026
e64218b
fix: address Copilot review findings on idle-timer release
bethropolis Sep 24, 2026
8c55cd1
fix(justfile): stop clean from deleting tracked packaging sources
bethropolis Sep 24, 2026
5a632d2
fix(install): require the Go version the error message claims
bethropolis Sep 24, 2026
61af976
fix(packaging): document the full firewall port set
bethropolis Sep 24, 2026
82d0684
feat(pair): return the verification code to the pairing client
bethropolis Sep 24, 2026
3715634
fix(runcommand): make kcd run list return the command list
bethropolis Sep 24, 2026
617f4d3
fix(mpris): don't force playback when skipping a paused player
bethropolis Sep 24, 2026
09d8aa6
refactor(cli): one device-resolution rule, and say what lock does
bethropolis Sep 24, 2026
baa21f5
fix(completions): add missing fish commands, unpair offline devices
bethropolis Sep 24, 2026
2e2dd81
fix(install): derive the version from the newest release tag
bethropolis Sep 24, 2026
66c352c
fix(mpris): break the poller deadlock on a lost PlaybackStatus signal
bethropolis Sep 25, 2026
108ba26
fix(mpris): don't stop the poller on a failed D-Bus read; drop bad ma…
bethropolis Sep 25, 2026
3adabf2
ci: run integration tests on pull requests
bethropolis Sep 25, 2026
f2023dd
fix(mpris): re-arm a stranded position poller with a slow watchdog
bethropolis Sep 25, 2026
96aa1d2
docs: state the MPRIS watchdog's cost instead of claiming zero timers
bethropolis Sep 25, 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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ jobs:
integration-test:
name: Integration Tests
runs-on: ubuntu-latest
if: github.event_name == 'push'
# Runs on pull_request too. `push` is scoped to main/master, so a
# feature branch only ever fires the pull_request event — gating on
# push alone meant integration never ran before merge.
if: github.event_name == 'push' || github.event_name == 'pull_request'
needs: test
steps:
- uses: actions/checkout@v4
Expand Down
61 changes: 61 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ version: 2

project_name: kcd

# Source tarball for the AUR source package (aur_sources pipe).
source:
enabled: true

before:
hooks:
- go mod tidy
Expand Down Expand Up @@ -110,6 +114,57 @@ aurs:

install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/kcd-bin/LICENSE"

aur_sources:
- name: kcd
homepage: "https://github.com/bethropolis/kcd"
description: |
Lightweight, headless implementation of the KDE Connect protocol (v8) written in Go
maintainers:
- "bethropolis <bethropolis at gmail dot com>"
license: "MIT"
private_key: "{{ .Env.AUR_KEY }}"
skip_upload: true
git_url: "ssh://[email protected]/kcd.git"
provides:
- kcd
conflicts:
- kcd-bin
makedepends:
- go
depends:
- glibc
optdepends:
- "libnotify: for desktop notifications"
- "wl-clipboard: for Wayland clipboard sync"
- "xclip: for X11 clipboard sync"
- "sshfs: for SFTP mounting support"
- "python-nautilus: for Nautilus file manager integration"
- "ydotool: for Wayland mousepad support"
- "xdotool: for X11 mousepad support"
- "wtype: for Wayland keyboard emulation"
build: |-
export CGO_ENABLED=0
go build -trimpath -ldflags "-s -w -X main.version=${pkgver} -X main.commit={{.Commit}} -X main.date={{.Date}}" -o kcd ./cmd/kcd
package: |-
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/[email protected]"

install -Dm644 "./packaging/kcd.example.toml" "${pkgdir}/usr/share/doc/kcd/kcd.example.toml"
install -Dm644 "./README.md" "${pkgdir}/usr/share/doc/kcd/README.md"
install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/kcd/LICENSE"

install -Dm644 "./packaging/kcd.bash-completion" "${pkgdir}/usr/share/bash-completion/completions/kcd"
install -Dm644 "./packaging/kcd.zsh-completion" "${pkgdir}/usr/share/zsh/site-functions/_kcd"
install -Dm644 "./packaging/kcd.fish-completion" "${pkgdir}/usr/share/fish/vendor_completions.d/kcd.fish"

install -Dm644 "./packaging/nautilus-kcd.py" "${pkgdir}/usr/share/nautilus-python/extensions/nautilus-kcd.py"

install -Dm644 "./packaging/firewalld-kcd.xml" "${pkgdir}/usr/lib/firewalld/services/kcd.xml"
install -Dm644 "./packaging/ufw-kcd" "${pkgdir}/etc/ufw/applications.d/kcd"

dockers_v2:
- images:
- "ghcr.io/bethropolis/kcd"
Expand Down Expand Up @@ -262,6 +317,12 @@ release:
systemctl --user enable --now kcd.socket
```

**Arch Linux source:**
```bash
yay -S kcd
systemctl --user enable --now kcd.socket
```

**Debian / Ubuntu:**
```bash
curl -LO https://github.com/bethropolis/kcd/releases/download/v{{ .Version }}/kcd_{{ .Version }}_x86_64.deb
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ error message may be confusing.
| 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` |
| MPRIS | `mpris.NewMPRISPlugin(tlsConfig *tls.Config, bus *events.Bus, pauseMusic bool, mprisCfg config.MPRISConfig, logger log.Logger, cacheDirs ...string) *MPRISPlugin` |

### Interface

Expand Down
11 changes: 6 additions & 5 deletions cmd/kcd/cli_battery.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,23 @@ import (
var batteryCmd = &cli.Command{
Name: "battery",
Usage: "Fetch battery level and charging status",
ArgsUsage: "<device-id>",
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")
}
cl, err := getClient(c)
if err != nil {
return err
}
charge, charging, err := cl.Battery(c.Args().First())
deviceID, err := resolveDeviceID(c, cl)
if err != nil {
return err
}
charge, charging, err := cl.Battery(deviceID)
if err != nil {
return err
}
Expand Down
22 changes: 3 additions & 19 deletions cmd/kcd/cli_clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"os"
"os/exec"

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

Expand All @@ -31,25 +30,10 @@ var clipboardCmd = &cli.Command{
return err
}

var targetID string
if c.NArg() >= 1 {
targetID = c.Args().First()
} else {
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")
}
targetID, err := resolveDeviceID(c, cl)
if err != nil {
return err
}

if err := cl.ClipboardPush(targetID); err != nil {
return err
}
Expand Down
19 changes: 3 additions & 16 deletions cmd/kcd/cli_connectivity.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"sort"
"strings"

"github.com/bethropolis/kcd/internal/device"
"github.com/bethropolis/kcd/internal/plugins/connectivity"
"github.com/urfave/cli/v2"
)
Expand All @@ -27,21 +26,9 @@ var connectivityCmd = &cli.Command{
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")
}
targetID, err := resolveDeviceID(c, cl)
if err != nil {
return err
}

raw, err := cl.Connectivity(targetID)
Expand Down
32 changes: 17 additions & 15 deletions cmd/kcd/cli_contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,17 @@ var contactsCmd = &cli.Command{
{
Name: "sync",
Usage: "Request a contacts sync from a device",
ArgsUsage: "<device-id>",
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
}
if err := cl.ContactsSync(c.Args().Get(0)); err != nil {
deviceID, err := resolveDeviceID(c, cl)
if err != nil {
return err
}
if err := cl.ContactsSync(deviceID); err != nil {
return err
}
fmt.Println("Contacts sync requested. Use `kcd watch --events contacts.updated` to see results.")
Expand All @@ -34,22 +35,23 @@ var contactsCmd = &cli.Command{
{
Name: "list",
Usage: "List cached contacts for a device",
ArgsUsage: "<device-id>",
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")
}
cl, err := getClient(c)
if err != nil {
return err
}
list, err := cl.ContactsList(c.Args().Get(0))
deviceID, err := resolveDeviceID(c, cl)
if err != nil {
return err
}
list, err := cl.ContactsList(deviceID)
if err != nil {
return err
}
Expand All @@ -72,16 +74,16 @@ var contactsCmd = &cli.Command{
{
Name: "clear",
Usage: "Delete cached contacts for a device (re-sync restores them)",
ArgsUsage: "<device-id>",
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)
id, err := resolveDeviceID(c, cl)
if err != nil {
return err
}
list, err := cl.ContactsList(id)
if err != nil {
return err
Expand Down
44 changes: 44 additions & 0 deletions cmd/kcd/cli_device.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package main

import (
"fmt"
"sort"
"strings"

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

// resolveDeviceID returns the device a command should act on.
//
// An explicit argument always wins. Otherwise the sole paired, connected
// device is selected, so the common single-phone setup needs no ID. With
// several candidates it is an error naming them rather than a silent
// coin flip against the wrong phone.
func resolveDeviceID(c *cli.Context, cl *client.Client) (string, error) {
if c.NArg() >= 1 {
return c.Args().First(), nil
}

devs, err := cl.Devices()
if err != nil {
return "", err
}

var paired []string
for _, d := range devs {
if d.Connected && d.State == device.StatePaired {
paired = append(paired, d.ID)
}
}
switch len(paired) {
case 0:
return "", fmt.Errorf("no paired connected devices found — pass a device ID")
case 1:
return paired[0], nil
default:
sort.Strings(paired)
return "", fmt.Errorf("multiple devices connected (%s) — pass a device ID", strings.Join(paired, ", "))
}
}
Loading
Loading