diff --git a/cmd/kcd/cli_volume.go b/cmd/kcd/cli_volume.go new file mode 100644 index 0000000..fd1cc58 --- /dev/null +++ b/cmd/kcd/cli_volume.go @@ -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: "", + 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: " <0-100>", + Action: func(c *cli.Context) error { + if c.NArg() < 3 { + return fmt.Errorf("usage: kcd volume set <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: " ", + Action: func(c *cli.Context) error { + if c.NArg() < 3 { + return fmt.Errorf("usage: kcd volume mute ") + } + 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 + }, + }, + }, +} diff --git a/cmd/kcd/main.go b/cmd/kcd/main.go index 3209eaa..2d4e07b 100644 --- a/cmd/kcd/main.go +++ b/cmd/kcd/main.go @@ -125,6 +125,7 @@ func main() { runCmd, smsCmd, mprisCmd, + volumeCmd, { Name: "doctor", diff --git a/docs/CLI.md b/docs/CLI.md index a5e66e2..9dc45c8 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -721,6 +721,60 @@ kcd sms attachment --- +## 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 +``` + +**Example output** + +``` +media 75% +alarm 100% +``` + +### volume set + +Set a sink's volume (0–100) on a remote device. + +``` +kcd volume set <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 +``` + +**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. diff --git a/docs/CLIENT_GUIDE.md b/docs/CLIENT_GUIDE.md index 2f7af7d..347fd18 100644 --- a/docs/CLIENT_GUIDE.md +++ b/docs/CLIENT_GUIDE.md @@ -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}) diff --git a/docs/IPC_PROTOCOL.md b/docs/IPC_PROTOCOL.md index 033936f..50f8fe0 100644 --- a/docs/IPC_PROTOCOL.md +++ b/docs/IPC_PROTOCOL.md @@ -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). @@ -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} @@ -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` @@ -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 | @@ -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}` | --- diff --git a/internal/config/config.go b/internal/config/config.go index 027c833..dbf2da8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` @@ -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" diff --git a/internal/config/plugins.go b/internal/config/plugins.go index b6990a6..2ec05a5 100644 --- a/internal/config/plugins.go +++ b/internal/config/plugins.go @@ -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 { @@ -101,6 +102,7 @@ func (p *PluginConfig) Defaults() { p.SMS = true p.Presenter = true p.FindThisDevice = true + p.RemoteSystemVolume = true } func (c *BatteryConfig) Defaults() { @@ -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 diff --git a/internal/daemon/ipc_routes.go b/internal/daemon/ipc_routes.go index 5156b45..2757671 100644 --- a/internal/daemon/ipc_routes.go +++ b/internal/daemon/ipc_routes.go @@ -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 diff --git a/internal/daemon/ipc_routes_remotevolume.go b/internal/daemon/ipc_routes_remotevolume.go new file mode 100644 index 0000000..24c991a --- /dev/null +++ b/internal/daemon/ipc_routes_remotevolume.go @@ -0,0 +1,80 @@ +package daemon + +import ( + "encoding/json" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/ipc" + "github.com/bethropolis/kcd/internal/plugin" + "github.com/bethropolis/kcd/internal/plugins/remotesystemvolume" +) + +func registerRemoteVolumeRoutes(handler *ipc.Handler, devices *device.Registry, plugins *plugin.Registry) { + handler.Register(ipc.CmdRemoteVolumeList, func(req ipc.Request) ipc.Response { + var p ipc.DevicePayload + if err := json.Unmarshal(req.Payload, &p); err != nil { + return ipc.Response{OK: false, Error: "invalid payload"} + } + pl, ok := plugins.GetByName("RemoteSystemVolume") + if !ok { + return ipc.Response{OK: false, Error: "remotesystemvolume plugin not enabled"} + } + sinks := pl.(*remotesystemvolume.RemoteSystemVolumePlugin).ListSinks(p.DeviceID) + if sinks == nil { + return ipc.Response{OK: true, Data: mustJSON([]remotesystemvolume.SinkInfo{})} + } + data, _ := json.Marshal(sinks) + return ipc.Response{OK: true, Data: data} + }) + + handler.Register(ipc.CmdRemoteVolumeSet, func(req ipc.Request) ipc.Response { + var p struct { + DeviceID string `json:"deviceId"` + Name string `json:"name"` + Volume int `json:"volume"` + } + if err := json.Unmarshal(req.Payload, &p); err != nil { + return ipc.Response{OK: false, Error: "invalid payload"} + } + pl, ok := plugins.GetByName("RemoteSystemVolume") + if !ok { + return ipc.Response{OK: false, Error: "remotesystemvolume plugin not enabled"} + } + dev, ok := devices.Get(p.DeviceID) + if !ok { + return ipc.Response{OK: false, Error: "device not found"} + } + if err := pl.(*remotesystemvolume.RemoteSystemVolumePlugin).SetVolume(dev, p.Name, p.Volume); err != nil { + return ipc.Response{OK: false, Error: err.Error()} + } + return ipc.Response{OK: true} + }) + + handler.Register(ipc.CmdRemoteVolumeMute, func(req ipc.Request) ipc.Response { + var p struct { + DeviceID string `json:"deviceId"` + Name string `json:"name"` + Muted bool `json:"muted"` + } + if err := json.Unmarshal(req.Payload, &p); err != nil { + return ipc.Response{OK: false, Error: "invalid payload"} + } + pl, ok := plugins.GetByName("RemoteSystemVolume") + if !ok { + return ipc.Response{OK: false, Error: "remotesystemvolume plugin not enabled"} + } + dev, ok := devices.Get(p.DeviceID) + if !ok { + return ipc.Response{OK: false, Error: "device not found"} + } + if err := pl.(*remotesystemvolume.RemoteSystemVolumePlugin).SetMuted(dev, p.Name, p.Muted); err != nil { + return ipc.Response{OK: false, Error: err.Error()} + } + return ipc.Response{OK: true} + }) +} + +func mustJSON(v any) []byte { + b, _ := json.Marshal(v) + return b +} diff --git a/internal/daemon/plugins.go b/internal/daemon/plugins.go index 465552d..1d66d40 100644 --- a/internal/daemon/plugins.go +++ b/internal/daemon/plugins.go @@ -20,6 +20,7 @@ import ( "github.com/bethropolis/kcd/internal/plugins/pair" "github.com/bethropolis/kcd/internal/plugins/ping" "github.com/bethropolis/kcd/internal/plugins/presenter" + "github.com/bethropolis/kcd/internal/plugins/remotesystemvolume" "github.com/bethropolis/kcd/internal/plugins/runcommand" "github.com/bethropolis/kcd/internal/plugins/sftp" "github.com/bethropolis/kcd/internal/plugins/share" @@ -39,7 +40,7 @@ func setupPlugins(cfg *config.Config, bus *events.Bus, tlsCfg *tls.Config, logge plugins.Register(notification.NewNotificationPlugin(cfg.Notification, bus, tlsCfg, logger)) } if cfg.Plugins.Clipboard { - plugins.Register(clipboard.NewClipboardPlugin(tlsCfg, logger)) + plugins.Register(clipboard.NewClipboardPlugin(tlsCfg, logger, cfg.Clipboard.PushOnConnect)) } if cfg.Plugins.Share { plugins.Register(share.NewSharePlugin(cfg.DownloadDir, cfg.Share, tlsCfg, bus, logger)) @@ -83,6 +84,9 @@ func setupPlugins(cfg *config.Config, bus *events.Bus, tlsCfg *tls.Config, logge if cfg.Plugins.SMS { plugins.Register(sms.NewSMSPlugin(cfg.SMS, bus, tlsCfg, logger)) } + if cfg.Plugins.RemoteSystemVolume { + plugins.Register(remotesystemvolume.NewRemoteSystemVolumePlugin(bus, logger)) + } return pairPlugin } diff --git a/internal/daemon/transport.go b/internal/daemon/transport.go index 75296ba..0ce77c5 100644 --- a/internal/daemon/transport.go +++ b/internal/daemon/transport.go @@ -227,6 +227,12 @@ func handleNewConnection(ctx context.Context, conn *transport.Conn, identity *pr if lastIP == nil { return } + // Prevent multiple concurrent reconnect goroutines for the same device. + if !sender.TryReconnect() { + logger.Debug("auto-reconnect: already reconnecting, skipping", + zap.String("device_id", sender.ID())) + return + } go reconnectWithBackoff(ctx, sender, lastIP, identity, cfg, devices, plugins, localDeviceID, logger) } @@ -256,6 +262,8 @@ func reconnectWithBackoff( const maxBackoff = 5 * time.Minute attempt := 0 + defer dev.ReconnectDone() + logger.Info("starting auto-reconnect", zap.String("device_id", dev.ID()), zap.String("device_name", dev.Name()), diff --git a/internal/device/device.go b/internal/device/device.go index 68f0953..a027eac 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -5,6 +5,7 @@ import ( "crypto/x509" "net" "sync" + "sync/atomic" "time" "github.com/bethropolis/kcd/internal/events" @@ -38,6 +39,10 @@ type Device struct { mu sync.RWMutex + // reconnecting is an atomic flag preventing multiple concurrent + // auto-reconnect goroutines for this device. + reconnecting atomic.Bool + // pluginDispatch routes incoming packets to registered plugins pluginDispatch func(ctx context.Context, dev *Device, pkt *protocol.Packet) bool onConnect func(dev *Device) @@ -296,3 +301,16 @@ func (d *Device) SetLastSeen(t time.Time) { defer d.mu.Unlock() d.lastSeen = t } + +// TryReconnect attempts to mark the device as reconnecting. +// Returns true if this goroutine should proceed; false if another +// reconnect goroutine is already running. +func (d *Device) TryReconnect() bool { + return d.reconnecting.CompareAndSwap(false, true) +} + +// ReconnectDone marks the device as no longer reconnecting. +// Must be called (typically via defer) after a reconnect goroutine exits. +func (d *Device) ReconnectDone() { + d.reconnecting.Store(false) +} diff --git a/internal/device/registry.go b/internal/device/registry.go index 59ebed8..0f4dbe4 100644 --- a/internal/device/registry.go +++ b/internal/device/registry.go @@ -117,10 +117,17 @@ func (r *Registry) Prune(threshold time.Duration) int { // Caps out at maxDuration. func ReconnectBackoff(attempt int, maxDuration time.Duration) time.Duration { if attempt <= 0 { - return time.Second * 2 + return 2 * time.Second } - dur := time.Duration(1< maxDuration || dur < 0 { // < 0 checks integer overflow + // Guard against shift overflow. On 64-bit, 1<= 54 (producing 0 due to modular multiplication), and + // on 32-bit, 1<<32 wraps to 0. Cap early — the backoff is already + // enormous at attempt=30 (~68 years if uncapped). + if attempt >= 31 { + return maxDuration + } + dur := time.Duration(1< maxDuration || dur <= 0 { return maxDuration } return dur diff --git a/internal/ipc/proto.go b/internal/ipc/proto.go index cc7f784..44e2c1f 100644 --- a/internal/ipc/proto.go +++ b/internal/ipc/proto.go @@ -39,6 +39,9 @@ const ( CmdMprisAction = "mpris_action" CmdMprisRemote = "mpris_remote" CmdSftpBrowse = "sftp_browse" + CmdRemoteVolumeList = "remote_volume_list" + CmdRemoteVolumeSet = "remote_volume_set" + CmdRemoteVolumeMute = "remote_volume_mute" ) // ConnectPayload carries the target IP for the CmdConnect command. diff --git a/internal/plugins/clipboard/clipboard.go b/internal/plugins/clipboard/clipboard.go index 8fed684..eed77b0 100644 --- a/internal/plugins/clipboard/clipboard.go +++ b/internal/plugins/clipboard/clipboard.go @@ -20,26 +20,94 @@ import ( "go.uber.org/zap" ) +type clipboardBackend int + +const ( + backendUnknown clipboardBackend = iota + backendWayland + backendX11 +) + // ClipboardPlugin handles clipboard sync both directions. type ClipboardPlugin struct { + pushOnConnect bool lastTimestamp int64 tlsConfig *tls.Config logger *zap.Logger - isWayland bool + backend clipboardBackend + backendOnce sync.Once mu sync.Mutex lastContent string // last content received from phone (inbound) lastPushedContent string // last content sent to phone (outbound) } // NewClipboardPlugin creates a clipboard plugin. -func NewClipboardPlugin(tlsConfig *tls.Config, logger *zap.Logger) *ClipboardPlugin { +func NewClipboardPlugin(tlsConfig *tls.Config, logger *zap.Logger, pushOnConnect bool) *ClipboardPlugin { return &ClipboardPlugin{ - tlsConfig: tlsConfig, - logger: logger.With(zap.String("plugin", "clipboard")), - isWayland: os.Getenv("WAYLAND_DISPLAY") != "", + tlsConfig: tlsConfig, + pushOnConnect: pushOnConnect, + logger: logger.With(zap.String("plugin", "clipboard")), } } +// detectBackend probes for a working clipboard tool (wl-paste → xclip) +// and caches the result so the probe runs at most once. +// +// Under systemd user services neither WAYLAND_DISPLAY nor DISPLAY +// is typically set. We probe by checking the display variable first, +// then falling back to scanning $XDG_RUNTIME_DIR for a Wayland socket. +func (p *ClipboardPlugin) detectBackend() clipboardBackend { + p.backendOnce.Do(func() { + wlDisplay := os.Getenv("WAYLAND_DISPLAY") + xDisplay := os.Getenv("DISPLAY") + + switch { + case wlDisplay != "": + if _, err := exec.LookPath("wl-paste"); err == nil { + p.backend = backendWayland + p.logger.Debug("clipboard: backend=wayland (WAYLAND_DISPLAY set)") + } + case xDisplay != "": + if _, err := exec.LookPath("xclip"); err == nil { + p.backend = backendX11 + p.logger.Debug("clipboard: backend=x11 (DISPLAY set)") + } + default: + // Systemd user service — neither variable is set. + // Probe $XDG_RUNTIME_DIR for any Wayland socket. + rtDir := os.Getenv("XDG_RUNTIME_DIR") + hasWaylandSock := false + if rtDir != "" { + if entries, err := os.ReadDir(rtDir); err == nil { + for _, e := range entries { + if strings.HasPrefix(e.Name(), "wayland-") && !e.IsDir() { + hasWaylandSock = true + break + } + } + } + } + if hasWaylandSock { + if _, err := exec.LookPath("wl-paste"); err == nil { + p.backend = backendWayland + p.logger.Debug("clipboard: backend=wayland (socket probe)") + } + } + if p.backend == backendUnknown { + if _, err := exec.LookPath("xclip"); err == nil { + p.backend = backendX11 + p.logger.Debug("clipboard: backend=x11 (fallback)") + } + } + } + + if p.backend == backendUnknown { + p.logger.Warn("clipboard: no clipboard tool found (install wl-clipboard or xclip)") + } + }) + return p.backend +} + // ClipboardBody represents the content of a clipboard packet. type ClipboardBody struct { Content string `json:"content"` @@ -100,14 +168,19 @@ func (p *ClipboardPlugin) Handle(ctx context.Context, dev device.Sender, pkt *pr // Spawning goroutine as Handlers must not block. go func() { var cmd *exec.Cmd - if p.isWayland { + switch p.detectBackend() { + case backendWayland: cmd = exec.CommandContext(context.Background(), "wl-copy") - } else { + case backendX11: cmd = exec.CommandContext(context.Background(), "xclip", "-selection", "clipboard") + default: + return } cmd.Stdin = strings.NewReader(body.Content) - _ = cmd.Run() + if err := cmd.Run(); err != nil { + p.logger.Warn("clipboard: failed to set clipboard", zap.Error(err)) + } }() return nil @@ -175,10 +248,13 @@ func (p *ClipboardPlugin) handleClipboardFile(ctx context.Context, dev device.Se defer t.Close() var cmd *exec.Cmd - if p.isWayland { + switch p.detectBackend() { + case backendWayland: cmd = exec.CommandContext(context.Background(), "wl-copy", "--type", mimeType) - } else { + case backendX11: cmd = exec.CommandContext(context.Background(), "xclip", "-selection", "clipboard", "-t", mimeType, "-i") + default: + return } cmd.Stdin = t if out, err := cmd.CombinedOutput(); err != nil { @@ -221,10 +297,13 @@ func downloadToFile(ctx context.Context, ip net.IP, port int, size int64, dest s // Push copies the local clipboard to the remote device using wl-paste or xclip -o. func Push(ctx context.Context, dev device.Sender, p *ClipboardPlugin) error { var cmd *exec.Cmd - if p.isWayland { + switch p.detectBackend() { + case backendWayland: cmd = exec.CommandContext(ctx, "wl-paste", "-n") - } else { + case backendX11: cmd = exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-o") + default: + return fmt.Errorf("clipboard: no clipboard tool available") } out, err := cmd.Output() @@ -261,19 +340,26 @@ func Push(ctx context.Context, dev device.Sender, p *ClipboardPlugin) error { func (p *ClipboardPlugin) readClipboard() string { var cmd *exec.Cmd - if p.isWayland { + switch p.detectBackend() { + case backendWayland: cmd = exec.CommandContext(context.Background(), "wl-paste", "-n") - } else { + case backendX11: cmd = exec.CommandContext(context.Background(), "xclip", "-selection", "clipboard", "-o") + default: + return "" } out, err := cmd.Output() if err != nil { + p.logger.Debug("clipboard: read failed", zap.Error(err)) return "" } return string(out) } func (p *ClipboardPlugin) OnConnect(dev device.Sender) { + if !p.pushOnConnect { + return + } content := p.readClipboard() p.mu.Lock() diff --git a/internal/plugins/remotesystemvolume/remotesystemvolume.go b/internal/plugins/remotesystemvolume/remotesystemvolume.go new file mode 100644 index 0000000..235d3a5 --- /dev/null +++ b/internal/plugins/remotesystemvolume/remotesystemvolume.go @@ -0,0 +1,146 @@ +package remotesystemvolume + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/events" + "github.com/bethropolis/kcd/internal/protocol" + "go.uber.org/zap" +) + +type VolumeBody struct { + RequestSinks bool `json:"requestSinks,omitempty"` + Name string `json:"name,omitempty"` + Volume int `json:"volume,omitempty"` + Muted bool `json:"muted,omitempty"` + MaxVolume int `json:"maxVolume,omitempty"` + SinkList []SinkInfo `json:"sinkList,omitempty"` +} + +type SinkInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Volume int `json:"volume"` + Muted bool `json:"muted"` + MaxVolume int `json:"maxVolume"` +} + +type RemoteSystemVolumePlugin struct { + logger *zap.Logger + bus *events.Bus + sinks sync.Map // device ID -> []SinkInfo +} + +func NewRemoteSystemVolumePlugin(bus *events.Bus, logger *zap.Logger) *RemoteSystemVolumePlugin { + return &RemoteSystemVolumePlugin{ + logger: logger.With(zap.String("plugin", "remotesystemvolume")), + bus: bus, + } +} + +func (p *RemoteSystemVolumePlugin) Name() string { return "RemoteSystemVolume" } +func (p *RemoteSystemVolumePlugin) Timeout() time.Duration { return 5 * time.Second } +func (p *RemoteSystemVolumePlugin) IncomingTypes() []string { + return []string{"kdeconnect.systemvolume"} +} +func (p *RemoteSystemVolumePlugin) OutgoingTypes() []string { + return []string{"kdeconnect.systemvolume.request"} +} + +func (p *RemoteSystemVolumePlugin) Handle(ctx context.Context, dev device.Sender, pkt *protocol.Packet) error { + var body VolumeBody + if err := json.Unmarshal(pkt.Body, &body); err != nil { + return err + } + + if body.SinkList != nil { + p.sinks.Store(dev.ID(), body.SinkList) + if p.bus != nil { + p.bus.Publish(events.TypeVolumeUpdate, dev.ID(), map[string]any{ + "sinks": body.SinkList, + }) + } + return nil + } + + if body.Name != "" { + if val, ok := p.sinks.Load(dev.ID()); ok { + sinks := val.([]SinkInfo) + for i, s := range sinks { + if s.Name == body.Name { + sinks[i].Volume = body.Volume + sinks[i].Muted = body.Muted + break + } + } + p.sinks.Store(dev.ID(), sinks) + } + if p.bus != nil { + p.bus.Publish(events.TypeVolumeUpdate, dev.ID(), map[string]any{ + "name": body.Name, + "volume": body.Volume, + "muted": body.Muted, + }) + } + } + + return nil +} + +func (p *RemoteSystemVolumePlugin) ListSinks(deviceID string) []SinkInfo { + if val, ok := p.sinks.Load(deviceID); ok { + return val.([]SinkInfo) + } + return nil +} + +func (p *RemoteSystemVolumePlugin) SetVolume(dev device.Sender, sinkName string, volume int) error { + pkt, err := protocol.NewPacket("kdeconnect.systemvolume.request", VolumeBody{ + Name: sinkName, + Volume: volume, + Muted: false, + }) + if err != nil { + return err + } + return dev.Send(pkt) +} + +func (p *RemoteSystemVolumePlugin) SetMuted(dev device.Sender, sinkName string, muted bool) error { + pkt, err := protocol.NewPacket("kdeconnect.systemvolume.request", VolumeBody{ + Name: sinkName, + Muted: muted, + }) + if err != nil { + return err + } + return dev.Send(pkt) +} + +func (p *RemoteSystemVolumePlugin) RequestSinkList(dev device.Sender) error { + pkt, err := protocol.NewPacket("kdeconnect.systemvolume.request", VolumeBody{ + RequestSinks: true, + }) + if err != nil { + return err + } + return dev.Send(pkt) +} + +func (p *RemoteSystemVolumePlugin) OnConnect(dev device.Sender) { + go func() { + if err := p.RequestSinkList(dev); err != nil { + p.logger.Warn("failed to request sink list", + zap.String("device", dev.ID()), + zap.Error(err), + ) + } + }() +} +func (p *RemoteSystemVolumePlugin) OnDisconnect(dev device.Sender) { + p.sinks.Delete(dev.ID()) +} diff --git a/packaging/kcd.example.toml b/packaging/kcd.example.toml index cb55406..3d21c45 100644 --- a/packaging/kcd.example.toml +++ b/packaging/kcd.example.toml @@ -76,6 +76,12 @@ battery = true # X11: requires xclip. clipboard = true +[clipboard] +# Push the local clipboard to a device every time it connects +# or reconnects. Off by default to avoid spurious pushes on +# daemon restart or phone reconnection. +# push_on_connect = false + # Forward phone notifications to the desktop via notify-send. # Requires libnotify / a running notification daemon. notification = true @@ -145,6 +151,13 @@ sms = true # Screen size detection: requires xdpyinfo (X11) or wlr-randr (Wayland). presenter = true +# Control the remote device's audio volume from this machine. +# Commands: +# kcd volume list — List audio sinks on the phone +# kcd volume set <0-100> — Set volume for a sink +# kcd volume mute — Mute/unmute a sink +remotesystemvolume = true + # ─── RunCommand: exposed commands ───────────────────────────────────────────── # Shell commands that the RunCommand plugin makes available to your phone. # Trigger them from the KDE Connect Android app → your device → Run command. diff --git a/pkg/client/client.go b/pkg/client/client.go index 7f240b2..5444af8 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -397,7 +397,36 @@ func (c *Client) MprisRemote() (*ipc.MprisRemoteResponse, error) { return &resp, nil } -// WatchFile subscribes to daemon events and streams them to the given channel. +// RemoteVolumeList returns the last known sink list for a device. +func (c *Client) RemoteVolumeList(deviceID string) ([]byte, error) { + resp, err := c.Call(ipc.CmdRemoteVolumeList, ipc.DevicePayload{DeviceID: deviceID}) + if err != nil { + return nil, err + } + return resp.Data, nil +} + +// RemoteVolumeSet sets the volume for a specific sink on a remote device. +func (c *Client) RemoteVolumeSet(deviceID, sinkName string, volume int) error { + _, err := c.Call(ipc.CmdRemoteVolumeSet, struct { + DeviceID string `json:"deviceId"` + Name string `json:"name"` + Volume int `json:"volume"` + }{DeviceID: deviceID, Name: sinkName, Volume: volume}) + return err +} + +// RemoteVolumeMute sets the mute state for a specific sink on a remote device. +func (c *Client) RemoteVolumeMute(deviceID, sinkName string, muted bool) error { + _, err := c.Call(ipc.CmdRemoteVolumeMute, struct { + DeviceID string `json:"deviceId"` + Name string `json:"name"` + Muted bool `json:"muted"` + }{DeviceID: deviceID, Name: sinkName, Muted: muted}) + return err +} + +// Watch subscribes to daemon events and streams them to the given channel. func (c *Client) Watch(ctx context.Context, filter []string, ch chan<- events.Event) error { dialer := net.Dialer{} conn, err := dialer.DialContext(ctx, "unix", c.SocketPath)