Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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
98 changes: 98 additions & 0 deletions cmd/kcd/cli_volume.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package main

import (
"encoding/json"
"fmt"

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

var volumeCmd = &cli.Command{
Name: "volume",
Usage: "Control remote device volume",
Subcommands: []*cli.Command{
{
Name: "list",
Usage: "List audio sinks on a remote device",
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
}
data, err := cl.RemoteVolumeList(c.Args().First())
if err != nil {
return err
}
var sinks []remotesystemvolume.SinkInfo
if err := json.Unmarshal(data, &sinks); err != nil {
return fmt.Errorf("decode sinks: %w", err)
}
if len(sinks) == 0 {
fmt.Println("No sinks known — connect to the device first.")
return nil
}
for _, s := range sinks {
muted := ""
if s.Muted {
muted = " (MUTED)"
}
fmt.Printf("%s %d%%%s\n", s.Name, s.Volume, muted)
}
return nil
},
},
{
Name: "set",
Usage: "Set volume on a remote device sink",
ArgsUsage: "<device-id> <sink-name> <0-100>",
Action: func(c *cli.Context) error {
if c.NArg() < 3 {
return fmt.Errorf("usage: kcd volume set <device-id> <sink-name> <0-100>")
}
cl, err := getClient(c)
if err != nil {
return err
}
volume := c.Args().Get(2)
var vol int
if _, err := fmt.Sscanf(volume, "%d", &vol); err != nil || vol < 0 || vol > 100 {
return fmt.Errorf("volume must be 0-100")
}
if err := cl.RemoteVolumeSet(c.Args().First(), c.Args().Get(1), vol); err != nil {
return err
}
fmt.Println("Volume set.")
return nil
},
},
{
Name: "mute",
Usage: "Mute or unmute a remote device sink",
ArgsUsage: "<device-id> <sink-name> <true|false>",
Action: func(c *cli.Context) error {
if c.NArg() < 3 {
return fmt.Errorf("usage: kcd volume mute <device-id> <sink-name> <true|false>")
}
cl, err := getClient(c)
if err != nil {
return err
}
muted := c.Args().Get(2) == "true"
if err := cl.RemoteVolumeMute(c.Args().First(), c.Args().Get(1), muted); err != nil {
return err
}
state := "muted"
if !muted {
state = "unmuted"
}
fmt.Printf("Sink %s.\n", state)
return nil
},
},
},
}
1 change: 1 addition & 0 deletions cmd/kcd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ func main() {
runCmd,
smsCmd,
mprisCmd,
volumeCmd,
{

Name: "doctor",
Expand Down
54 changes: 54 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,60 @@ kcd sms attachment <device-id> <part-id> <unique-identifier>

---

## volume

Control the remote device's audio volume (requires `remotesystemvolume` plugin).

### volume list

List audio sinks on a remote device and their current volume/mute state.

```
kcd volume list <device-id>
```

**Example output**

```
media 75%
alarm 100%
```

### volume set

Set a sink's volume (0–100) on a remote device.

```
kcd volume set <device-id> <sink-name> <0-100>
```

**Examples**

```bash
kcd volume set a1b2... media 50
kcd volume set a1b2... alarm 80
```

### volume mute

Mute or unmute a sink on a remote device.

```
kcd volume mute <device-id> <sink-name> <true|false>
```

**Examples**

```bash
kcd volume mute a1b2... media true # Mute
kcd volume mute a1b2... media false # Unmute
```

> Sink names and current volume can be discovered with `kcd volume list`.
> Volume changes made on the phone are also published as `volume.update` events.

---

## watch

Monitor real-time events from the daemon as an NDJSON stream. This is the primary way to observe what is happening across all devices.
Expand Down
26 changes: 25 additions & 1 deletion docs/CLIENT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,31 @@ ipc_request(sock, "clipboard_push", {"deviceId": dev_id})

The daemon reads the local clipboard (`wl-paste`/`xclip`) and sends it.

### 5.9 Get SFTP Connection Info
### 5.9 Remote Volume Control

```python
# List audio sinks
resp = ipc_request(sock, "remote_volume_list", {"deviceId": dev_id})
if resp["ok"]:
for sink in resp["data"]:
print(f'{sink["name"]}: {sink["volume"]}% (muted: {sink["muted"]})')

# Set volume
ipc_request(sock, "remote_volume_set", {
"deviceId": dev_id, "name": "media", "volume": 50
})

# Mute/unmute
ipc_request(sock, "remote_volume_mute", {
"deviceId": dev_id, "name": "media", "muted": True
})
```

Volume changes from the phone arrive as `volume.update` events. The event payload
is either `{"name", "volume", "muted"}` for a single sink change or `{"sinks": [...]}`
for the full sink list.

### 5.10 Get SFTP Connection Info

```python
resp = ipc_request(sock, "sftp_info", {"deviceId": dev_id})
Expand Down
67 changes: 65 additions & 2 deletions docs/IPC_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,57 @@ an integer value field). Seek with `"seek"` (int64, offset in ms) or

**Response data:** none

#### `remote_volume_list`

List the last known audio sinks for a device.

**Request payload:**

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

**Response data:** `[]SinkInfo`

```json
{
"ok": true,
"data": [
{"name": "media", "description": "Media", "volume": 75, "muted": false, "maxVolume": 100}
]
}
```

Returns an empty array if the plugin has not yet received a sink list from the device (connect to the device first).

**Error cases:**
- `"device not found"` — the device ID is unknown or was removed
- `"remotesystemvolume plugin not enabled"` — plugin disabled in config

#### `remote_volume_set`

Set the volume of a specific audio sink on a remote device (0–100).

**Request payload:**

```json
{"deviceId": "a1b2c3d4e5f6_...", "name": "media", "volume": 50}
```

**Response data:** none

#### `remote_volume_mute`

Mute or unmute a specific audio sink on a remote device.

**Request payload:**

```json
{"deviceId": "a1b2c3d4e5f6_...", "name": "media", "muted": true}
```

**Response data:** none

#### `mpris_remote`

List remote MPRIS players (players on paired devices).
Expand Down Expand Up @@ -933,9 +984,9 @@ SFTP credentials received (success) or error.

#### `volume.update`

Device volume level changed.
Device volume level changed (sent in two shapes).

**Payload:**
**Payload (per-sink update):**

```json
{"name": "media", "volume": 70, "muted": false}
Expand All @@ -947,6 +998,16 @@ Device volume level changed.
| `volume` | number | Volume level (0–100) |
| `muted` | bool | Whether the stream is muted |

**Payload (full sink list):**

```json
{
"sinks": [
{"name": "media", "description": "Media", "volume": 75, "muted": false, "maxVolume": 100}
]
}
```

### 5.11 SMS Events

#### `sms.incoming`
Expand Down Expand Up @@ -1054,6 +1115,7 @@ who may want to implement a full network-level implementation.
| `kdeconnect.sms.request_attachment` | SMS | Request an MMS attachment file |
| `kdeconnect.telephony.request_mute` | Telephony | Mute incoming call ringer |
| `kdeconnect.systemvolume` | SystemVolume | Push local sink list to phone |
| `kdeconnect.systemvolume.request` | RemoteSystemVolume | Set phone volume/mute or request sink list |
| `kdeconnect.findmyphone.request` | FindMyPhone | Trigger phone ringer |
| `kdeconnect.connectivity_report.request` | Connectivity | Request signal strength report |
| `kdeconnect.ping` | Ping | Send a ping |
Expand Down Expand Up @@ -1090,6 +1152,7 @@ plugin processes it and a link to the body struct definition.
| `kdeconnect.mpris.request` | MPRIS | `MPRISRequest{}` (same struct, different semantics) |
| `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
2 changes: 2 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type Config struct {
Share ShareConfig `toml:"share"`
SFTP SFTPConfig `toml:"sftp"`
Ping PingConfig `toml:"ping"`
Clipboard ClipboardConfig `toml:"clipboard"`
Pairing PairingConfig `toml:"pairing"`
Mousepad MousepadConfig `toml:"mousepad"`
SMS SMSConfig `toml:"sms"`
Expand Down Expand Up @@ -74,6 +75,7 @@ func Defaults() *Config {
c.Ping.Defaults()
c.Pairing.Defaults()
c.Mousepad.Defaults()
c.Clipboard.Defaults()
c.SMS.Defaults()
c.PruneStaleThreshold = "15m"

Expand Down
48 changes: 30 additions & 18 deletions internal/config/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,25 @@ import (
)

type PluginConfig struct {
Battery bool `toml:"battery"`
Clipboard bool `toml:"clipboard"`
Notification bool `toml:"notification"`
Share bool `toml:"share"`
RunCommand bool `toml:"runcommand"`
MPRIS bool `toml:"mpris"`
Ping bool `toml:"ping"`
Telephony bool `toml:"telephony"`
Connectivity bool `toml:"connectivity"`
Mousepad bool `toml:"mousepad"`
SFTP bool `toml:"sftp"`
FindMyPhone bool `toml:"findmyphone"`
LockDevice bool `toml:"lockdevice"`
SystemVolume bool `toml:"systemvolume"`
PauseMusic bool `toml:"pausemusic"`
SMS bool `toml:"sms"`
Presenter bool `toml:"presenter"`
FindThisDevice bool `toml:"findthisdevice"`
Battery bool `toml:"battery"`
Clipboard bool `toml:"clipboard"`
Notification bool `toml:"notification"`
Share bool `toml:"share"`
RunCommand bool `toml:"runcommand"`
MPRIS bool `toml:"mpris"`
Ping bool `toml:"ping"`
Telephony bool `toml:"telephony"`
Connectivity bool `toml:"connectivity"`
Mousepad bool `toml:"mousepad"`
SFTP bool `toml:"sftp"`
FindMyPhone bool `toml:"findmyphone"`
LockDevice bool `toml:"lockdevice"`
SystemVolume bool `toml:"systemvolume"`
PauseMusic bool `toml:"pausemusic"`
SMS bool `toml:"sms"`
Presenter bool `toml:"presenter"`
FindThisDevice bool `toml:"findthisdevice"`
RemoteSystemVolume bool `toml:"remotesystemvolume"`
}

type BatteryConfig struct {
Expand Down Expand Up @@ -101,6 +102,7 @@ func (p *PluginConfig) Defaults() {
p.SMS = true
p.Presenter = true
p.FindThisDevice = true
p.RemoteSystemVolume = true
}

func (c *BatteryConfig) Defaults() {
Expand All @@ -118,6 +120,16 @@ func (c *NotificationPluginConfig) Defaults() {
c.ExpireMS = -1
}

type ClipboardConfig struct {
// PushOnConnect sends the local clipboard to a device when it
// connects/reconnects. Off by default to avoid spurious pushes.
PushOnConnect bool `toml:"push_on_connect"`
}

func (c *ClipboardConfig) Defaults() {
c.PushOnConnect = false
}

func (c *ShareConfig) Defaults() {
c.PortMin = 1739
c.PortMax = 1764
Expand Down
3 changes: 3 additions & 0 deletions internal/daemon/ipc_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ func registerIPCRoutes(handler *ipc.Handler, cfg *config.Config, devices *device
if cfg.Plugins.MPRIS {
registerMprisRoutes(handler, devices, plugins)
}
if cfg.Plugins.RemoteSystemVolume {
registerRemoteVolumeRoutes(handler, devices, plugins)
}

handler.Register(ipc.CmdConnect, func(req ipc.Request) ipc.Response {
var p ipc.ConnectPayload
Expand Down
Loading
Loading