From 601d8fa6dec4f612100d769780e0e1957c68ae68 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:31:48 +0300 Subject: [PATCH 01/25] fix(discovery): stop auto-dialling unpaired strangers, pair on demand Only open outbound TCP on discovery for paired devices, active pairing mode, or explicit 'kcd pair ' intent (one-shot dial + on-demand pair request via last-seen discovery address). Discovered strangers are still recorded as UNPAIRED (new-device default, with UNKNOWN migration) but never dialled. '--connected' and clipboard/findmyphone auto-pick now mean paired+connected; client docs require state==PAIRED for auto-select. --- cmd/kcd/cli_clipboard.go | 5 ++-- cmd/kcd/cli_devices.go | 6 +++-- cmd/kcd/cli_findmyphone.go | 5 ++-- docs/CLI.md | 14 +++++++---- docs/CLIENT_GUIDE.md | 17 +++++++++++++ docs/IPC_PROTOCOL.md | 7 ++++-- internal/daemon/daemon.go | 46 ++++++++++++++++++++++++++++++++++++ internal/daemon/transport.go | 36 +++++++++++++++++++++++++++- internal/device/device.go | 42 ++++++++++++++++++++++++++++++++ internal/ipc/handler.go | 23 ++++++++++++++++++ 10 files changed, 187 insertions(+), 14 deletions(-) diff --git a/cmd/kcd/cli_clipboard.go b/cmd/kcd/cli_clipboard.go index 9c2bf99..c171769 100644 --- a/cmd/kcd/cli_clipboard.go +++ b/cmd/kcd/cli_clipboard.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" + "github.com/bethropolis/kcd/internal/device" "github.com/urfave/cli/v2" ) @@ -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") } } diff --git a/cmd/kcd/cli_devices.go b/cmd/kcd/cli_devices.go index 2bd9515..20c6414 100644 --- a/cmd/kcd/cli_devices.go +++ b/cmd/kcd/cli_devices.go @@ -28,7 +28,7 @@ var devicesCmd = &cli.Command{ }, &cli.BoolFlag{ Name: "connected", - Usage: "Only show currently connected devices", + Usage: "Only show connected AND paired (usable) devices", }, }, Action: func(c *cli.Context) error { @@ -46,7 +46,9 @@ var devicesCmd = &cli.Command{ if c.Bool("connected") { filtered := make([]device.DeviceInfo, 0, len(devices)) for _, d := range devices { - if d.Connected { + // Usable means both paired and connected: strangers may + // hold a raw TCP connection but can't do anything. + if d.Connected && d.State == device.StatePaired { filtered = append(filtered, d) } } diff --git a/cmd/kcd/cli_findmyphone.go b/cmd/kcd/cli_findmyphone.go index b94870b..b213ee0 100644 --- a/cmd/kcd/cli_findmyphone.go +++ b/cmd/kcd/cli_findmyphone.go @@ -3,6 +3,7 @@ package main import ( "fmt" + "github.com/bethropolis/kcd/internal/device" "github.com/urfave/cli/v2" ) @@ -23,13 +24,13 @@ var findmyphoneCmd = &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") } } diff --git a/docs/CLI.md b/docs/CLI.md index b219266..9ab812a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -28,7 +28,10 @@ kcd daemon [--config ] [--log-level ] ``` The daemon: -- Listens for device announcements via UDP and mDNS (always on) +- Listens for device announcements via UDP and mDNS (always on). + Unpaired strangers are recorded as `UNPAIRED` but never auto-dialled — + outbound connections open only to paired devices, in `kcd pair` listen + mode, or on explicit `kcd pair ` / `kcd connect` request. - Broadcasts its own identity only during `kcd pair` (listen mode) - Accepts inbound TCP connections on port 1716 - Runs all enabled plugins @@ -108,6 +111,7 @@ kcd devices [--json] |---|---| | `--json` | Output as a JSON array | | `--watch`, `-w` | Stream device changes live (clears screen on each change) | +| `--connected` | Only show connected **and paired** (usable) devices — unpaired strangers holding a raw TCP connection are hidden | **Example output** @@ -174,7 +178,7 @@ Pair with a device. Two modes depending on whether you provide a device ID. kcd pair ``` -If the device has already sent a pair request to `kcd` (state `PairRequestedByPeer`), this accepts it. Otherwise, it sends a new pair request — accept on your phone. +If the device has already sent a pair request to `kcd` (state `PairRequestedByPeer`), this accepts it. Otherwise, it connects to the device on demand (using its last-seen discovery address) and sends a new pair request — accept on your phone. ### Listen mode (headless / server) @@ -243,7 +247,7 @@ Push the local clipboard content to a device. kcd clipboard [device-id] ``` -If `device-id` is omitted, `kcd` automatically targets the first connected device. +If `device-id` is omitted, `kcd` automatically targets the first paired and connected device. **Clipboard backend detection** @@ -897,10 +901,10 @@ done ## Tips -**Get the first connected device ID** +**Get the first usable (paired + connected) device ID** ```bash -kcd devices --json | jq -r '[.[] | select(.Connected)] | .[0].ID' +kcd devices --json | jq -r '[.[] | select(.Connected and .State=="PAIRED")] | .[0].ID' ``` **Send a file from a Nautilus script** diff --git a/docs/CLIENT_GUIDE.md b/docs/CLIENT_GUIDE.md index 2a52fca..53fc3f0 100644 --- a/docs/CLIENT_GUIDE.md +++ b/docs/CLIENT_GUIDE.md @@ -88,6 +88,23 @@ for dev in resp["data"]: States: `UNPAIRED`, `PAIR_REQUESTED`, `PAIR_REQUESTED_BY_PEER`, `PAIRED`. +> **Auto-device selection:** `connected: true` only means a raw TCP socket +> is open — unpaired strangers on the LAN also appear connected. Never +> auto-select the first entry with `connected == true`. Always prefer a +> device with `connected and state == "PAIRED"`, falling back to any +> `state == "PAIRED"` device, and to nothing otherwise: +> +> ```python +> def pick_auto_device(devices): +> for d in devices or []: +> if d.get("connected") and d.get("state") == "PAIRED": +> return d +> for d in devices or []: +> if d.get("state") == "PAIRED": +> return d +> return None # don't bind to unpaired stranger devices +> ``` + ### 3.2 Pairing Flow Pairing requires a persistent watch connection to receive the pairing request diff --git a/docs/IPC_PROTOCOL.md b/docs/IPC_PROTOCOL.md index 4250b19..35e11de 100644 --- a/docs/IPC_PROTOCOL.md +++ b/docs/IPC_PROTOCOL.md @@ -94,10 +94,10 @@ Fields: | `id` | string | Permanent device identifier | | `name` | string | Human-readable device name | | `type` | string | `"phone"`, `"tablet"`, `"laptop"`, `"desktop"` | -| `state` | string | `"UNPAIRED"`, `"PAIR_REQUESTED"`, `"PAIR_REQUESTED_BY_PEER"`, `"PAIRED"`, `"UNKNOWN"` | +| `state` | string | `"UNPAIRED"`, `"PAIR_REQUESTED"`, `"PAIR_REQUESTED_BY_PEER"`, `"PAIRED"` (`"UNKNOWN"` may appear in state files written by older versions and means unpaired) | | `cert_fp` | string | Not populated in this response (empty) | | `last_seen` | string (RFC3339) | Not populated in this response (zero time) | -| `connected` | bool | Whether the device currently has an active TCP connection | +| `connected` | bool | Whether the device currently has an active TCP connection. Note: `connected: true` alone does **not** mean usable — a stranger on the LAN can hold a raw connection while `state` is `UNPAIRED`. Clients must check `state == "PAIRED"` before sending commands or auto-selecting a device. | #### `pair` @@ -118,6 +118,9 @@ Optional fields: - If neither `accept` nor `reject` is set, sends a pair request to the device. - If `accept: true`, accepts an incoming pair request from the device. - If `reject: true`, rejects or unpairs. +- If the device has no active connection, the daemon dials it on demand + using its last-seen discovery address (background auto-dial no longer + connects to unpaired devices), then sends the pair request. **Response data:** none (`{"ok": true}`) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 2365d4c..10a1111 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -78,6 +78,11 @@ func Run(ctx context.Context, cfg *config.Config) error { if loaded, err := device.LoadDevices(statePath); err == nil { for _, info := range loaded { dev := device.NewDevice(info.ID, info.Name, info.Type, logger) + // Migrate pre-fix state: devices persisted as UNKNOWN (the old + // zero value) are simply unpaired. + if info.State == device.StateUnknown { + info.State = device.StateUnpaired + } dev.SetState(info.State) dev.CertFP = info.CertFP dev.SetLastSeen(info.LastSeen) @@ -135,6 +140,47 @@ func Run(ctx context.Context, cfg *config.Config) error { registerIPCRoutes(handler, cfg, devices, plugins, bc, ctx, tlsCfg, logger, startedAt) + // Explicit `kcd pair ` dials on demand (background auto-dial no + // longer touches unpaired devices). Marks a one-shot dial flag so the + // next discovery announcement connects, and kicks an immediate dial if + // we already know where the device was last seen. + handler.SetPairDialHook(func(deviceID string) error { + dev, ok := devices.Get(deviceID) + if !ok { + return fmt.Errorf("device not found") + } + if dev.State() == device.StatePaired { + return fmt.Errorf("device already paired") + } + dev.RequestPairDial() + ip, port := dev.DiscoveryAddr() + if ip == nil { + // No address yet — the flagged dial fires on the next + // broadcast from the phone; tell the user to retry then. + return fmt.Errorf("device address unknown yet, wait for discovery and retry") + } + if port == 0 { + port = 1716 + } + go func() { + DialDevice(ctx, ip, port, deviceID, protocol.ProtocolVersion, identity, tlsCfg, devices, plugins, cfg.DeviceID, logger) + if !dev.IsConnected() { + logger.Warn("on-demand pair dial failed", zap.String("device_id", deviceID)) + return + } + if dev.State() == device.StatePairRequestedByPeer { + if err := pairPlugin.AcceptPairing(dev); err != nil { + logger.Warn("auto-accept on pair dial failed", zap.Error(err)) + } + } else if dev.State() != device.StatePaired { + if err := pairPlugin.RequestPairing(dev); err != nil { + logger.Warn("pair request on dial failed", zap.Error(err)) + } + } + }() + return nil + }) + ipcServer := ipc.NewServer(cfg.SocketPath, handler, logger) // Start IPC in background diff --git a/internal/daemon/transport.go b/internal/daemon/transport.go index f36c80b..baa3c6f 100644 --- a/internal/daemon/transport.go +++ b/internal/daemon/transport.go @@ -74,7 +74,7 @@ func DialDevice(ctx context.Context, targetIP net.IP, targetPort int, targetID s } } -func runTransport(ctx context.Context, cfg *tls.Config, _ *discovery.BroadcasterController, identity *protocol.Packet, devices *device.Registry, plugins *plugin.Registry, localDeviceID string, logger *zap.Logger) { +func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.BroadcasterController, identity *protocol.Packet, devices *device.Registry, plugins *plugin.Registry, localDeviceID string, logger *zap.Logger) { // TCP Listener tcpListener, err := transport.Listen(ctx, ":1716") if err != nil { @@ -101,6 +101,40 @@ func runTransport(ctx context.Context, cfg *tls.Config, _ *discovery.Broadcaster return } + // Don't auto-dial strangers heard on the network. Only open an + // outbound TCP connection when the device is already paired, when + // the daemon is actively in pairing mode (`kcd pair` listen mode + // starts the broadcaster), or when the user explicitly requested + // pairing with this device (`kcd pair ` sets a one-shot flag). + // Otherwise just record/update the registry entry so `kcd devices` + // still shows the discovered device as UNPAIRED. + dev, known := devices.Get(body.DeviceID) + isPaired := known && dev.State() == device.StatePaired + pairingMode := bc != nil && bc.IsRunning() + if !isPaired && !pairingMode { + if known && dev.ConsumePairDial() { + dev.SetDiscoveryAddr(ip, tcpPort) + dev.SetLastSeen(time.Now()) + // Fall through to dial below: explicit user intent. + } else { + safeName := protocol.SanitizeDeviceName(body.DeviceName) + if !known { + nd := device.NewDevice(body.DeviceID, safeName, body.DeviceType, logger) + nd.SetDiscoveryAddr(ip, tcpPort) + nd.SetLastSeen(time.Now()) + devices.Add(nd) + } else { + dev.SetName(safeName) + dev.SetDiscoveryAddr(ip, tcpPort) + dev.SetLastSeen(time.Now()) + } + logger.Debug("discovered unpaired device, not dialling (not in pairing mode)", + zap.String("device_id", body.DeviceID), + zap.String("ip", ip.String())) + return + } + } + // Spawn goroutine to prevent blocking the discovery listener go func(targetIP net.IP, targetPort int, targetID string, targetProto int) { DialDevice(ctx, targetIP, targetPort, targetID, targetProto, identity, cfg, devices, plugins, localDeviceID, logger) diff --git a/internal/device/device.go b/internal/device/device.go index 9afdac7..4d1a8bf 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -29,6 +29,19 @@ type Device struct { lastSeen time.Time lastIP net.IP // cached from last successful connection; survives Disconnect + // discoveryIP/discoveryPort remember where a device was last seen + // announcing itself (UDP/mDNS), even if we never opened a TCP + // connection to it. Used to dial on explicit user request + // (e.g. `kcd pair `) without auto-dialling strangers. + discoveryIP net.IP + discoveryPort int + + // pairDialRequested is set when the user explicitly asked to pair with + // this device while it has no active connection. The next discovery + // announcement for it triggers a one-shot outbound dial so the pair + // request can be delivered. + pairDialRequested atomic.Bool + conn *transport.Conn sendChan chan *protocol.Packet // buffered 32 done chan struct{} @@ -64,11 +77,13 @@ type Device struct { } // NewDevice creates a new disconnected device instance. +// New devices start as Unpaired (not Unknown) so listings are unambiguous. func NewDevice(id, name, dtype string, logger *zap.Logger) *Device { return &Device{ id: id, name: name, Type: dtype, + state: StateUnpaired, sendChan: make(chan *protocol.Packet, 32), done: make(chan struct{}), logger: logger.With(zap.String("device_id", id)), @@ -319,6 +334,33 @@ func (d *Device) SetLastSeen(t time.Time) { d.lastSeen = t } +// SetDiscoveryAddr records where the device was last seen announcing itself. +func (d *Device) SetDiscoveryAddr(ip net.IP, port int) { + d.mu.Lock() + defer d.mu.Unlock() + d.discoveryIP = ip + d.discoveryPort = port +} + +// DiscoveryAddr returns the last-seen announcement address, or nil if unknown. +func (d *Device) DiscoveryAddr() (net.IP, int) { + d.mu.RLock() + defer d.mu.RUnlock() + return d.discoveryIP, d.discoveryPort +} + +// RequestPairDial marks the device for a one-shot outbound dial on its next +// discovery announcement. Used when the user explicitly runs `kcd pair ` +// for a device with no active connection. +func (d *Device) RequestPairDial() { + d.pairDialRequested.Store(true) +} + +// ConsumePairDial reports and clears a pending explicit pair-dial request. +func (d *Device) ConsumePairDial() bool { + return d.pairDialRequested.CompareAndSwap(true, false) +} + // TryReconnect attempts to mark the device as reconnecting. // Returns true if this goroutine should proceed; false if another // reconnect goroutine is already running. diff --git a/internal/ipc/handler.go b/internal/ipc/handler.go index fdded29..e4e0164 100644 --- a/internal/ipc/handler.go +++ b/internal/ipc/handler.go @@ -20,6 +20,11 @@ type Handler struct { bus *events.Bus routes map[string]func(Request) Response pruneThreshold time.Duration + // pairDialHook, when set, dials a disconnected device on explicit user + // pair request (`kcd pair `). The daemon wires this to DialDevice + // using the device's last-seen discovery address, so pairing an unpaired + // device doesn't depend on background auto-dial. + pairDialHook func(deviceID string) error } // NewHandler creates a new IPC command handler. @@ -40,6 +45,12 @@ func (h *Handler) Register(command string, fn func(Request) Response) { h.routes[command] = fn } +// SetPairDialHook sets the hook used to connect to a disconnected device on +// explicit user pair request. See the pairDialHook field for details. +func (h *Handler) SetPairDialHook(fn func(deviceID string) error) { + h.pairDialHook = fn +} + // HandleRequest processes an incoming IPC request and returns a response. func (h *Handler) HandleRequest(req Request) Response { if fn, ok := h.routes[req.Command]; ok { @@ -113,6 +124,18 @@ func (h *Handler) handlePair(payload []byte) Response { return Response{OK: false, Error: "device not found"} } + // Pairing needs an active connection (pair packets go over TLS). + // Background auto-dial no longer connects to unpaired strangers, so an + // explicit `kcd pair ` triggers the on-demand dial hook when one is + // wired (the daemon always sets it). Without a hook, fall through to the + // legacy paths below. + if !dev.IsConnected() && h.pairDialHook != nil { + if err := h.pairDialHook(dev.ID()); err != nil { + return Response{OK: false, Error: err.Error()} + } + return Response{OK: true} + } + // Use the pair plugin to handle pairing properly if h.pairPlugin != nil { state := dev.State() From 3163c77bbf4e763491814eced05c5f48c30d878c Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:53:00 +0300 Subject: [PATCH 02/25] fix(pair): confirm before accepting listen-mode requests, decode escaped names pair_listen now only reports the candidate instead of auto-accepting, so stale requests can no longer pair silently. 'kcd pair' asks Accept [y/N] (default reject); -y/--yes keeps headless usage working. Some senders transmit non-ASCII names as decimal byte escapes; decode them to real UTF-8 on receipt, truncate runewise, and migrate stored names on daemon load. --- cmd/kcd/cli_devices.go | 53 +++++++++++++++++++---- docs/CLI.md | 16 +++++-- docs/CLIENT_GUIDE.md | 4 ++ docs/IPC_PROTOCOL.md | 4 ++ internal/daemon/daemon.go | 5 ++- internal/ipc/handler.go | 18 +++++--- internal/protocol/identity.go | 68 +++++++++++++++++++++++++++++- internal/protocol/identity_test.go | 53 +++++++++++++++++++++++ 8 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 internal/protocol/identity_test.go diff --git a/cmd/kcd/cli_devices.go b/cmd/kcd/cli_devices.go index 20c6414..9cf1045 100644 --- a/cmd/kcd/cli_devices.go +++ b/cmd/kcd/cli_devices.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "strings" "syscall" "github.com/bethropolis/kcd/internal/device" @@ -67,12 +68,18 @@ var devicesCmd = &cli.Command{ var pairCmd = &cli.Command{ Name: "pair", - Usage: "Initiate pairing with a remote device", - Description: `With a device ID: send a pair request to that device. + Usage: "Initiate pairing or accept incoming requests", + Description: `With a device ID: send a pair request to that device (or accept if they already requested). -Without a device ID: enter listen mode. Waits for any incoming pair -request and auto-accepts it. Press Ctrl+C to cancel.`, +Without a device ID: enter listen mode to receive and verify incoming pairing requests.`, ArgsUsage: "[device-id]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically accept incoming requests without confirmation (headless mode)", + }, + }, Action: func(c *cli.Context) error { cl, err := getClient(c) if err != nil { @@ -80,10 +87,11 @@ request and auto-accepts it. Press Ctrl+C to cancel.`, } if c.NArg() >= 1 { - if err := cl.Pair(c.Args().First()); err != nil { + targetID := c.Args().First() + if err := cl.Pair(targetID); err != nil { return err } - fmt.Println("Pairing request sent") + fmt.Printf("Pair request sent / accepted for %s\n", targetID) return nil } @@ -120,10 +128,39 @@ request and auto-accepts it. Press Ctrl+C to cancel.`, if r.err != nil { return r.err } - fmt.Printf("Paired with %s (%s)\n", r.result.DeviceName, r.result.DeviceID) + + fmt.Printf("\nIncoming pair request from:\n") + fmt.Printf(" Device: %s (%s)\n", r.result.DeviceName, r.result.DeviceID) if r.result.VerificationKey != "" { - fmt.Printf("Verification code: %s\n", r.result.VerificationKey) + fmt.Printf(" Verification code: %s\n", r.result.VerificationKey) } + + // Headless / auto-accept flag + if c.Bool("yes") { + if err := cl.Pair(r.result.DeviceID); err != nil { + return fmt.Errorf("failed to accept pairing: %w", err) + } + fmt.Printf("Paired with %s (%s)\n", r.result.DeviceName, r.result.DeviceID) + return nil + } + + // Interactive prompt (default: reject) + fmt.Print("\nAccept pairing? [y/N]: ") + var response string + fmt.Scanln(&response) + + response = strings.TrimSpace(strings.ToLower(response)) + if response == "y" || response == "yes" { + if err := cl.Pair(r.result.DeviceID); err != nil { + return fmt.Errorf("failed to accept pairing: %w", err) + } + fmt.Printf("Paired with %s (%s)\n", r.result.DeviceName, r.result.DeviceID) + return nil + } + + // User rejected: reject and cancel request + _ = cl.Unpair(r.result.DeviceID) + fmt.Printf("Rejected pairing with %s\n", r.result.DeviceName) return nil } }, diff --git a/docs/CLI.md b/docs/CLI.md index 9ab812a..326190e 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -186,12 +186,22 @@ If the device has already sent a pair request to `kcd` (state `PairRequestedByPe kcd pair ``` -No arguments = listen mode. Broadcast is started automatically so the phone can discover the PC. The CLI waits for any incoming pair request and accepts it immediately, printing the verification code: +No arguments = listen mode. Broadcast is started automatically so the phone can discover the PC. The CLI shows the incoming request with its verification code and asks for confirmation: ``` Listening for pair requests… (Ctrl+C to cancel) -Paired with Pixel 8 Pro (a1b2c3d4...) -Verification code: 3a8f + +Incoming pair request from: + Device: Pixel 8 Pro (a1b2c3d4...) + Verification code: 3a8f12bc + +Accept pairing? [y/N]: +``` + +Answer `y` to pair, anything else (default) to reject. For headless systems and scripts, pass `-y` / `--yes` to accept without prompting: + +```bash +kcd pair --yes ``` Broadcast stops when pairing completes or you press Ctrl+C. diff --git a/docs/CLIENT_GUIDE.md b/docs/CLIENT_GUIDE.md index 53fc3f0..831638f 100644 --- a/docs/CLIENT_GUIDE.md +++ b/docs/CLIENT_GUIDE.md @@ -150,6 +150,10 @@ if resp["ok"]: print("Paired successfully!") ``` +> `pair_listen` never accepts on its own — it only returns the candidate. +> Your client must ask the user and then call `pair` (accept) or `unpair` +> (reject), mirroring `kcd pair` / `kcd pair --yes`. + ### 3.3 Unpairing ```python diff --git a/docs/IPC_PROTOCOL.md b/docs/IPC_PROTOCOL.md index 35e11de..cce9caa 100644 --- a/docs/IPC_PROTOCOL.md +++ b/docs/IPC_PROTOCOL.md @@ -147,6 +147,10 @@ The handler blocks until a pair request arrives or the context is cancelled. The 16-character verification key should be displayed to the user to verify identity match on both sides. +> The daemon only **reports** the candidate — it does not accept it. +> Accept explicitly with `pair`, reject with `unpair`. This keeps stale +> requests (e.g. leftovers from tests) from pairing silently. + #### `unpair` Remove a paired device. diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 10a1111..1ec9899 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -77,12 +77,15 @@ func Run(ctx context.Context, cfg *config.Config) error { statePath := config.StatePath() if loaded, err := device.LoadDevices(statePath); err == nil { for _, info := range loaded { - dev := device.NewDevice(info.ID, info.Name, info.Type, logger) // Migrate pre-fix state: devices persisted as UNKNOWN (the old // zero value) are simply unpaired. if info.State == device.StateUnknown { info.State = device.StateUnpaired } + // Migrate pre-fix names: some senders transmit decimal byte + // escapes (e.g. "Caf\\195\\169"); decode to real UTF-8. + info.Name = protocol.SanitizeDeviceName(protocol.DecodeDeviceName(info.Name)) + dev := device.NewDevice(info.ID, info.Name, info.Type, logger) dev.SetState(info.State) dev.CertFP = info.CertFP dev.SetLastSeen(info.LastSeen) diff --git a/internal/ipc/handler.go b/internal/ipc/handler.go index e4e0164..c81cba1 100644 --- a/internal/ipc/handler.go +++ b/internal/ipc/handler.go @@ -196,10 +196,14 @@ func (h *Handler) handleUnpair(payload []byte) Response { } func (h *Handler) handlePairListen() Response { - // Check for any device already in StatePairRequestedByPeer + // Report any device already in StatePairRequestedByPeer WITHOUT + // accepting it. The caller (CLI / GUI / script) inspects the candidate + // and decides: accept via CmdPair, reject via CmdUnpair. Auto-accepting + // here would pair with stale requests (e.g. leftovers from tests) + // before the user ever sees a prompt. for _, dev := range h.devices.List() { if dev.State() == device.StatePairRequestedByPeer { - return h.pairAcceptResult(dev, "") + return h.pairListenResult(dev, "") } } @@ -225,16 +229,16 @@ func (h *Handler) handlePairListen() Response { vKey = k } } - return h.pairAcceptResult(dev, vKey) + return h.pairListenResult(dev, vKey) case <-time.After(60 * time.Second): return Response{OK: false, Error: "timed out waiting for pair request (60s)"} } } -func (h *Handler) pairAcceptResult(dev *device.Device, vKey string) Response { - if err := h.pairPlugin.AcceptPairing(dev); err != nil { - return Response{OK: false, Error: "failed to accept pairing: " + err.Error()} - } +// pairListenResult returns the candidate device info without accepting it. +// The caller (CLI / GUI / script) decides whether to accept via CmdPair or +// reject via CmdUnpair. +func (h *Handler) pairListenResult(dev *device.Device, vKey string) Response { data, _ := json.Marshal(PairListenResult{ DeviceID: dev.ID(), DeviceName: dev.Name(), diff --git a/internal/protocol/identity.go b/internal/protocol/identity.go index e8b21ec..e037746 100644 --- a/internal/protocol/identity.go +++ b/internal/protocol/identity.go @@ -2,6 +2,8 @@ package protocol import ( "regexp" + "strings" + "unicode/utf8" ) // ProtocolVersion is the KDE Connect protocol version we advertise. @@ -17,14 +19,76 @@ const MaxDeviceNameLength = 32 // SanitizeDeviceName strips potential terminal injection and XSS characters // and truncates the name to a maximum length per the KDE Connect specification. +// +// Some senders (notably phones with non-ASCII names) transmit the name with +// decimal byte escapes already applied (e.g. "Caf\195\169" instead of +// "Café"). Decode those first so listings show real UTF-8. +// Truncation is rune-aware so multi-byte characters are never split. func SanitizeDeviceName(name string) string { - clean := invalidNameChars.ReplaceAllString(name, "") + clean := DecodeDeviceName(name) + clean = invalidNameChars.ReplaceAllString(clean, "") if len(clean) > MaxDeviceNameLength { - clean = clean[:MaxDeviceNameLength] + clean = truncateRunes(clean, MaxDeviceNameLength) } return clean } +// decimalEscape matches a backslash followed by exactly three decimal +// digits, e.g. "\195". Some senders emit non-ASCII name bytes this way +// ("Caf\195\169" for "Café") instead of raw UTF-8. +var decimalEscape = regexp.MustCompile(`\\([0-9]{3})`) + +// DecodeDeviceName converts decimal byte escapes (\DDD) back to the bytes +// they represent, yielding real UTF-8. Strings without backslashes — the +// common case — are returned untouched, as are strings with no valid +// escape runs (e.g. a literal "back\slash"). If decoding leaves a broken +// tail (the old byte-based truncation could chop an escape in half), the +// invalid bytes are dropped rather than keeping mojibake. +func DecodeDeviceName(name string) string { + if name == "" || !containsBackslash(name) { + return name + } + decoded := decimalEscape.ReplaceAllStringFunc(name, func(m string) string { + d := m[1:] + v := int(d[0]-'0')*100 + int(d[1]-'0')*10 + int(d[2]-'0') + if v > 255 { + return m + } + return string([]byte{byte(v)}) + }) + if decoded == name { + return name + } + if !utf8.ValidString(decoded) { + decoded = strings.ToValidUTF8(decoded, "") + } + return decoded +} + +func containsBackslash(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == '\\' { + return true + } + } + return false +} + +// truncateRunes cuts s to at most maxBytes without splitting a UTF-8 rune. +func truncateRunes(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + cut := 0 + for i := range s { + if i > maxBytes { + break + } + cut = i + } + return s[:cut] +} + // IdentityBody contains the fields of an identity packet body. type IdentityBody struct { DeviceID string `json:"deviceId"` diff --git a/internal/protocol/identity_test.go b/internal/protocol/identity_test.go new file mode 100644 index 0000000..c76eedc --- /dev/null +++ b/internal/protocol/identity_test.go @@ -0,0 +1,53 @@ +package protocol + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestDecodeDeviceName(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"plain ascii", "Pixel 8 Pro", "Pixel 8 Pro"}, + {"real utf8 untouched", "Café", "Café"}, + {"decimal escapes", `Caf\195\169`, "Café"}, + {"leading decimal escape", `\195\169clair`, "éclair"}, + {"decimal escape mid-string", `T\195\169st`, "Tést"}, + {"truncated tail escape dropped", `Phone-\195`, "Phone-"}, + {"out of range kept", `val\999end`, `val\999end`}, + {"invalid escape kept", `back\slash`, `back\slash`}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := DecodeDeviceName(tc.input); got != tc.want { + t.Errorf("DecodeDeviceName(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestSanitizeDeviceName(t *testing.T) { + if got := SanitizeDeviceName(`Caf\195\169`); got != "Café" { + t.Errorf("escaped name not decoded: got %q", got) + } + if got := SanitizeDeviceName("Phone (Work); rm -rf"); got != "Phone Work rm -rf" { + t.Errorf("injection chars not stripped: got %q", got) + } + // Multibyte truncation must never split a rune nor exceed the cap. + long := strings.Repeat("Ö", 40) // 80 bytes + got := SanitizeDeviceName(long) + if len(got) > MaxDeviceNameLength { + t.Errorf("truncated name exceeds cap: %d bytes", len(got)) + } + if !utf8.ValidString(got) { + t.Errorf("truncated name is invalid UTF-8: %q", got) + } + if n := len([]rune(got)); n != MaxDeviceNameLength/2 { + t.Errorf("expected 16 Ö runes (32 bytes), got %d runes", n) + } +} From 11878e0074fe6f5283c9ef08c5bb8ff8d12ff51f Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:03:41 +0300 Subject: [PATCH 03/25] fix(discovery): ephemeral dials replace the auto-dial gate An unpaired stranger gets one ephemeral TCP dial per unpaired era so the identity exchange makes both sides visible, closed again on the next sighting while still unpaired. No timers or polling: a marker suppresses re-dials, pairing mode and explicit pair intent bypass, unpair/reject re-arms, and broadcast stop drops leftover unpaired connections. kcd pair listen also retries past the daemon 60s timeout until Ctrl+C. --- docs/ARCHITECTURE.md | 13 ++++ internal/daemon/ipc_routes.go | 11 ++++ internal/daemon/transport.go | 103 +++++++++++++++++++----------- internal/daemon/transport_test.go | 72 +++++++++++++++++++++ internal/device/device.go | 38 +++++++++++ internal/plugins/pair/pair.go | 6 ++ 6 files changed, 206 insertions(+), 37 deletions(-) create mode 100644 internal/daemon/transport_test.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2bc7de0..cdd2b75 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -58,6 +58,19 @@ Devices are found via two parallel mechanisms that run concurrently: The broadcast interval is adaptive: when `shouldReduce()` returns true (all known devices already connected) the interval steps up to 60 seconds. Broadcast is controlled by a `BroadcasterController` which is off by default — it only starts during `kcd pair` (listen mode) and stops when pairing completes. The UDP **listener** is always active, so paired devices reconnect without any broadcast. +### Ephemeral discovery dials + +Hearing a device is not enough for the phone to list the PC — the TCP +identity exchange is what makes both sides visible. So an unpaired +stranger gets exactly **one ephemeral dial per unpaired era**: the socket +is closed again on the next sighting while it is still unpaired, and the +marker (`Device.ephemeralDialed`) suppresses further re-dials, so there is +no connect/disconnect churn and no timers involved. Bypassed (dial and +stay) by: paired state, pairing mode, and explicit `kcd pair ` intent. +Explicit unpair/reject clears the marker, making the device eligible again. +When broadcast stops (`kcd pair` exits), still-connected unpaired devices +with no pair in flight are disconnected immediately. + ### mDNS / Zeroconf (`_kdeconnect._udp`) At startup the `Broadcaster` registers the local device as a Zeroconf service with the `libp2p/zeroconf/v2` library. TXT records carry `id`, `name`, `type`, and `protocol` fields per the KDE Connect spec. diff --git a/internal/daemon/ipc_routes.go b/internal/daemon/ipc_routes.go index 2757671..65cbf80 100644 --- a/internal/daemon/ipc_routes.go +++ b/internal/daemon/ipc_routes.go @@ -78,6 +78,17 @@ func registerIPCRoutes(handler *ipc.Handler, cfg *config.Config, devices *device }) handler.Register(ipc.CmdBroadcastStop, func(req ipc.Request) ipc.Response { bc.Stop() + // Pairing window closed: drop discovery connections that never led + // to pairing so strangers don't linger. Paired devices, pair + // requests in flight, and explicit pair intents are left alone. + for _, dev := range devices.List() { + if !dev.IsConnected() || dev.State() != device.StateUnpaired || dev.PairDialPending() { + continue + } + logger.Debug("broadcast stopped, dropping unpaired discovery connection", + zap.String("device_id", dev.ID())) + dev.Disconnect() + } return ipc.Response{OK: true} }) diff --git a/internal/daemon/transport.go b/internal/daemon/transport.go index baa3c6f..554c2a6 100644 --- a/internal/daemon/transport.go +++ b/internal/daemon/transport.go @@ -74,6 +74,27 @@ func DialDevice(ctx context.Context, targetIP net.IP, targetPort int, targetID s } } +// shouldEphemeralClose reports whether an active connection that exists +// only for the discovery handshake should be closed on a fresh sighting. +// Connections are kept when pairing mode is active, when the device is +// paired, when a pair request is in flight either way, or when the user +// explicitly asked to pair with the device. +func shouldEphemeralClose(dev *device.Device, pairingMode bool) bool { + if pairingMode { + return false + } + if !dev.EphemeralDialed() { + return false + } + if dev.State() != device.StateUnpaired { + return false + } + if dev.PairDialPending() { + return false + } + return true +} + func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.BroadcasterController, identity *protocol.Packet, devices *device.Registry, plugins *plugin.Registry, localDeviceID string, logger *zap.Logger) { // TCP Listener tcpListener, err := transport.Listen(ctx, ":1716") @@ -87,6 +108,14 @@ func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.Broadcaste // The controller is started in stopped state. // UDP/mDNS Listener (onDeviceFound) + // + // Discovery is event-driven with no timers or polling: + // - Paired devices, pairing mode (`kcd pair` listen), and explicit + // `kcd pair ` intent dial and keep the connection. + // - An unpaired stranger gets exactly one ephemeral dial per unpaired + // era so both sides can list each other (the TCP identity exchange + // is what makes the PC appear on the phone). The next sighting + // closes the socket again while it is still unpaired. onDeviceFound := func(ip net.IP, tcpPort int, peerIdentity *protocol.Packet) { var body protocol.IdentityBody if err := json.Unmarshal(peerIdentity.Body, &body); err != nil { @@ -97,48 +126,48 @@ func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.Broadcaste return } - if dev, ok := devices.Get(body.DeviceID); ok && dev.IsConnected() { + pairingMode := bc != nil && bc.IsRunning() + dev, known := devices.Get(body.DeviceID) + + if known && dev.IsConnected() { + // Fresh sighting of a connected device: close it again if it + // exists only for the discovery handshake. + if shouldEphemeralClose(dev, pairingMode) { + logger.Debug("closing ephemeral discovery connection", + zap.String("device_id", body.DeviceID)) + dev.Disconnect() + } return } - // Don't auto-dial strangers heard on the network. Only open an - // outbound TCP connection when the device is already paired, when - // the daemon is actively in pairing mode (`kcd pair` listen mode - // starts the broadcaster), or when the user explicitly requested - // pairing with this device (`kcd pair ` sets a one-shot flag). - // Otherwise just record/update the registry entry so `kcd devices` - // still shows the discovered device as UNPAIRED. - dev, known := devices.Get(body.DeviceID) - isPaired := known && dev.State() == device.StatePaired - pairingMode := bc != nil && bc.IsRunning() - if !isPaired && !pairingMode { - if known && dev.ConsumePairDial() { - dev.SetDiscoveryAddr(ip, tcpPort) - dev.SetLastSeen(time.Now()) - // Fall through to dial below: explicit user intent. - } else { - safeName := protocol.SanitizeDeviceName(body.DeviceName) - if !known { - nd := device.NewDevice(body.DeviceID, safeName, body.DeviceType, logger) - nd.SetDiscoveryAddr(ip, tcpPort) - nd.SetLastSeen(time.Now()) - devices.Add(nd) - } else { - dev.SetName(safeName) - dev.SetDiscoveryAddr(ip, tcpPort) - dev.SetLastSeen(time.Now()) - } - logger.Debug("discovered unpaired device, not dialling (not in pairing mode)", - zap.String("device_id", body.DeviceID), - zap.String("ip", ip.String())) - return - } + safeName := protocol.SanitizeDeviceName(body.DeviceName) + if !known { + dev = device.NewDevice(body.DeviceID, safeName, body.DeviceType, logger) + devices.Add(dev) + } else { + dev.SetName(safeName) + } + dev.SetDiscoveryAddr(ip, tcpPort) + dev.SetLastSeen(time.Now()) + + if dev.State() == device.StatePaired || pairingMode || dev.ConsumePairDial() { + // Spawn goroutine to prevent blocking the discovery listener + go func(targetIP net.IP, targetPort int, targetID string, targetProto int) { + DialDevice(ctx, targetIP, targetPort, targetID, targetProto, identity, cfg, devices, plugins, localDeviceID, logger) + }(ip, tcpPort, body.DeviceID, body.ProtocolVersion) + return } - // Spawn goroutine to prevent blocking the discovery listener - go func(targetIP net.IP, targetPort int, targetID string, targetProto int) { - DialDevice(ctx, targetIP, targetPort, targetID, targetProto, identity, cfg, devices, plugins, localDeviceID, logger) - }(ip, tcpPort, body.DeviceID, body.ProtocolVersion) + if !dev.EphemeralDialed() { + dev.MarkEphemeralDialed() + // Spawn goroutine to prevent blocking the discovery listener + go func(targetIP net.IP, targetPort int, targetID string, targetProto int) { + DialDevice(ctx, targetIP, targetPort, targetID, targetProto, identity, cfg, devices, plugins, localDeviceID, logger) + }(ip, tcpPort, body.DeviceID, body.ProtocolVersion) + } + // Otherwise the device already had its ephemeral dial for this + // unpaired era: stay silent instead of redialling on every + // announcement. Pairing mode and explicit `kcd pair ` bypass. } udpListener := discovery.NewListener(1716, localDeviceID, onDeviceFound, logger) diff --git a/internal/daemon/transport_test.go b/internal/daemon/transport_test.go new file mode 100644 index 0000000..cbd0e23 --- /dev/null +++ b/internal/daemon/transport_test.go @@ -0,0 +1,72 @@ +package daemon + +import ( + "testing" + + "github.com/bethropolis/kcd/internal/device" + "go.uber.org/zap" +) + +func ephemeralDevice(state device.PairingState, markEphemeral, pairIntent bool) *device.Device { + dev := device.NewDevice("test-id", "Test", "phone", zap.NewNop()) + dev.SetState(state) + if markEphemeral { + dev.MarkEphemeralDialed() + } + if pairIntent { + dev.RequestPairDial() + } + return dev +} + +func TestShouldEphemeralClose(t *testing.T) { + cases := []struct { + name string + dev *device.Device + pairingMode bool + want bool + }{ + {"pairing mode keeps", ephemeralDevice(device.StateUnpaired, true, false), true, false}, + {"never dialled keeps", ephemeralDevice(device.StateUnpaired, false, false), false, false}, + {"paired keeps", ephemeralDevice(device.StatePaired, true, false), false, false}, + {"pair requested keeps", ephemeralDevice(device.StatePairRequested, true, false), false, false}, + {"pair requested by peer keeps", ephemeralDevice(device.StatePairRequestedByPeer, true, false), false, false}, + {"explicit intent keeps", ephemeralDevice(device.StateUnpaired, true, true), false, false}, + {"idle stranger closes", ephemeralDevice(device.StateUnpaired, true, false), false, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shouldEphemeralClose(tc.dev, tc.pairingMode); got != tc.want { + t.Errorf("shouldEphemeralClose() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestEphemeralMarkerReset(t *testing.T) { + dev := device.NewDevice("test-id", "Test", "phone", zap.NewNop()) + if dev.EphemeralDialed() { + t.Fatal("new device must not be ephemeral-marked") + } + dev.MarkEphemeralDialed() + if !dev.EphemeralDialed() { + t.Fatal("marker not set") + } + dev.ClearEphemeral() + if dev.EphemeralDialed() { + t.Fatal("marker not cleared") + } + if dev.PairDialPending() { + t.Fatal("new device must have no pair intent") + } + dev.RequestPairDial() + if !dev.PairDialPending() { + t.Fatal("pair intent not visible") + } + if !dev.ConsumePairDial() { + t.Fatal("pair intent not consumed") + } + if dev.PairDialPending() { + t.Fatal("pair intent not cleared after consume") + } +} diff --git a/internal/device/device.go b/internal/device/device.go index 4d1a8bf..f492f33 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -42,6 +42,15 @@ type Device struct { // request can be delivered. pairDialRequested atomic.Bool + // ephemeralDialed marks that this device already received its one + // ephemeral discovery dial for the current unpaired era. Ephemeral + // dials let a stranger complete the TCP identity exchange (so both + // sides list each other) without staying connected: the next sighting + // closes the socket again while the device is still unpaired. The + // marker is cleared when the device is explicitly unpaired/rejected, + // making it eligible again. Paired devices and pairing mode bypass it. + ephemeralDialed bool + conn *transport.Conn sendChan chan *protocol.Packet // buffered 32 done chan struct{} @@ -361,6 +370,35 @@ func (d *Device) ConsumePairDial() bool { return d.pairDialRequested.CompareAndSwap(true, false) } +// PairDialPending reports whether an explicit pair-dial was requested, +// without clearing it. +func (d *Device) PairDialPending() bool { + return d.pairDialRequested.Load() +} + +// MarkEphemeralDialed records that the one ephemeral discovery dial for the +// current unpaired era has been used. +func (d *Device) MarkEphemeralDialed() { + d.mu.Lock() + defer d.mu.Unlock() + d.ephemeralDialed = true +} + +// EphemeralDialed reports whether the ephemeral discovery dial was used. +func (d *Device) EphemeralDialed() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.ephemeralDialed +} + +// ClearEphemeral makes the device eligible for a fresh ephemeral discovery +// dial (e.g. after an explicit unpair or rejection). +func (d *Device) ClearEphemeral() { + d.mu.Lock() + defer d.mu.Unlock() + d.ephemeralDialed = false +} + // TryReconnect attempts to mark the device as reconnecting. // Returns true if this goroutine should proceed; false if another // reconnect goroutine is already running. diff --git a/internal/plugins/pair/pair.go b/internal/plugins/pair/pair.go index 633d55d..9418edf 100644 --- a/internal/plugins/pair/pair.go +++ b/internal/plugins/pair/pair.go @@ -173,18 +173,21 @@ func (p *PairPlugin) handleUnpairRequest(_ context.Context, dev *device.Device) // We requested, they rejected p.logger.Info("pair request rejected by peer", zap.String("device_id", dev.ID())) dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() p.emit(events.TypePairRejected, dev, "") case device.StatePairRequestedByPeer: // They requested, then cancelled p.logger.Info("pair request cancelled by peer", zap.String("device_id", dev.ID())) dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() p.emit(events.TypePairRejected, dev, "") case device.StatePaired: // Unpair request p.logger.Info("unpair request received", zap.String("device_id", dev.ID())) dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() case device.StateUnpaired, device.StateUnknown: // Already unpaired, ignore @@ -213,6 +216,7 @@ func (p *PairPlugin) AcceptPairing(dev *device.Device) error { if err := dev.Send(pkt); err != nil { p.logger.Error("failed to send pair accept", zap.Error(err)) dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() return err } @@ -277,6 +281,7 @@ func (p *PairPlugin) RejectPairing(dev *device.Device) error { dev.Send(pkt) // best effort dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() p.mu.Lock() delete(p.pairingTimestamp, dev.ID()) @@ -299,6 +304,7 @@ func (p *PairPlugin) Unpair(dev *device.Device) error { dev.Send(pkt) // best effort dev.SetState(device.StateUnpaired) + dev.ClearEphemeral() p.mu.Lock() delete(p.pairingTimestamp, dev.ID()) From 3ff9a40f76a26d8c5061164585d94badaff5363a Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:03:41 +0300 Subject: [PATCH 04/25] fix(cli): devices --connected lists paired devices even when offline Liveness stays on the CONNECTED column; the flag now hides only unpaired strangers instead of offline paired phones. --- cmd/kcd/cli_devices.go | 98 +++++++++++++++++++++++------------------- docs/CLI.md | 17 +++++--- 2 files changed, 66 insertions(+), 49 deletions(-) diff --git a/cmd/kcd/cli_devices.go b/cmd/kcd/cli_devices.go index 9cf1045..887b011 100644 --- a/cmd/kcd/cli_devices.go +++ b/cmd/kcd/cli_devices.go @@ -29,7 +29,7 @@ var devicesCmd = &cli.Command{ }, &cli.BoolFlag{ Name: "connected", - Usage: "Only show connected AND paired (usable) devices", + Usage: "Only show paired devices (including offline ones)", }, }, Action: func(c *cli.Context) error { @@ -47,9 +47,10 @@ var devicesCmd = &cli.Command{ if c.Bool("connected") { filtered := make([]device.DeviceInfo, 0, len(devices)) for _, d := range devices { - // Usable means both paired and connected: strangers may - // hold a raw TCP connection but can't do anything. - if d.Connected && d.State == device.StatePaired { + // Paired devices show even when offline — the CONNECTED + // column carries liveness. Unpaired strangers (which may + // briefly hold a raw TCP connection) are hidden. + if d.State == device.StatePaired { filtered = append(filtered, d) } } @@ -114,54 +115,63 @@ Without a device ID: enter listen mode to receive and verify incoming pairing re result *ipc.PairListenResult err error } - resultCh := make(chan listenResult, 1) - go func() { - r, err := cl.PairListen() - resultCh <- listenResult{r, err} - }() - - select { - case <-sigCh: - fmt.Println("\nCancelled") - return nil - case r := <-resultCh: - if r.err != nil { - return r.err - } - fmt.Printf("\nIncoming pair request from:\n") - fmt.Printf(" Device: %s (%s)\n", r.result.DeviceName, r.result.DeviceID) - if r.result.VerificationKey != "" { - fmt.Printf(" Verification code: %s\n", r.result.VerificationKey) - } + // Keep waiting past the daemon's 60s pair_listen timeout so a slow + // phone-side accept doesn't force a restart. Ctrl+C cancels. + for { + resultCh := make(chan listenResult, 1) + go func() { + r, err := cl.PairListen() + resultCh <- listenResult{r, err} + }() + + select { + case <-sigCh: + fmt.Println("\nCancelled") + return nil + case r := <-resultCh: + if r.err != nil { + if strings.Contains(r.err.Error(), "timed out") { + fmt.Println("No pair requests yet, still listening… (Ctrl+C to cancel)") + continue + } + return r.err + } - // Headless / auto-accept flag - if c.Bool("yes") { - if err := cl.Pair(r.result.DeviceID); err != nil { - return fmt.Errorf("failed to accept pairing: %w", err) + fmt.Printf("\nIncoming pair request from:\n") + fmt.Printf(" Device: %s (%s)\n", r.result.DeviceName, r.result.DeviceID) + if r.result.VerificationKey != "" { + fmt.Printf(" Verification code: %s\n", r.result.VerificationKey) } - fmt.Printf("Paired with %s (%s)\n", r.result.DeviceName, r.result.DeviceID) - return nil - } - // Interactive prompt (default: reject) - fmt.Print("\nAccept pairing? [y/N]: ") - var response string - fmt.Scanln(&response) + // Headless / auto-accept flag + if c.Bool("yes") { + if err := cl.Pair(r.result.DeviceID); err != nil { + return fmt.Errorf("failed to accept pairing: %w", err) + } + fmt.Printf("Paired with %s (%s)\n", r.result.DeviceName, r.result.DeviceID) + return nil + } - response = strings.TrimSpace(strings.ToLower(response)) - if response == "y" || response == "yes" { - if err := cl.Pair(r.result.DeviceID); err != nil { - return fmt.Errorf("failed to accept pairing: %w", err) + // Interactive prompt (default: reject) + fmt.Print("\nAccept pairing? [y/N]: ") + var response string + fmt.Scanln(&response) + + response = strings.TrimSpace(strings.ToLower(response)) + if response == "y" || response == "yes" { + if err := cl.Pair(r.result.DeviceID); err != nil { + return fmt.Errorf("failed to accept pairing: %w", err) + } + fmt.Printf("Paired with %s (%s)\n", r.result.DeviceName, r.result.DeviceID) + return nil } - fmt.Printf("Paired with %s (%s)\n", r.result.DeviceName, r.result.DeviceID) + + // User rejected: reject and cancel request + _ = cl.Unpair(r.result.DeviceID) + fmt.Printf("Rejected pairing with %s\n", r.result.DeviceName) return nil } - - // User rejected: reject and cancel request - _ = cl.Unpair(r.result.DeviceID) - fmt.Printf("Rejected pairing with %s\n", r.result.DeviceName) - return nil } }, } diff --git a/docs/CLI.md b/docs/CLI.md index 326190e..f71d60e 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -29,9 +29,10 @@ kcd daemon [--config ] [--log-level ] The daemon: - Listens for device announcements via UDP and mDNS (always on). - Unpaired strangers are recorded as `UNPAIRED` but never auto-dialled — - outbound connections open only to paired devices, in `kcd pair` listen - mode, or on explicit `kcd pair ` / `kcd connect` request. + Unpaired strangers get one ephemeral TCP handshake so both sides list + each other, closed again on the next sighting — no lingering, no + re-dial churn. Persistent connections are for paired devices, active + `kcd pair` listen mode, or explicit `kcd pair ` / `kcd connect`. - Broadcasts its own identity only during `kcd pair` (listen mode) - Accepts inbound TCP connections on port 1716 - Runs all enabled plugins @@ -111,7 +112,7 @@ kcd devices [--json] |---|---| | `--json` | Output as a JSON array | | `--watch`, `-w` | Stream device changes live (clears screen on each change) | -| `--connected` | Only show connected **and paired** (usable) devices — unpaired strangers holding a raw TCP connection are hidden | +| `--connected` | Only show paired devices, including offline ones (unpaired strangers are hidden; check the `CONNECTED` column for liveness) | **Example output** @@ -911,7 +912,13 @@ done ## Tips -**Get the first usable (paired + connected) device ID** +**Get the first paired device ID (works offline too)** + +```bash +kcd devices --json | jq -r '[.[] | select(.State=="PAIRED")] | .[0].ID' +``` + +**Get the first online (connected) paired device ID** ```bash kcd devices --json | jq -r '[.[] | select(.Connected and .State=="PAIRED")] | .[0].ID' From e29e8e0b6867d43e222e20a7ad5c647570827bd2 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:21:55 +0300 Subject: [PATCH 05/25] feat(connectivity): expose cached network status via watch dump and CLI Add Report() accessor on the connectivity plugin, emit a cached connectivity.update in the watch initial-state dump, and add a kcd connectivity [--json] [device-id] query path (IPC command, daemon route, client method) rendering per-SIM signal bars. --- cmd/kcd/cli_connectivity.go | 115 ++++++++++++++++++ cmd/kcd/cli_connectivity_test.go | 63 ++++++++++ cmd/kcd/main.go | 1 + docs/ARCHITECTURE.md | 1 + docs/CLI.md | 26 ++++ docs/IPC_PROTOCOL.md | 29 +++++ internal/daemon/ipc_routes.go | 3 + internal/daemon/ipc_routes_connectivity.go | 33 +++++ internal/ipc/proto.go | 1 + internal/ipc/server.go | 20 +++ internal/plugins/connectivity/connectivity.go | 14 +++ .../plugins/connectivity/connectivity_test.go | 38 ++++++ packaging/kcd.fish-completion | 16 ++- pkg/client/client.go | 10 ++ 14 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 cmd/kcd/cli_connectivity.go create mode 100644 cmd/kcd/cli_connectivity_test.go create mode 100644 internal/daemon/ipc_routes_connectivity.go diff --git a/cmd/kcd/cli_connectivity.go b/cmd/kcd/cli_connectivity.go new file mode 100644 index 0000000..290099d --- /dev/null +++ b/cmd/kcd/cli_connectivity.go @@ -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 +} diff --git a/cmd/kcd/cli_connectivity_test.go b/cmd/kcd/cli_connectivity_test.go new file mode 100644 index 0000000..7d62c0b --- /dev/null +++ b/cmd/kcd/cli_connectivity_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "testing" + + "github.com/bethropolis/kcd/internal/plugins/connectivity" +) + +func TestFormatConnectivity(t *testing.T) { + cases := []struct { + name string + body connectivity.ConnectivityBody + want []string + }{ + { + name: "empty", + body: connectivity.ConnectivityBody{}, + want: []string{"No signal data reported"}, + }, + { + name: "single sim detailed type", + body: connectivity.ConnectivityBody{SignalStrengths: map[string]connectivity.SignalStrength{ + "0": {NetworkType: "LTE", NetworkDetailedType: "LTE", SignalStrength: 3}, + }}, + want: []string{"LTE [███░] (3/4)"}, + }, + { + name: "falls back to network type", + body: connectivity.ConnectivityBody{SignalStrengths: map[string]connectivity.SignalStrength{ + "0": {NetworkType: "5G", SignalStrength: 4}, + }}, + want: []string{"5G [████] (4/4)"}, + }, + { + name: "clamps out of range", + body: connectivity.ConnectivityBody{SignalStrengths: map[string]connectivity.SignalStrength{ + "0": {NetworkType: "GSM", SignalStrength: 9}, + }}, + want: []string{"GSM [████] (4/4)"}, + }, + { + name: "dual sim primary first", + body: connectivity.ConnectivityBody{SignalStrengths: map[string]connectivity.SignalStrength{ + "1": {NetworkType: "EDGE", SignalStrength: 2}, + "0": {NetworkType: "LTE", SignalStrength: 4}, + }}, + want: []string{"SIM 0: LTE [████] (4/4)", "SIM 1: EDGE [██░░] (2/4)"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := formatConnectivity(tc.body) + if len(got) != len(tc.want) { + t.Fatalf("got %q, want %q", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("line %d: got %q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +} diff --git a/cmd/kcd/main.go b/cmd/kcd/main.go index 2d4e07b..87f8a8c 100644 --- a/cmd/kcd/main.go +++ b/cmd/kcd/main.go @@ -113,6 +113,7 @@ func main() { unpairCmd, pingCmd, batteryCmd, + connectivityCmd, watchCmd, sftpCmd, replyCmd, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cdd2b75..19026dd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -297,6 +297,7 @@ kcd pair [] — pair: with an ID sends a request; without one, listen mode kcd unpair — revoke trust kcd ping — send a ping kcd battery — fetch battery status +kcd connectivity [id] — show cellular signal/network type kcd share — send a file kcd clipboard [id] — push local clipboard to phone kcd sftp request — request SFTP credentials diff --git a/docs/CLI.md b/docs/CLI.md index f71d60e..e4a66aa 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -250,6 +250,32 @@ Battery: 31% (discharging) --- +## connectivity + +Show the phone's cellular signal strength and network type (5G/LTE/…). + +``` +kcd connectivity [device-id] [--json] +``` + +If `device-id` is omitted, `kcd` automatically targets the first paired and connected device. + +**Example output** + +``` +LTE [███░] (3/4) +``` + +Dual-SIM phones print one line per SIM (`SIM 0: …`), primary first. +`--json` prints the raw report (same shape as `connectivity.update` event +payloads) for scripting. Exits non-zero with `no connectivity data` when +the device is offline or never reported — reports are requested fresh on +every connect. + +> For continuous monitoring, use `kcd watch --events=connectivity.update` instead. + +--- + ## clipboard Push the local clipboard content to a device. diff --git a/docs/IPC_PROTOCOL.md b/docs/IPC_PROTOCOL.md index cce9caa..e689321 100644 --- a/docs/IPC_PROTOCOL.md +++ b/docs/IPC_PROTOCOL.md @@ -240,6 +240,35 @@ device to respond and returns the value). | `charge` | number | Battery percentage (0–100) | | `charging` | bool | Whether the device is currently charging | +#### `connectivity` + +Return the last cellular connectivity report cached for a device (same +shape as `connectivity.update` event payloads). + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** + +```json +{"signalStrengths": {"0": {"networkType": "LTE", "networkDetailedType": "LTE", "signalStrength": 4}}} +``` + +| Field | Type | Description | +|---|---|---| +| `signalStrengths` | object | Map of SIM subscription ID → signal info (dual-SIM aware) | +| `networkType` | string | Network generation (`5G`, `LTE`, `GSM`, …) | +| `networkDetailedType` | string | Finer-grained type when reported (may be absent) | +| `signalStrength` | number | Level 0 (no signal) – 4 (full) | + +Errors: `device not found`, `connectivity plugin not enabled`, +`no connectivity data (device offline or never reported)`. Reports are +requested fresh on every connect; `kcd watch` also emits a cached +`connectivity.update` on subscribe so clients never boot blind. + #### `clipboard_push` Push the local clipboard content to a device. diff --git a/internal/daemon/ipc_routes.go b/internal/daemon/ipc_routes.go index 65cbf80..7205bbb 100644 --- a/internal/daemon/ipc_routes.go +++ b/internal/daemon/ipc_routes.go @@ -29,6 +29,9 @@ func registerIPCRoutes(handler *ipc.Handler, cfg *config.Config, devices *device if cfg.Plugins.Battery { registerBatteryRoutes(handler, devices) } + if cfg.Plugins.Connectivity { + registerConnectivityRoutes(handler, devices, plugins) + } if cfg.Plugins.Clipboard { registerClipboardRoutes(handler, devices, plugins) } diff --git a/internal/daemon/ipc_routes_connectivity.go b/internal/daemon/ipc_routes_connectivity.go new file mode 100644 index 0000000..ccfde31 --- /dev/null +++ b/internal/daemon/ipc_routes_connectivity.go @@ -0,0 +1,33 @@ +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/connectivity" +) + +func registerConnectivityRoutes(handler *ipc.Handler, devices *device.Registry, plugins *plugin.Registry) { + handler.Register(ipc.CmdConnectivity, 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"} + } + dev, ok := devices.Get(p.DeviceID) + if !ok { + return ipc.Response{OK: false, Error: "device not found"} + } + pl, ok := plugins.GetByName("Connectivity") + if !ok { + return ipc.Response{OK: false, Error: "connectivity plugin not enabled"} + } + report, ok := pl.(*connectivity.ConnectivityPlugin).Report(dev.ID()) + if !ok { + return ipc.Response{OK: false, Error: "no connectivity data (device offline or never reported)"} + } + data, _ := json.Marshal(report) + return ipc.Response{OK: true, Data: data} + }) +} diff --git a/internal/ipc/proto.go b/internal/ipc/proto.go index 44e2c1f..1a79705 100644 --- a/internal/ipc/proto.go +++ b/internal/ipc/proto.go @@ -12,6 +12,7 @@ const ( CmdUnpair = "unpair" CmdPing = "ping" CmdBattery = "battery" + CmdConnectivity = "connectivity" CmdClipboardPush = "clipboard_push" CmdRunList = "run_list" CmdRunExec = "run_exec" diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 61ac1e6..a7d6d90 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -11,6 +11,7 @@ import ( "time" "github.com/bethropolis/kcd/internal/events" + "github.com/bethropolis/kcd/internal/plugins/connectivity" "github.com/bethropolis/kcd/internal/plugins/mpris" "go.uber.org/zap" ) @@ -166,6 +167,25 @@ func (s *Server) handleWatch(conn net.Conn, payload []byte) { return } + // Send initial connectivity state if the device already reported. + // Unlike battery there is no meaningful zero value, so devices + // without a report are skipped instead of emitting empty data. + if pl, ok := s.handler.plugins.GetByName("Connectivity"); ok { + if report, ok := pl.(*connectivity.ConnectivityPlugin).Report(dev.ID()); ok { + connEv := map[string]interface{}{ + "type": "connectivity.update", + "deviceId": dev.ID(), + "timestamp": time.Now().UTC(), + "payload": report, + } + data, _ = json.Marshal(connEv) + data = append(data, '\n') + if _, err := conn.Write(data); err != nil { + return + } + } + } + // Send initial mpris state if recently updated. // If the last update was more than 10 seconds ago the state is // likely stale — the phone probably stopped playing — so we skip it diff --git a/internal/plugins/connectivity/connectivity.go b/internal/plugins/connectivity/connectivity.go index d64e0b5..8015a54 100644 --- a/internal/plugins/connectivity/connectivity.go +++ b/internal/plugins/connectivity/connectivity.go @@ -89,6 +89,20 @@ func (p *ConnectivityPlugin) OnConnect(dev device.Sender) { dev.Send(pkt) } +// Report returns the last connectivity report received from a device. +// The second return value is false when the device never reported (or the +// entry was cleared on disconnect). Used for watch initial-state dumps and +// on-demand CLI queries. +func (p *ConnectivityPlugin) Report(deviceID string) (ConnectivityBody, bool) { + p.mu.Lock() + defer p.mu.Unlock() + if p.lastReports == nil { + return ConnectivityBody{}, false + } + body, ok := p.lastReports[deviceID] + return body, ok +} + func (p *ConnectivityPlugin) OnDisconnect(dev device.Sender) { p.mu.Lock() defer p.mu.Unlock() diff --git a/internal/plugins/connectivity/connectivity_test.go b/internal/plugins/connectivity/connectivity_test.go index 24b322d..c7c8776 100644 --- a/internal/plugins/connectivity/connectivity_test.go +++ b/internal/plugins/connectivity/connectivity_test.go @@ -97,3 +97,41 @@ func expectNoEvent(t *testing.T, sub *events.Subscriber) { case <-time.After(50 * time.Millisecond): } } + +func TestReportCachesLastHandle(t *testing.T) { + plugin := NewConnectivityPlugin(nil) + dev := testSender{id: "device-1"} + + if _, ok := plugin.Report("device-1"); ok { + t.Fatal("expected miss before any report") + } + + body := ConnectivityBody{ + SignalStrengths: map[string]SignalStrength{ + "0": {NetworkType: "LTE", NetworkDetailedType: "LTE", SignalStrength: 4}, + }, + } + pkt, err := protocol.NewPacket("kdeconnect.connectivity_report", body) + if err != nil { + t.Fatal(err) + } + if err := plugin.Handle(context.Background(), dev, pkt); err != nil { + t.Fatal(err) + } + + got, ok := plugin.Report("device-1") + if !ok { + t.Fatal("expected hit after Handle") + } + if got.SignalStrengths["0"].SignalStrength != 4 { + t.Errorf("wrong cached level: %+v", got) + } + if _, ok := plugin.Report("unknown"); ok { + t.Error("expected miss for unknown device") + } + + plugin.OnDisconnect(dev) + if _, ok := plugin.Report("device-1"); ok { + t.Error("expected miss after disconnect clears the cache") + } +} diff --git a/packaging/kcd.fish-completion b/packaging/kcd.fish-completion index ff37d5b..9b407ec 100644 --- a/packaging/kcd.fish-completion +++ b/packaging/kcd.fish-completion @@ -34,7 +34,7 @@ function __kcd_at_arg end # All top-level subcommands (for "don't complete subcommands twice" guards) -set -l __kcd_cmds daemon devices connect pair unpair ping battery watch \ +set -l __kcd_cmds daemon devices connect pair unpair ping battery connectivity watch \ sftp reply call findmyphone lock unlock share clipboard run sms mpris doctor status # --------------------------------------------------------------------------- @@ -68,6 +68,8 @@ complete -c kcd -n "not __fish_seen_subcommand_from $__kcd_cmds" \ -a ping -d 'Send a ping notification to a device' complete -c kcd -n "not __fish_seen_subcommand_from $__kcd_cmds" \ -a battery -d 'Fetch battery level and charging status' +complete -c kcd -n "not __fish_seen_subcommand_from $__kcd_cmds" \ + -a connectivity -d 'Show cellular signal strength and network type' complete -c kcd -n "not __fish_seen_subcommand_from $__kcd_cmds" \ -a watch -d 'Monitor real-time events from the daemon' complete -c kcd -n "not __fish_seen_subcommand_from $__kcd_cmds" \ @@ -105,7 +107,7 @@ complete -c kcd -n "__fish_seen_subcommand_from devices" \ complete -c kcd -n "__fish_seen_subcommand_from devices" \ -l watch -s w -d 'Stream device changes in real time' complete -c kcd -n "__fish_seen_subcommand_from devices" \ - -l connected -d 'Only show currently connected devices' + -l connected -d 'Only show paired devices (including offline)' # --------------------------------------------------------------------------- # connect @@ -120,13 +122,19 @@ complete -c kcd -n "__fish_seen_subcommand_from devices" \ complete -c kcd -n "__fish_seen_subcommand_from pair && __kcd_at_arg 2" \ -a '(__kcd_devices)' -d 'Device ID' -# unpair / ping / battery / findmyphone / lock / unlock / clipboard +# unpair / ping / battery / connectivity / findmyphone / lock / unlock / clipboard # — only make sense with connected devices -for _cmd in unpair ping battery findmyphone lock unlock clipboard +for _cmd in unpair ping battery connectivity findmyphone lock unlock clipboard complete -c kcd -n "__fish_seen_subcommand_from $_cmd && __kcd_at_arg 2" \ -a '(__kcd_connected_devices)' -d 'Device ID' end +# --------------------------------------------------------------------------- +# connectivity (just --json flag; device-id handled above) +# --------------------------------------------------------------------------- +complete -c kcd -n "__fish_seen_subcommand_from connectivity" \ + -l json -d 'Output the raw report as JSON' + # --------------------------------------------------------------------------- # clipboard (just --watch flag; device-id handled above) # --------------------------------------------------------------------------- diff --git a/pkg/client/client.go b/pkg/client/client.go index 5444af8..f0b3bd4 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -155,6 +155,16 @@ func (c *Client) ClipboardPush(deviceID string) error { return err } +// Connectivity returns the last raw connectivity report for a device. +// Callers decode it (same shape as connectivity.update event payloads). +func (c *Client) Connectivity(deviceID string) (json.RawMessage, error) { + res, err := c.Call(ipc.CmdConnectivity, ipc.DevicePayload{DeviceID: deviceID}) + if err != nil { + return nil, err + } + return res.Data, nil +} + // RunList requests the remote device to send its command list. func (c *Client) RunList(deviceID string) error { _, err := c.Call(ipc.CmdRunList, ipc.DevicePayload{DeviceID: deviceID}) From 0786bf4b95a4e22e4bb99fb083b3890a4ddffe0f Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:26:16 +0300 Subject: [PATCH 06/25] feat(clients): snapshot bootstrap, anchors, enriched devices, json gaps Add a state.snapshot event on watch connect covering all known devices with cached battery/media/signal so clients boot from one connection. Expose posAnchorMs on NowPlaying for drift-free position math and flag in-flight album art with artPending plus an empty URL instead of an unloadable kdeconnect:/ URI. Enrich devices --json via an IPC-only DeviceSummary (disk shape unchanged) and add --json to battery, volume list, sftp info/volumes with fish completions. this is basically to make it easier for external clients --- README.md | 3 +- cmd/kcd/cli_battery.go | 16 +++++ cmd/kcd/cli_sftp.go | 23 ++++++++ cmd/kcd/cli_volume.go | 10 ++++ docs/CLI.md | 19 ++++-- docs/CLIENT_GUIDE.md | 15 +++-- docs/IPC_PROTOCOL.md | 46 ++++++++++++--- internal/events/bus.go | 1 + internal/ipc/handler.go | 13 ++-- internal/ipc/server.go | 17 +++++- internal/ipc/snapshot.go | 88 ++++++++++++++++++++++++++++ internal/ipc/snapshot_test.go | 65 ++++++++++++++++++++ internal/plugins/mpris/mpris.go | 40 ++++++++++--- internal/plugins/mpris/mpris_test.go | 51 ++++++++++++++++ packaging/kcd.fish-completion | 9 ++- 15 files changed, 379 insertions(+), 37 deletions(-) create mode 100644 internal/ipc/snapshot.go create mode 100644 internal/ipc/snapshot_test.go diff --git a/README.md b/README.md index 5ff54b8..2981a03 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,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 | @@ -339,7 +340,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 | diff --git a/cmd/kcd/cli_battery.go b/cmd/kcd/cli_battery.go index f6cfa6a..1158398 100644 --- a/cmd/kcd/cli_battery.go +++ b/cmd/kcd/cli_battery.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "github.com/urfave/cli/v2" @@ -10,6 +11,12 @@ var batteryCmd = &cli.Command{ Name: "battery", Usage: "Fetch battery level and charging status", ArgsUsage: "", + 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") @@ -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" diff --git a/cmd/kcd/cli_sftp.go b/cmd/kcd/cli_sftp.go index 7ef2e46..dc8eb73 100644 --- a/cmd/kcd/cli_sftp.go +++ b/cmd/kcd/cli_sftp.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "github.com/urfave/cli/v2" @@ -37,6 +38,12 @@ The device responds with connection credentials on 'kcd watch'.`, ArgsUsage: "", Description: `Display the cached SFTP server credentials (IP, port, user, volumes). Use 'kcd sftp request' first to populate the cache.`, + 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") @@ -49,6 +56,11 @@ Use 'kcd sftp request' first to populate the cache.`, if err != nil { return err } + if c.Bool("json") { + out, _ := json.Marshal(info) + fmt.Println(string(out)) + return nil + } fmt.Printf("IP: %s\n", info.IP) fmt.Printf("Port: %s\n", info.Port) fmt.Printf("User: %s\n", info.User) @@ -69,6 +81,12 @@ Use 'kcd sftp request' first to populate the cache.`, ArgsUsage: "", Description: `Show the browsable storage roots exposed by the device. Uses the multiPaths/pathNames fields from the cached SFTP credentials.`, + 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") @@ -81,6 +99,11 @@ Uses the multiPaths/pathNames fields from the cached SFTP credentials.`, if err != nil { return err } + if c.Bool("json") { + out, _ := json.Marshal(volumes) + fmt.Println(string(out)) + return nil + } if len(volumes) == 0 { fmt.Println("No volumes available. Try 'kcd sftp request' first.") return nil diff --git a/cmd/kcd/cli_volume.go b/cmd/kcd/cli_volume.go index fd1cc58..e076af4 100644 --- a/cmd/kcd/cli_volume.go +++ b/cmd/kcd/cli_volume.go @@ -16,6 +16,12 @@ var volumeCmd = &cli.Command{ Name: "list", Usage: "List audio sinks on a remote device", ArgsUsage: "", + 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") @@ -28,6 +34,10 @@ var volumeCmd = &cli.Command{ if err != nil { return err } + if c.Bool("json") { + fmt.Println(string(data)) + return nil + } var sinks []remotesystemvolume.SinkInfo if err := json.Unmarshal(data, &sinks); err != nil { return fmt.Errorf("decode sinks: %w", err) diff --git a/docs/CLI.md b/docs/CLI.md index e4a66aa..8bb681b 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -123,10 +123,11 @@ a1b2c3d4_e5f6_7890_abcd_ef1234567890 Pixel 8 Pro phone Paired tru b9e1f234_0000_1111_2222_333344445555 Galaxy Tab S9 tablet Unpaired false ``` -**JSON output** +**JSON output** (enriched with cached battery/media/signal; sections omitted +when the device never reported them): ```bash -kcd devices --json | jq '.[0].ID' +kcd devices --json | jq '.[0] | {name, battery: .battery.charge}' ``` ```json @@ -236,7 +237,7 @@ kcd ping Fetch the current battery level and charging state of a device. ``` -kcd battery +kcd battery [--json] ``` **Example output** @@ -246,6 +247,8 @@ Battery: 74% (charging) Battery: 31% (discharging) ``` +`--json` prints `{"deviceId":"...","charge":74,"charging":true}` for scripting. + > For continuous monitoring, use `kcd watch --events=battery.update` instead. --- @@ -584,7 +587,7 @@ kcd watch --json --events=sftp.mount | jq -r 'select(.type=="sftp.mount") | .pay Show cached SFTP connection details for a paired device, including available storage volumes: ``` -kcd sftp info +kcd sftp info [--json] ``` **Example output** @@ -608,7 +611,7 @@ If the phone returned an error (e.g. storage permission not granted), the `error List available storage volumes without the full info output: ``` -kcd sftp volumes +kcd sftp volumes [--json] ``` **Example output** @@ -778,7 +781,7 @@ Control the remote device's audio volume (requires `remotesystemvolume` plugin). List audio sinks on a remote device and their current volume/mute state. ``` -kcd volume list +kcd volume list [--json] ``` **Example output** @@ -938,6 +941,10 @@ done ## Tips +Fire-and-forget commands (`ping`, `lock`, `share`, `reply`, …) print a +human ack and exit 0 — only commands that return data offer `--json`. +Errors are always structured (`daemon error: …` on stderr/exit code). + **Get the first paired device ID (works offline too)** ```bash diff --git a/docs/CLIENT_GUIDE.md b/docs/CLIENT_GUIDE.md index 831638f..a95f138 100644 --- a/docs/CLIENT_GUIDE.md +++ b/docs/CLIENT_GUIDE.md @@ -281,6 +281,7 @@ except KeyboardInterrupt: | Filter string | When it fires | |---|---| +| `state.snapshot` | Once per watch connection, right after the ack — full state for all known devices (online and offline); sent regardless of filters | | `device.connected` | TCP connection established | | `device.disconnected` | TCP connection lost | | `battery.update` | Battery level or charging state changed | @@ -304,10 +305,13 @@ except KeyboardInterrupt: > **Album art:** `mpris.update` payloads (and `kcd mpris status --json`) > expose `albumArtUrl` as a loadable `file://` path once the daemon has -> fetched the art from the phone into `$XDG_CACHE_HOME/kcd/art/`. If the -> art is still being fetched or fails, the raw `kdeconnect:/artUri?...` -> URI is emitted instead — treat anything that isn't `http(s)://` or -> `file://` as "no art available" and show a placeholder. +> fetched the art from the phone into `$XDG_CACHE_HOME/kcd/art/`. While the +> fetch is in flight the payload carries `"albumArtUrl": ""` with +> `"artPending": true` — render a placeholder whenever the URL is empty. + +> **Position:** payloads stamp `posAnchorMs` (Unix millis when `pos` was +> sampled). Live position is `pos + (nowMs - posAnchorMs)` while playing, +> frozen otherwise — no client-side timers needed. See [`IPC_PROTOCOL.md §5`](IPC_PROTOCOL.md#5-event-types) for the full list. @@ -470,8 +474,11 @@ Each event type carries a different payload shape. Here are the common ones: "album": "Album", "isPlaying": true, "pos": 45000, + "posAnchorMs": 1712345678901, "length": 240000, "volume": 80, + "albumArtUrl": "", + "artPending": true, "canControl": true, "shuffle": false, "loopStatus": "None" diff --git a/docs/IPC_PROTOCOL.md b/docs/IPC_PROTOCOL.md index e689321..8b5b8b2 100644 --- a/docs/IPC_PROTOCOL.md +++ b/docs/IPC_PROTOCOL.md @@ -96,8 +96,11 @@ Fields: | `type` | string | `"phone"`, `"tablet"`, `"laptop"`, `"desktop"` | | `state` | string | `"UNPAIRED"`, `"PAIR_REQUESTED"`, `"PAIR_REQUESTED_BY_PEER"`, `"PAIRED"` (`"UNKNOWN"` may appear in state files written by older versions and means unpaired) | | `cert_fp` | string | Not populated in this response (empty) | -| `last_seen` | string (RFC3339) | Not populated in this response (zero time) | +| `last_seen` | string (RFC3339) | Last time the device was seen (announcement or connection) | | `connected` | bool | Whether the device currently has an active TCP connection. Note: `connected: true` alone does **not** mean usable — a stranger on the LAN can hold a raw connection while `state` is `UNPAIRED`. Clients must check `state == "PAIRED"` before sending commands or auto-selecting a device. | +| `battery` | object (optional) | `{"charge": 85, "charging": true}` — cached battery state | +| `media` | object (optional) | Cached `NowPlaying` plus `mediaAgeMs` (ms since the phone reported); absent when the device never reported media | +| `signal` | object (optional) | Cached connectivity report (`{"signalStrengths": {...}}`); absent when never reported | #### `pair` @@ -684,7 +687,17 @@ are delivered. accepting the watch request. The client must read (and discard) this line before processing events. -2. **State dump:** For each **connected** device, in arbitrary order: +2. **`state.snapshot`:** One event covering **all known devices** (online + and offline) with cached battery/media/signal, so clients boot with full + state from this single connection: + ```json + {"type":"state.snapshot","timestamp":"2026-05-27T10:00:00Z","payload":{"devices":[{...DeviceSummary...}]}} + ``` + Media entries carry `mediaAgeMs` (ms since the phone last reported) with + no freshness gate — clients apply their own staleness rules. This event + is sent regardless of `events` filters. + +3. **State dump:** For each **connected** device, in arbitrary order: **2a. `device.connected`:** ```json @@ -777,6 +790,18 @@ A TCP connection was lost. **Payload:** none (`null`) +#### `state.snapshot` + +Full-state bootstrap sent once per `watch` connection, right after the +ack and regardless of `events` filters. Covers **all known devices** +(online and offline) with the same enriched shape as `devices`. + +**Payload:** + +```json +{"devices": [{...DeviceSummary (see `devices`)...}]} +``` + ### 5.2 Pairing Events #### `pair.requested` @@ -1122,11 +1147,12 @@ Now-playing state from a device's media player. > **Album art:** when the phone advertises a `kdeconnect:/artUri?...` URI, > the daemon requests the art bytes over a side channel and caches them to -> `$XDG_CACHE_HOME/kcd/art/.`. Once fetched, `albumArtUrl` -> is emitted as a loadable `file://` path. A second `mpris.update` event is -> published when the art arrives. If the fetch is still in flight or fails, -> the original URI is left untouched — clients should fall back to a -> placeholder. +> `$XDG_CACHE_HOME/kcd/art/.`. While the fetch is in +> flight, events carry `"albumArtUrl": ""` with `"artPending": true` — never +> an unloadable URI, so clients can render a placeholder with no special +> casing. Once fetched, `albumArtUrl` is emitted as a loadable `file://` +> path in a second `mpris.update`. If the fetch fails, the pending flag +> clears on the next state change. > **Freshness:** the daemon re-requests now-playing from every connected > device with an **actively-playing** player every 5 seconds @@ -1138,6 +1164,11 @@ Now-playing state from a device's media player. > player keeps its last pushed track (so it can be resumed), but stops being > refreshed and ages out of the 10-second initial-dump gate. +> **Position:** every event stamps `posAnchorMs` (Unix millis when `pos` +> was sampled). Clients compute live position drift-free as +> `pos + (nowMs - posAnchorMs)` while `isPlaying` (rate 1), frozen +> otherwise — no local timers needed. + > **Session teardown:** when the phone reports a `playerList` that no longer > contains the device's currently-tracked player (the media session was > destroyed, e.g. the app was swiped away), the daemon drops the cached @@ -1158,6 +1189,7 @@ Now-playing state from a device's media player. "url": "spotify:track:...", "length": 240000, "pos": 45000, + "posAnchorMs": 1712345678901, "isPlaying": true, "volume": 80, "canControl": true, diff --git a/internal/events/bus.go b/internal/events/bus.go index 0efa0b1..2a8f5d0 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -39,6 +39,7 @@ const ( TypeSMSAttachment EventType = "sms.attachment" TypeRingReceived EventType = "ring.received" TypeMprisUpdate EventType = "mpris.update" + TypeStateSnapshot EventType = "state.snapshot" ) const ( diff --git a/internal/ipc/handler.go b/internal/ipc/handler.go index c81cba1..116056e 100644 --- a/internal/ipc/handler.go +++ b/internal/ipc/handler.go @@ -74,17 +74,12 @@ func (h *Handler) HandleRequest(req Request) Response { } func (h *Handler) handleDevices() Response { - // To convert the internal representation to JSON, we construct DeviceInfo structs + // Enriched summaries (battery/media/signal embedded, omitempty). + // Base identity fields are unchanged, so old clients keep working. devs := h.devices.List() - infos := make([]device.DeviceInfo, 0, len(devs)) + infos := make([]DeviceSummary, 0, len(devs)) for _, dev := range devs { - infos = append(infos, device.DeviceInfo{ - ID: dev.ID(), - Name: dev.Name(), - Type: dev.Type, - State: dev.State(), - Connected: dev.IsConnected(), - }) + infos = append(infos, SummarizeDevice(dev, h.plugins)) } data, err := json.Marshal(infos) diff --git a/internal/ipc/server.go b/internal/ipc/server.go index a7d6d90..e03a569 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -127,6 +127,21 @@ func (s *Server) handleWatch(conn net.Conn, payload []byte) { // Send OK response to indicate stream is starting s.writeResponse(conn, Response{OK: true}) + // Full-state snapshot first: every known device (online AND offline) + // with cached battery/media/signal, so clients boot with complete + // state from this single connection — no auxiliary bootstrap calls, + // no hydration races. Sent regardless of event filters. + snapEv := map[string]interface{}{ + "type": events.TypeStateSnapshot, + "timestamp": time.Now().UTC(), + "payload": BuildSnapshot(s.handler.devices, s.handler.plugins), + } + data, _ := json.Marshal(snapEv) + data = append(data, '\n') + if _, err := conn.Write(data); err != nil { + return + } + // Initial State Dump // For each connected device, we emit device.connected and battery.update. devs := s.handler.devices.Connected() @@ -144,7 +159,7 @@ func (s *Server) handleWatch(conn net.Conn, payload []byte) { "timestamp": time.Now().UTC(), "payload": devData, } - data, _ := json.Marshal(initEv) + data, _ = json.Marshal(initEv) data = append(data, '\n') if _, err := conn.Write(data); err != nil { return diff --git a/internal/ipc/snapshot.go b/internal/ipc/snapshot.go new file mode 100644 index 0000000..222bd53 --- /dev/null +++ b/internal/ipc/snapshot.go @@ -0,0 +1,88 @@ +package ipc + +import ( + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/plugin" + "github.com/bethropolis/kcd/internal/plugins/connectivity" + "github.com/bethropolis/kcd/internal/plugins/mpris" +) + +// BatteryStatus mirrors the battery state for embedding in summaries. +type BatteryStatus struct { + Charge int `json:"charge"` + Charging bool `json:"charging"` +} + +// MediaState is the cached now-playing state plus its age, so clients can +// apply their own staleness rules instead of the daemon hiding paused media. +type MediaState struct { + mpris.NowPlaying + MediaAgeMs int64 `json:"mediaAgeMs"` +} + +// DeviceSummary enriches DeviceInfo with cached sub-states for clients. +// It is IPC-only: DeviceInfo stays the on-disk shape, so persisted state +// never grows these fields. All sub-states are omitempty. +type DeviceSummary struct { + device.DeviceInfo + Battery *BatteryStatus `json:"battery,omitempty"` + Media *MediaState `json:"media,omitempty"` + Signal *connectivity.ConnectivityBody `json:"signal,omitempty"` +} + +// SummarizeDevice builds the enriched view of one device. Absent plugins +// or missing caches simply omit their section. +func SummarizeDevice(dev *device.Device, plugins *plugin.Registry) DeviceSummary { + sum := DeviceSummary{ + DeviceInfo: device.DeviceInfo{ + ID: dev.ID(), + Name: dev.Name(), + Type: dev.Type, + State: dev.State(), + LastSeen: dev.LastSeen(), + Connected: dev.IsConnected(), + }, + } + + charge, charging := dev.GetBattery() + sum.Battery = &BatteryStatus{Charge: charge, Charging: charging} + + if plugins == nil { + return sum + } + if pl, ok := plugins.GetByName("MPRIS"); ok { + mp := pl.(*mpris.MPRISPlugin) + if state := mp.RemoteState(dev.ID()); state != nil { + sum.Media = &MediaState{ + NowPlaying: *state, + MediaAgeMs: mp.RemoteStateAge(dev.ID()).Milliseconds(), + } + } + } + if pl, ok := plugins.GetByName("Connectivity"); ok { + if report, ok := pl.(*connectivity.ConnectivityPlugin).Report(dev.ID()); ok { + r := report + sum.Signal = &r + } + } + return sum +} + +// SnapshotPayload is the state.snapshot event payload: every known device +// (online and offline) with its cached sub-states, so clients boot with +// full state from a single watch connection. +type SnapshotPayload struct { + Devices []DeviceSummary `json:"devices"` +} + +// BuildSnapshot assembles the full-state payload for the state.snapshot event. +func BuildSnapshot(devices *device.Registry, plugins *plugin.Registry) SnapshotPayload { + snap := SnapshotPayload{Devices: []DeviceSummary{}} + if devices == nil { + return snap + } + for _, dev := range devices.List() { + snap.Devices = append(snap.Devices, SummarizeDevice(dev, plugins)) + } + return snap +} diff --git a/internal/ipc/snapshot_test.go b/internal/ipc/snapshot_test.go new file mode 100644 index 0000000..bed52c1 --- /dev/null +++ b/internal/ipc/snapshot_test.go @@ -0,0 +1,65 @@ +package ipc_test + +import ( + "encoding/json" + "testing" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/ipc" + "github.com/bethropolis/kcd/internal/plugin" + "go.uber.org/zap/zaptest" +) + +func TestBuildSnapshotCoversOfflineDevices(t *testing.T) { + logger := zaptest.NewLogger(t) + devReg := device.NewRegistry(nil) + pluginReg := plugin.NewRegistry(logger) + + online := device.NewDevice("dev-online", "Online Phone", "phone", logger) + online.UpdateBattery(50, true) + online.SetState(device.StatePaired) + devReg.Add(online) + + offline := device.NewDevice("dev-off", "Offline Phone", "phone", logger) + offline.SetState(device.StatePaired) + devReg.Add(offline) + + stranger := device.NewDevice("dev-stranger", "Stranger", "phone", logger) + devReg.Add(stranger) + + snap := ipc.BuildSnapshot(devReg, pluginReg) + if len(snap.Devices) != 3 { + t.Fatalf("expected all 3 devices in snapshot, got %d", len(snap.Devices)) + } + + byID := make(map[string]ipc.DeviceSummary) + for _, d := range snap.Devices { + byID[d.ID] = d + } + + if byID["dev-online"].Battery == nil || byID["dev-online"].Battery.Charge != 50 { + t.Errorf("online battery missing: %+v", byID["dev-online"].Battery) + } + if byID["dev-off"].Battery == nil { + t.Error("offline paired device should still carry battery (dump parity)") + } + if byID["dev-stranger"].Media != nil || byID["dev-stranger"].Signal != nil { + t.Error("absent plugins must omit media/signal sections") + } + + // Identity fields survive the enrichment untouched. + raw, _ := json.Marshal(snap) + var decoded struct { + Devices []map[string]interface{} `json:"devices"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + for _, d := range decoded.Devices { + for _, k := range []string{"id", "name", "type", "state", "connected"} { + if _, ok := d[k]; !ok { + t.Errorf("summary missing base field %q: %v", k, d) + } + } + } +} diff --git a/internal/plugins/mpris/mpris.go b/internal/plugins/mpris/mpris.go index 327d93a..da81649 100644 --- a/internal/plugins/mpris/mpris.go +++ b/internal/plugins/mpris/mpris.go @@ -160,14 +160,19 @@ type MPRISRequest struct { } type NowPlaying struct { - Player string `json:"player"` - Title string `json:"title"` - Artist string `json:"artist"` - Album string `json:"album"` - AlbumArtUrl string `json:"albumArtUrl"` - Url string `json:"url,omitempty"` - Length int64 `json:"length"` - Pos int64 `json:"pos,omitempty"` + Player string `json:"player"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + AlbumArtUrl string `json:"albumArtUrl"` + ArtPending bool `json:"artPending,omitempty"` + Url string `json:"url,omitempty"` + Length int64 `json:"length"` + Pos int64 `json:"pos,omitempty"` + // PosAnchorMs is the wall-clock time (Unix millis) at which Pos was + // sampled. Clients compute the live position drift-free as + // Pos + (nowMs - PosAnchorMs) * (isPlaying ? 1 : 0). + PosAnchorMs int64 `json:"posAnchorMs,omitempty"` IsPlaying bool `json:"isPlaying"` Volume int `json:"volume,omitempty"` CanControl bool `json:"canControl"` @@ -312,6 +317,7 @@ func (p *MPRISPlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc tracker.lastPosition = body.Pos tracker.lastPositionAt = time.Now() tracker.playing = body.IsPlaying + state.PosAnchorMs = tracker.lastPositionAt.UnixMilli() shouldPublish := shouldPublishRemoteState(p.remoteStates[dev.ID()], state) p.remoteStates[dev.ID()] = state p.remoteStateTimes[dev.ID()] = time.Now() @@ -328,6 +334,12 @@ func (p *MPRISPlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc if p.artCache != nil { if resolved := p.artCache.Resolve(pub.AlbumArtUrl); resolved != "" { pub.AlbumArtUrl = resolved + } else if pub.AlbumArtUrl != "" && strings.HasPrefix(pub.AlbumArtUrl, "kdeconnect:") { + // Art still downloading: publish an empty URL with the + // pending flag instead of an unloadable kdeconnect:/ + // URI. The arrival re-publish carries file://. + pub.AlbumArtUrl = "" + pub.ArtPending = true } } p.bus.Publish(events.TypeMprisUpdate, dev.ID(), pub) @@ -842,6 +854,16 @@ func (p *MPRISPlugin) RequestState(dev device.Sender, player string) error { } // RemoteState returns the last known NowPlaying state for a remote device, +// markArtPending empties unloadable kdeconnect:/ art URIs and flags them, +// so serving paths (status, snapshot, summaries) agree with published +// events: art is either a loadable URL or "" with ArtPending set. +func markArtPending(np *NowPlaying) { + if np.AlbumArtUrl != "" && strings.HasPrefix(np.AlbumArtUrl, "kdeconnect:") { + np.AlbumArtUrl = "" + np.ArtPending = true + } +} + // with position extrapolated from the last update time if playing. func (p *MPRISPlugin) RemoteState(deviceID string) *NowPlaying { p.mu.RLock() @@ -852,6 +874,7 @@ func (p *MPRISPlugin) RemoteState(deviceID string) *NowPlaying { } copy := state.DeepCopy() copy.AlbumArtUrl = p.resolveArtURL(copy.AlbumArtUrl) + markArtPending(copy) if tracker, ok := p.positionTrackers[deviceID]; ok && tracker.playing { elapsed := time.Since(tracker.lastPositionAt).Milliseconds() copy.Pos = tracker.lastPosition + elapsed @@ -883,6 +906,7 @@ func (p *MPRISPlugin) RemoteStates() map[string]*NowPlaying { } copy := state.DeepCopy() copy.AlbumArtUrl = p.resolveArtURL(copy.AlbumArtUrl) + markArtPending(copy) if tracker, ok := p.positionTrackers[id]; ok && tracker.playing { elapsed := time.Since(tracker.lastPositionAt).Milliseconds() copy.Pos = tracker.lastPosition + elapsed diff --git a/internal/plugins/mpris/mpris_test.go b/internal/plugins/mpris/mpris_test.go index 0c00d1c..e6bb35b 100644 --- a/internal/plugins/mpris/mpris_test.go +++ b/internal/plugins/mpris/mpris_test.go @@ -374,3 +374,54 @@ func TestHandleKeepsListedPlayer(t *testing.T) { } expectNoEvent(t, sub) // no spurious empty update } + +func TestPublishedEventHasAnchorAndPendingArt(t *testing.T) { + bus := events.NewBus(zap.NewNop()) + sub := bus.Subscribe(4, events.TypeMprisUpdate) + defer sub.Close() + + plugin := NewMPRISPlugin(nil, bus, false, zap.NewNop()) + if plugin.watchCancel != nil { + defer plugin.watchCancel() + } + dev := testSender{id: "device-anchor"} + + before := time.Now().UnixMilli() + state := MPRISRequest{ + Player: "Metrolist", + Title: "Anchor Test", + Pos: 42000, + IsPlaying: true, + PlaybackStatus: "Playing", + AlbumArtUrl: "kdeconnect:/artUri?title=Anchor+Test&kdeArtHash=424242", + } + if err := plugin.Handle(context.Background(), dev, newMPRISPacket(t, state)); err != nil { + t.Fatal(err) + } + + var ev events.Event + select { + case ev = <-sub.C: + case <-time.After(time.Second): + t.Fatal("expected event") + } + pub, ok := ev.Payload.(*NowPlaying) + if !ok { + t.Fatalf("expected *NowPlaying payload, got %T", ev.Payload) + } + if pub.PosAnchorMs < before { + t.Errorf("PosAnchorMs %d older than push start %d", pub.PosAnchorMs, before) + } + if pub.AlbumArtUrl != "" || !pub.ArtPending { + t.Errorf("expected empty URL with ArtPending, got url=%q pending=%v", pub.AlbumArtUrl, pub.ArtPending) + } + + // Serving paths agree with the published event. + rs := plugin.RemoteState(dev.ID()) + if rs == nil || rs.AlbumArtUrl != "" || !rs.ArtPending { + t.Errorf("RemoteState should hide unresolved art, got %+v", rs) + } + if rs.PosAnchorMs != pub.PosAnchorMs { + t.Errorf("anchor mismatch: published %d, served %d", pub.PosAnchorMs, rs.PosAnchorMs) + } +} diff --git a/packaging/kcd.fish-completion b/packaging/kcd.fish-completion index 9b407ec..d0159f1 100644 --- a/packaging/kcd.fish-completion +++ b/packaging/kcd.fish-completion @@ -130,8 +130,10 @@ for _cmd in unpair ping battery connectivity findmyphone lock unlock clipboard end # --------------------------------------------------------------------------- -# connectivity (just --json flag; device-id handled above) +# battery / connectivity (just --json flags; device-id handled above) # --------------------------------------------------------------------------- +complete -c kcd -n "__fish_seen_subcommand_from battery" \ + -l json -d 'Output raw JSON' complete -c kcd -n "__fish_seen_subcommand_from connectivity" \ -l json -d 'Output the raw report as JSON' @@ -195,6 +197,11 @@ for _sub in request info volumes mount unmount -a '(__kcd_connected_devices)' -d 'Device ID' end +complete -c kcd -n "__fish_seen_subcommand_from sftp && __fish_seen_subcommand_from info" \ + -l json -d 'Output raw JSON' +complete -c kcd -n "__fish_seen_subcommand_from sftp && __fish_seen_subcommand_from volumes" \ + -l json -d 'Output raw JSON' + # --------------------------------------------------------------------------- # reply # --------------------------------------------------------------------------- From 92b549984fe91780a49f9d5259e06cbc70a8d54e Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:05:12 +0300 Subject: [PATCH 07/25] fix(ipc): report connected devices as seen now in summaries last_seen is stamped at connect time and discovery sightings skip connected devices, so the raw stamp goes stale for the whole session and clients show "Xm ago" for a live phone. SummarizeDevice now reports now for connected devices; stored stamp, prune, and devices.json semantics untouched. --- internal/ipc/snapshot.go | 12 +++++++++- internal/ipc/snapshot_test.go | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/internal/ipc/snapshot.go b/internal/ipc/snapshot.go index 222bd53..0a5dec0 100644 --- a/internal/ipc/snapshot.go +++ b/internal/ipc/snapshot.go @@ -1,6 +1,8 @@ package ipc import ( + "time" + "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/plugin" "github.com/bethropolis/kcd/internal/plugins/connectivity" @@ -33,13 +35,21 @@ type DeviceSummary struct { // SummarizeDevice builds the enriched view of one device. Absent plugins // or missing caches simply omit their section. func SummarizeDevice(dev *device.Device, plugins *plugin.Registry) DeviceSummary { + // A connected device is seen right now by definition: last_seen is only + // stamped at connect time and on discovery sightings (which skip + // connected devices), so without this it goes stale for the whole + // session and clients show "Xm ago" for a live phone. + lastSeen := dev.LastSeen() + if dev.IsConnected() { + lastSeen = time.Now() + } sum := DeviceSummary{ DeviceInfo: device.DeviceInfo{ ID: dev.ID(), Name: dev.Name(), Type: dev.Type, State: dev.State(), - LastSeen: dev.LastSeen(), + LastSeen: lastSeen, Connected: dev.IsConnected(), }, } diff --git a/internal/ipc/snapshot_test.go b/internal/ipc/snapshot_test.go index bed52c1..d5bca7f 100644 --- a/internal/ipc/snapshot_test.go +++ b/internal/ipc/snapshot_test.go @@ -1,12 +1,17 @@ package ipc_test import ( + "context" + "crypto/tls" "encoding/json" + "net" "testing" + "time" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/ipc" "github.com/bethropolis/kcd/internal/plugin" + "github.com/bethropolis/kcd/internal/transport" "go.uber.org/zap/zaptest" ) @@ -63,3 +68,39 @@ func TestBuildSnapshotCoversOfflineDevices(t *testing.T) { } } } + +// A connected device is seen now by definition: last_seen is stamped at +// connect time and discovery sightings skip connected devices, so the raw +// stamp goes stale for the whole session. The summary must report now for +// connected devices and preserve the stored stamp for offline ones. +func TestSummarizeDeviceConnectedMeansSeenNow(t *testing.T) { + logger := zaptest.NewLogger(t) + stale := time.Now().Add(-53 * time.Minute) + + offline := device.NewDevice("dev-off", "Offline Phone", "phone", logger) + offline.SetLastSeen(stale) + offlineSum := ipc.SummarizeDevice(offline, nil) + if !offlineSum.LastSeen.Equal(stale) { + t.Errorf("offline summary must preserve stored last_seen, got %v", offlineSum.LastSeen) + } + + online := device.NewDevice("dev-on", "Online Phone", "phone", logger) + online.SetLastSeen(stale) + left, right := net.Pipe() + conn := transport.NewConn(tls.Client(left, &tls.Config{InsecureSkipVerify: true})) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + online.Connect(ctx, conn, nil, nil, nil) + + onlineSum := ipc.SummarizeDevice(online, nil) + if time.Since(onlineSum.LastSeen) > time.Minute { + t.Errorf("connected summary last_seen must be now, got %v", onlineSum.LastSeen) + } + + _ = right.Close() + deadline := time.Now().Add(2 * time.Second) + for online.IsConnected() && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + online.Disconnect() +} From 701b24da9978fcff28d6afbaa45e9e693f434a5e Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:22:30 +0300 Subject: [PATCH 08/25] fix(discovery): harden paired dials, intent lifetime, and device names Paired sightings no longer trust unauthenticated discovery for dial targets or names; redial LastIP:LastPort with per-device throttle. Clamp discovery ports, throttle ephemeral dials, extend pair intent past the one-shot trigger, and strip control/shell chars from names. --- cmd/kcd/cli_devices.go | 11 ++-- internal/daemon/daemon.go | 9 ++- internal/daemon/ipc_routes.go | 5 +- internal/daemon/transport.go | 96 +++++++++++++++++++++++++++++- internal/daemon/transport_test.go | 60 +++++++++++++++++++ internal/device/device.go | 80 +++++++++++++++++++++++-- internal/plugins/pair/pair.go | 7 +++ internal/protocol/identity.go | 40 ++++++++++++- internal/protocol/identity_test.go | 51 ++++++++++++++++ 9 files changed, 342 insertions(+), 17 deletions(-) diff --git a/cmd/kcd/cli_devices.go b/cmd/kcd/cli_devices.go index 887b011..91e5575 100644 --- a/cmd/kcd/cli_devices.go +++ b/cmd/kcd/cli_devices.go @@ -10,6 +10,7 @@ import ( "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/ipc" + "github.com/bethropolis/kcd/internal/protocol" "github.com/urfave/cli/v2" ) @@ -139,7 +140,7 @@ Without a device ID: enter listen mode to receive and verify incoming pairing re } fmt.Printf("\nIncoming pair request from:\n") - fmt.Printf(" Device: %s (%s)\n", r.result.DeviceName, r.result.DeviceID) + fmt.Printf(" Device: %s (%s)\n", protocol.DisplayName(r.result.DeviceName), r.result.DeviceID) if r.result.VerificationKey != "" { fmt.Printf(" Verification code: %s\n", r.result.VerificationKey) } @@ -149,7 +150,7 @@ Without a device ID: enter listen mode to receive and verify incoming pairing re if err := cl.Pair(r.result.DeviceID); err != nil { return fmt.Errorf("failed to accept pairing: %w", err) } - fmt.Printf("Paired with %s (%s)\n", r.result.DeviceName, r.result.DeviceID) + fmt.Printf("Paired with %s (%s)\n", protocol.DisplayName(r.result.DeviceName), r.result.DeviceID) return nil } @@ -163,13 +164,13 @@ Without a device ID: enter listen mode to receive and verify incoming pairing re if err := cl.Pair(r.result.DeviceID); err != nil { return fmt.Errorf("failed to accept pairing: %w", err) } - fmt.Printf("Paired with %s (%s)\n", r.result.DeviceName, r.result.DeviceID) + fmt.Printf("Paired with %s (%s)\n", protocol.DisplayName(r.result.DeviceName), r.result.DeviceID) return nil } // User rejected: reject and cancel request _ = cl.Unpair(r.result.DeviceID) - fmt.Printf("Rejected pairing with %s\n", r.result.DeviceName) + fmt.Printf("Rejected pairing with %s\n", protocol.DisplayName(r.result.DeviceName)) return nil } } @@ -205,6 +206,6 @@ func printDeviceTable(devices []device.DeviceInfo) { fmt.Printf("%-36s %-20s %-10s %-10s %s\n", "DEVICE ID", "NAME", "TYPE", "STATE", "CONNECTED") fmt.Println("---------------------------------------------------------------------------------------------------") for _, d := range devices { - fmt.Printf("%-36s %-20s %-10s %-10s %v\n", d.ID, d.Name, d.Type, d.State, d.Connected) + fmt.Printf("%-36s %-20s %-10s %-10s %v\n", d.ID, protocol.DisplayName(d.Name), d.Type, d.State, d.Connected) } } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 1ec9899..28bdf24 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -84,7 +84,8 @@ func Run(ctx context.Context, cfg *config.Config) error { } // Migrate pre-fix names: some senders transmit decimal byte // escapes (e.g. "Caf\\195\\169"); decode to real UTF-8. - info.Name = protocol.SanitizeDeviceName(protocol.DecodeDeviceName(info.Name)) + // (SanitizeDeviceName already decodes; don't double-decode.) + info.Name = protocol.SanitizeDeviceName(info.Name) dev := device.NewDevice(info.ID, info.Name, info.Type, logger) dev.SetState(info.State) dev.CertFP = info.CertFP @@ -171,6 +172,12 @@ func Run(ctx context.Context, cfg *config.Config) error { logger.Warn("on-demand pair dial failed", zap.String("device_id", deviceID)) return } + // The immediate dial succeeded: consume the one-shot trigger so + // the next discovery announcement doesn't spawn a duplicate + // dial. The keep-alive intent (PairDialActive) stays armed until + // pairing starts, ends, or expires. On failure the trigger is + // left for the next sighting to retry. + dev.ConsumePairDial() if dev.State() == device.StatePairRequestedByPeer { if err := pairPlugin.AcceptPairing(dev); err != nil { logger.Warn("auto-accept on pair dial failed", zap.Error(err)) diff --git a/internal/daemon/ipc_routes.go b/internal/daemon/ipc_routes.go index 7205bbb..bcc5977 100644 --- a/internal/daemon/ipc_routes.go +++ b/internal/daemon/ipc_routes.go @@ -83,9 +83,10 @@ func registerIPCRoutes(handler *ipc.Handler, cfg *config.Config, devices *device bc.Stop() // Pairing window closed: drop discovery connections that never led // to pairing so strangers don't linger. Paired devices, pair - // requests in flight, and explicit pair intents are left alone. + // requests in flight, and explicit pair intents (kept alive until + // pairing starts, ends, or expires) are left alone. for _, dev := range devices.List() { - if !dev.IsConnected() || dev.State() != device.StateUnpaired || dev.PairDialPending() { + if !dev.IsConnected() || dev.State() != device.StateUnpaired || dev.PairDialActive() { continue } logger.Debug("broadcast stopped, dropping unpaired discovery connection", diff --git a/internal/daemon/transport.go b/internal/daemon/transport.go index 554c2a6..40aff20 100644 --- a/internal/daemon/transport.go +++ b/internal/daemon/transport.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net" + "sync" "time" "github.com/bethropolis/kcd/internal/cert" @@ -23,8 +24,22 @@ import ( // escalating instead of resetting to the 2s floor on every drop. const reconnectFlapThreshold = 15 * time.Second +// validDialPort reports whether a discovery-advertised TCP port is usable. +// Port 0 and out-of-range values come from malformed or hostile packets and +// must never reach the dialer (port 0 would dial ":0"). +func validDialPort(port int) bool { + return port > 0 && port <= 65535 +} + // DialDevice manually connects to a device at the given IP and port. func DialDevice(ctx context.Context, targetIP net.IP, targetPort int, targetID string, targetProto int, identity *protocol.Packet, cfg *tls.Config, devices *device.Registry, plugins *plugin.Registry, localDeviceID string, logger *zap.Logger) { + if targetIP == nil || !validDialPort(targetPort) { + logger.Debug("refusing to dial invalid target", + zap.String("device_id", targetID), + zap.String("ip", targetIP.String()), + zap.Int("port", targetPort)) + return + } addr := fmt.Sprintf("%s:%d", targetIP, targetPort) logger.Debug("dialing discovered device", zap.String("device_id", targetID), zap.String("addr", addr)) @@ -40,6 +55,11 @@ func DialDevice(ctx context.Context, targetIP net.IP, targetPort int, targetID s var myID protocol.IdentityBody json.Unmarshal(identity.Body, &myID) + // Clamp the echoed protocol version: discovery bodies are unauthenticated + // and a garbage value here would only confuse the peer. + if targetProto <= 0 || targetProto > protocol.ProtocolVersion { + targetProto = protocol.ProtocolVersion + } preTlsId := protocol.IdentityBody{ DeviceID: myID.DeviceID, DeviceName: myID.DeviceName, @@ -89,7 +109,9 @@ func shouldEphemeralClose(dev *device.Device, pairingMode bool) bool { if dev.State() != device.StateUnpaired { return false } - if dev.PairDialPending() { + // An explicit `kcd pair ` intent outlives the one-shot dial trigger + // (consumed on first sighting) until pairing starts, ends, or expires. + if dev.PairDialActive() { return false } return true @@ -116,6 +138,36 @@ func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.Broadcaste // era so both sides can list each other (the TCP identity exchange // is what makes the PC appear on the phone). The next sighting // closes the socket again while it is still unpaired. + // Ephemeral-dial rate limiting: a hostile or buggy peer minting fresh + // device IDs per broadcast could otherwise spawn an unbounded dial per + // announcement. Stranger dials are throttled globally (1/s) and per + // announcer IP (1/5s). Paired and explicit-intent dials bypass this — + // paired dials have their own per-device throttle below and intent + // dials are user-initiated. + var dialMu sync.Mutex + lastEphemeralGlobal := time.Now().Add(-time.Minute) + lastEphemeralByIP := map[string]time.Time{} + allowEphemeralDial := func(ip net.IP) bool { + dialMu.Lock() + defer dialMu.Unlock() + now := time.Now() + if now.Sub(lastEphemeralGlobal) < time.Second { + return false + } + if last, ok := lastEphemeralByIP[ip.String()]; ok && now.Sub(last) < 5*time.Second { + return false + } + // Opportunistic prune so spoofed source IPs can't grow the map. + for k, v := range lastEphemeralByIP { + if now.Sub(v) > time.Minute { + delete(lastEphemeralByIP, k) + } + } + lastEphemeralGlobal = now + lastEphemeralByIP[ip.String()] = now + return true + } + onDeviceFound := func(ip net.IP, tcpPort int, peerIdentity *protocol.Packet) { var body protocol.IdentityBody if err := json.Unmarshal(peerIdentity.Body, &body); err != nil { @@ -126,6 +178,14 @@ func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.Broadcaste return } + // Never dial garbage ports from unauthenticated announcements. + if !validDialPort(tcpPort) { + logger.Debug("ignoring discovery with invalid tcpPort", + zap.String("device_id", body.DeviceID), + zap.Int("port", tcpPort)) + return + } + pairingMode := bc != nil && bc.IsRunning() dev, known := devices.Get(body.DeviceID) @@ -140,6 +200,28 @@ func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.Broadcaste return } + if known && dev.State() == device.StatePaired { + // Paired devices never trust discovery for their dial target: + // the DeviceID/TCPPort in these packets are unauthenticated, so + // a spoofed sighting must not redirect the auto-dial or clobber + // the name. Redial the last known-good address from the + // authenticated exchange instead; after a restart (no LastIP + // yet) wait for the phone's inbound connection. + dev.SetLastSeen(time.Now()) + if !dev.IsConnected() { + if lastIP := dev.LastIP(); lastIP != nil { + port := dev.LastPort() + if !validDialPort(port) { + port = 1716 + } + if dev.ShouldDiscoveryDial(10 * time.Second) { + go DialDevice(ctx, lastIP, port, body.DeviceID, body.ProtocolVersion, identity, cfg, devices, plugins, localDeviceID, logger) + } + } + } + return + } + safeName := protocol.SanitizeDeviceName(body.DeviceName) if !known { dev = device.NewDevice(body.DeviceID, safeName, body.DeviceType, logger) @@ -150,7 +232,7 @@ func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.Broadcaste dev.SetDiscoveryAddr(ip, tcpPort) dev.SetLastSeen(time.Now()) - if dev.State() == device.StatePaired || pairingMode || dev.ConsumePairDial() { + if pairingMode || dev.ConsumePairDial() { // Spawn goroutine to prevent blocking the discovery listener go func(targetIP net.IP, targetPort int, targetID string, targetProto int) { DialDevice(ctx, targetIP, targetPort, targetID, targetProto, identity, cfg, devices, plugins, localDeviceID, logger) @@ -160,6 +242,9 @@ func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.Broadcaste if !dev.EphemeralDialed() { dev.MarkEphemeralDialed() + if !allowEphemeralDial(ip) { + return + } // Spawn goroutine to prevent blocking the discovery listener go func(targetIP net.IP, targetPort int, targetID string, targetProto int) { DialDevice(ctx, targetIP, targetPort, targetID, targetProto, identity, cfg, devices, plugins, localDeviceID, logger) @@ -269,6 +354,13 @@ func handleNewConnection(ctx context.Context, conn *transport.Conn, identity *pr dev.CertFP = certFP } + // Remember the peer's listening port from the authenticated exchange so + // paired auto-dials use a known-good target instead of trusting future + // (unauthenticated) discovery announcements. + if validDialPort(peerBody.TCPPort) { + dev.SetLastPort(peerBody.TCPPort) + } + dev.IncomingCaps = peerBody.IncomingCapabilities dev.OutgoingCaps = peerBody.OutgoingCapabilities diff --git a/internal/daemon/transport_test.go b/internal/daemon/transport_test.go index cbd0e23..9762438 100644 --- a/internal/daemon/transport_test.go +++ b/internal/daemon/transport_test.go @@ -2,6 +2,7 @@ package daemon import ( "testing" + "time" "github.com/bethropolis/kcd/internal/device" "go.uber.org/zap" @@ -70,3 +71,62 @@ func TestEphemeralMarkerReset(t *testing.T) { t.Fatal("pair intent not cleared after consume") } } + +func TestPairDialIntentLifetime(t *testing.T) { + dev := device.NewDevice("test-id", "Test", "phone", zap.NewNop()) + dev.MarkEphemeralDialed() + + if dev.PairDialActive() { + t.Fatal("new device must have no active pair intent") + } + + dev.RequestPairDial() + if !dev.PairDialActive() { + t.Fatal("pair intent not active after request") + } + + // Consuming the one-shot dial trigger must NOT clear the keep-alive + // intent: a slow phone-side accept must not downgrade into an + // ephemeral-close flap. + if !dev.ConsumePairDial() { + t.Fatal("pair intent not consumed") + } + if !dev.PairDialActive() { + t.Fatal("keep-alive intent lost after one-shot consume") + } + if shouldEphemeralClose(dev, false) { + t.Fatal("explicit intent must pin the connection after consume") + } + + dev.ClearPairDial() + if dev.PairDialActive() { + t.Fatal("pair intent not cleared") + } + if !shouldEphemeralClose(dev, false) { + t.Fatal("idle stranger should close once intent is cleared") + } +} + +func TestLastPortRoundTrip(t *testing.T) { + dev := device.NewDevice("test-id", "Test", "phone", zap.NewNop()) + if dev.LastPort() != 0 { + t.Fatal("new device must have unknown port") + } + dev.SetLastPort(1716) + if dev.LastPort() != 1716 { + t.Fatalf("LastPort() = %d, want 1716", dev.LastPort()) + } +} + +func TestShouldDiscoveryDialThrottle(t *testing.T) { + dev := device.NewDevice("test-id", "Test", "phone", zap.NewNop()) + if !dev.ShouldDiscoveryDial(10 * time.Second) { + t.Fatal("first dial must be allowed") + } + if dev.ShouldDiscoveryDial(10 * time.Second) { + t.Fatal("immediate redial must be throttled") + } + if !dev.ShouldDiscoveryDial(0) { + t.Fatal("zero interval must always allow") + } +} diff --git a/internal/device/device.go b/internal/device/device.go index f492f33..ffacf93 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -28,6 +28,11 @@ type Device struct { lastSeen time.Time lastIP net.IP // cached from last successful connection; survives Disconnect + // lastPort is the tcpPort the peer last advertised over the authenticated + // (post-TLS) identity exchange. Used with lastIP as the dial target for + // paired devices so unauthenticated discovery packets can never redirect + // a paired auto-dial. Zero means unknown (fall back to 1716). + lastPort int // discoveryIP/discoveryPort remember where a device was last seen // announcing itself (UDP/mDNS), even if we never opened a TCP @@ -36,12 +41,25 @@ type Device struct { discoveryIP net.IP discoveryPort int - // pairDialRequested is set when the user explicitly asked to pair with - // this device while it has no active connection. The next discovery - // announcement for it triggers a one-shot outbound dial so the pair - // request can be delivered. + // pairDialRequested is the one-shot outbound dial trigger for an explicit + // `kcd pair ` request. It is consumed by the first discovery + // announcement after the request so the pair request can be delivered. pairDialRequested atomic.Bool + // pairIntentUntil is the Unix-nano deadline until which an explicit pair + // intent keeps a connection alive. Unlike pairDialRequested (consumed on + // first sighting), the intent survives dial/connect cycles until pairing + // starts, is rejected, succeeds, or the deadline (pairDialIntentTTL) + // expires — so a slow phone-side accept can't downgrade into an + // ephemeral-close flap. + pairIntentUntil atomic.Int64 + + // lastDiscoveryDial is when onDeviceFound last spawned a dial for this + // device. It throttles paired auto-redials to a dead LastIP so a phone + // that changed DHCP address (or a spoofed broadcast storm) can't cause + // a dial per announcement. + lastDiscoveryDial time.Time + // ephemeralDialed marks that this device already received its one // ephemeral discovery dial for the current unpaired era. Ephemeral // dials let a stranger complete the TCP identity exchange (so both @@ -358,11 +376,17 @@ func (d *Device) DiscoveryAddr() (net.IP, int) { return d.discoveryIP, d.discoveryPort } +// pairDialIntentTTL bounds how long an explicit `kcd pair ` intent pins +// a connection while pairing hasn't started yet. +const pairDialIntentTTL = 5 * time.Minute + // RequestPairDial marks the device for a one-shot outbound dial on its next -// discovery announcement. Used when the user explicitly runs `kcd pair ` -// for a device with no active connection. +// discovery announcement and arms the keep-alive intent until pairing starts, +// is rejected, succeeds, or the TTL expires. Used when the user explicitly +// runs `kcd pair ` for a device with no active connection. func (d *Device) RequestPairDial() { d.pairDialRequested.Store(true) + d.pairIntentUntil.Store(time.Now().Add(pairDialIntentTTL).UnixNano()) } // ConsumePairDial reports and clears a pending explicit pair-dial request. @@ -376,6 +400,50 @@ func (d *Device) PairDialPending() bool { return d.pairDialRequested.Load() } +// PairDialActive reports whether an explicit pair intent is still keeping +// the connection alive: requested and neither cleared nor expired. Unlike +// PairDialPending (the one-shot dial trigger, consumed on first sighting), +// this survives dial/connect cycles until pairing starts or ends. +func (d *Device) PairDialActive() bool { + return d.pairIntentUntil.Load() > time.Now().UnixNano() +} + +// ClearPairDial drops both the one-shot trigger and the keep-alive intent. +// Call it when pairing completes, is rejected/cancelled, or is unpaired. +func (d *Device) ClearPairDial() { + d.pairDialRequested.Store(false) + d.pairIntentUntil.Store(0) +} + +// LastPort returns the last authenticated tcpPort advertised by the peer, +// or 0 if unknown. +func (d *Device) LastPort() int { + d.mu.RLock() + defer d.mu.RUnlock() + return d.lastPort +} + +// SetLastPort records the peer's advertised listening port after a +// successful authenticated exchange. Callers must pass a validated port. +func (d *Device) SetLastPort(port int) { + d.mu.Lock() + defer d.mu.Unlock() + d.lastPort = port +} + +// ShouldDiscoveryDial reports whether enough time has passed since the last +// discovery-triggered dial for this device, and marks this dial if so. It +// bounds redial storms to a stale LastIP (DHCP roam) or spoofed sightings. +func (d *Device) ShouldDiscoveryDial(minInterval time.Duration) bool { + d.mu.Lock() + defer d.mu.Unlock() + if time.Since(d.lastDiscoveryDial) < minInterval { + return false + } + d.lastDiscoveryDial = time.Now() + return true +} + // MarkEphemeralDialed records that the one ephemeral discovery dial for the // current unpaired era has been used. func (d *Device) MarkEphemeralDialed() { diff --git a/internal/plugins/pair/pair.go b/internal/plugins/pair/pair.go index 9418edf..dee2cff 100644 --- a/internal/plugins/pair/pair.go +++ b/internal/plugins/pair/pair.go @@ -174,6 +174,7 @@ func (p *PairPlugin) handleUnpairRequest(_ context.Context, dev *device.Device) p.logger.Info("pair request rejected by peer", zap.String("device_id", dev.ID())) dev.SetState(device.StateUnpaired) dev.ClearEphemeral() + dev.ClearPairDial() p.emit(events.TypePairRejected, dev, "") case device.StatePairRequestedByPeer: @@ -181,6 +182,7 @@ func (p *PairPlugin) handleUnpairRequest(_ context.Context, dev *device.Device) p.logger.Info("pair request cancelled by peer", zap.String("device_id", dev.ID())) dev.SetState(device.StateUnpaired) dev.ClearEphemeral() + dev.ClearPairDial() p.emit(events.TypePairRejected, dev, "") case device.StatePaired: @@ -188,6 +190,7 @@ func (p *PairPlugin) handleUnpairRequest(_ context.Context, dev *device.Device) p.logger.Info("unpair request received", zap.String("device_id", dev.ID())) dev.SetState(device.StateUnpaired) dev.ClearEphemeral() + dev.ClearPairDial() case device.StateUnpaired, device.StateUnknown: // Already unpaired, ignore @@ -217,6 +220,7 @@ func (p *PairPlugin) AcceptPairing(dev *device.Device) error { p.logger.Error("failed to send pair accept", zap.Error(err)) dev.SetState(device.StateUnpaired) dev.ClearEphemeral() + dev.ClearPairDial() return err } @@ -282,6 +286,7 @@ func (p *PairPlugin) RejectPairing(dev *device.Device) error { dev.Send(pkt) // best effort dev.SetState(device.StateUnpaired) dev.ClearEphemeral() + dev.ClearPairDial() p.mu.Lock() delete(p.pairingTimestamp, dev.ID()) @@ -305,6 +310,7 @@ func (p *PairPlugin) Unpair(dev *device.Device) error { dev.Send(pkt) // best effort dev.SetState(device.StateUnpaired) dev.ClearEphemeral() + dev.ClearPairDial() p.mu.Lock() delete(p.pairingTimestamp, dev.ID()) @@ -320,6 +326,7 @@ func (p *PairPlugin) Unpair(dev *device.Device) error { func (p *PairPlugin) pairingDone(dev *device.Device) { dev.SetState(device.StatePaired) + dev.ClearPairDial() p.mu.Lock() delete(p.pairingTimestamp, dev.ID()) diff --git a/internal/protocol/identity.go b/internal/protocol/identity.go index e037746..2a607f7 100644 --- a/internal/protocol/identity.go +++ b/internal/protocol/identity.go @@ -3,6 +3,7 @@ package protocol import ( "regexp" "strings" + "unicode" "unicode/utf8" ) @@ -13,7 +14,11 @@ const ProtocolVersion = 8 // TypeIdentity is the packet type for identity exchange. const TypeIdentity = "kdeconnect.identity" -var invalidNameChars = regexp.MustCompile(`['";:.!?()\[\]<>]`) +// invalidNameChars strips shell metacharacters and markup that could be +// abused via copy-paste, unquoted shell expansion, or log/terminal rendering. +// Control and format characters (NUL, newlines, ANSI, bidi overrides) are +// dropped separately by stripControlRunes below. +var invalidNameChars = regexp.MustCompile("[`'\";:.!?()\\[\\]<>$&|\\\\*~#%^{}=/]") const MaxDeviceNameLength = 32 @@ -26,6 +31,7 @@ const MaxDeviceNameLength = 32 // Truncation is rune-aware so multi-byte characters are never split. func SanitizeDeviceName(name string) string { clean := DecodeDeviceName(name) + clean = stripControlRunes(clean) clean = invalidNameChars.ReplaceAllString(clean, "") if len(clean) > MaxDeviceNameLength { clean = truncateRunes(clean, MaxDeviceNameLength) @@ -33,6 +39,38 @@ func SanitizeDeviceName(name string) string { return clean } +// stripControlRunes drops Unicode control (Cc, incl. NUL, CR, LF, ESC) and +// format (Cf, incl. bidi overrides U+202A-U+202E, U+2066-U+2069) runes. +// These are invisible, break C-backed consumers (NUL truncation), enable +// log/terminal forgery (newlines, ESC sequences), and visual spoofing +// (bidi overrides) — none are legitimate in a display name. +func stripControlRunes(s string) string { + return strings.Map(func(r rune) rune { + if unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r) { + return -1 + } + return r + }, s) +} + +// ansiEscape matches terminal escape sequences: CSI (ESC [ ... letter), +// OSC (ESC ] ... BEL or ESC \), and lone ESC / two-byte sequences. +var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b\\][^\x07\x1b]*(?:\x07|\x1b\\\\)|\x1b[@-_]") + +// DisplayName renders a stored device name for terminal or notification +// sinks: ANSI escapes are removed and CR/LF/TAB become spaces so a hostile +// name can't forge log lines, break CLI tables, or inject terminal codes. +// Storage and JSON keep the sanitized (not display) form. +func DisplayName(name string) string { + s := ansiEscape.ReplaceAllString(name, "") + return strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' || r == '\t' { + return ' ' + } + return r + }, s) +} + // decimalEscape matches a backslash followed by exactly three decimal // digits, e.g. "\195". Some senders emit non-ASCII name bytes this way // ("Caf\195\169" for "Café") instead of raw UTF-8. diff --git a/internal/protocol/identity_test.go b/internal/protocol/identity_test.go index c76eedc..3812476 100644 --- a/internal/protocol/identity_test.go +++ b/internal/protocol/identity_test.go @@ -38,6 +38,39 @@ func TestSanitizeDeviceName(t *testing.T) { if got := SanitizeDeviceName("Phone (Work); rm -rf"); got != "Phone Work rm -rf" { t.Errorf("injection chars not stripped: got %q", got) } + // Shell metacharacters must not survive sanitization. + for _, tc := range []struct{ in, want string }{ + {"`id`", "id"}, + {"${HOME}", "HOME"}, + {"$(whoami)", "whoami"}, + {"a|cat /etc/passwd", "acat etcpasswd"}, + {"a & rm", "a rm"}, + {`back\slash`, "backslash"}, + {"a*b?c", "abc"}, + } { + if got := SanitizeDeviceName(tc.in); got != tc.want { + t.Errorf("SanitizeDeviceName(%q) = %q, want %q", tc.in, got, tc.want) + } + } + // Control characters (NUL, newlines, ESC, bidi overrides) are dropped. + for _, in := range []string{ + "A\x00B", + "Phone\nFAKE-INFO", + "Phone\r\nspam", + "\x1b]0;pwned\x07Phone", + "ab\u202ec routine", + "tab\there", + } { + got := SanitizeDeviceName(in) + for _, r := range got { + if r == 0 || r == '\n' || r == '\r' || r == '\x1b' || r == '\u202e' || r == '\t' { + t.Errorf("SanitizeDeviceName(%q) keeps control rune: got %q", in, got) + } + } + if !utf8.ValidString(got) { + t.Errorf("SanitizeDeviceName(%q) invalid UTF-8: %q", in, got) + } + } // Multibyte truncation must never split a rune nor exceed the cap. long := strings.Repeat("Ö", 40) // 80 bytes got := SanitizeDeviceName(long) @@ -51,3 +84,21 @@ func TestSanitizeDeviceName(t *testing.T) { t.Errorf("expected 16 Ö runes (32 bytes), got %d runes", n) } } + +func TestDisplayName(t *testing.T) { + cases := []struct{ in, want string }{ + {"Pixel 8 Pro", "Pixel 8 Pro"}, + {"Café", "Café"}, + {"A\nB", "A B"}, + {"A\r\nB", "A B"}, + {"A\tB", "A B"}, + {"\x1b[2JPhone", "Phone"}, + {"\x1b[31mRed\x1b[0m", "Red"}, + {"\x1b]0;title\x07Phone", "Phone"}, + } + for _, tc := range cases { + if got := DisplayName(tc.in); got != tc.want { + t.Errorf("DisplayName(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} From 4774ae69a8f415b20860ab44bdf9c54e366950fc Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:39:41 +0300 Subject: [PATCH 09/25] fix(mpris): only stamp album art onto the track that requested it receiveAlbumArt could complete after a track change and publish the old cover against the new track. Guard the attribution on matching art URL and player; bytes stay cached for the new track's own request. --- internal/plugins/mpris/mpris.go | 24 ++++++--- internal/plugins/mpris/mpris_test.go | 78 ++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/internal/plugins/mpris/mpris.go b/internal/plugins/mpris/mpris.go index da81649..de6e3dd 100644 --- a/internal/plugins/mpris/mpris.go +++ b/internal/plugins/mpris/mpris.go @@ -610,15 +610,27 @@ func (p *MPRISPlugin) receiveAlbumArt(_ context.Context, dev device.Sender, play return } + p.stampAlbumArt(dev.ID(), player, artUrl, fileURL) +} + +// stampAlbumArt publishes a resolved file:// URL for a completed album-art +// fetch — but only if the device's current track still wants exactly this +// art. The side-channel download can take up to 30s, during which the track +// (or the active player) may have changed; stamping blindly would show the +// old track's cover on the new track. On mismatch the bytes stay in the art +// cache and the new track's own art request fulfills it. +func (p *MPRISPlugin) stampAlbumArt(deviceID, player, artUrl, fileURL string) { p.mu.Lock() - state := p.remoteStates[dev.ID()] - if state != nil { - state = state.DeepCopy() - state.AlbumArtUrl = fileURL + state := p.remoteStates[deviceID] + if state == nil || state.AlbumArtUrl != artUrl || state.Player != player { + p.mu.Unlock() + return } + state = state.DeepCopy() + state.AlbumArtUrl = fileURL p.mu.Unlock() - if state != nil && p.bus != nil { - p.bus.Publish(events.TypeMprisUpdate, dev.ID(), state) + if p.bus != nil { + p.bus.Publish(events.TypeMprisUpdate, deviceID, state) } } diff --git a/internal/plugins/mpris/mpris_test.go b/internal/plugins/mpris/mpris_test.go index e6bb35b..7b7d284 100644 --- a/internal/plugins/mpris/mpris_test.go +++ b/internal/plugins/mpris/mpris_test.go @@ -207,6 +207,84 @@ func TestHandleIgnoresEmptyAlbumArtPayload(t *testing.T) { time.Sleep(50 * time.Millisecond) // let any goroutine finish } +func TestStampAlbumArtMatchesCurrentTrack(t *testing.T) { + bus := events.NewBus(zap.NewNop()) + sub := bus.Subscribe(4, events.TypeMprisUpdate) + defer sub.Close() + + plugin := NewMPRISPlugin(nil, bus, false, zap.NewNop()) + if plugin.watchCancel != nil { + defer plugin.watchCancel() + } + + const urlA = "kdeconnect:/artUri?title=Song+A&kdeArtHash=111" + const urlB = "kdeconnect:/artUri?title=Song+B&kdeArtHash=222" + + seed := func(player, title, art string) { + plugin.mu.Lock() + plugin.remoteStates["dev-art-race"] = &NowPlaying{Player: player, Title: title, AlbumArtUrl: art} + plugin.mu.Unlock() + } + storedArt := func() string { + plugin.mu.Lock() + defer plugin.mu.Unlock() + return plugin.remoteStates["dev-art-race"].AlbumArtUrl + } + + // Positive: art arrives for the track still current — publish file:// + // against that track's metadata. + seed("Spotify", "Song A", urlA) + plugin.stampAlbumArt("dev-art-race", "Spotify", urlA, "file:///cache/a.jpg") + select { + case ev := <-sub.C: + got, ok := ev.Payload.(*NowPlaying) + if !ok { + t.Fatalf("expected *NowPlaying payload, got %T", ev.Payload) + } + if got.Title != "Song A" || got.AlbumArtUrl != "file:///cache/a.jpg" { + t.Fatalf("wrong attribution: %+v", got) + } + case <-time.After(time.Second): + t.Fatal("expected mpris update for matching art") + } + if got := storedArt(); got != urlA { + t.Fatalf("stored state must keep the kdeconnect: URL, got %q", got) + } + + // Negative: track advanced to B while A's fetch was in flight — drop + // A's bytes (the cache keeps them) and publish nothing. + seed("Spotify", "Song B", urlB) + plugin.stampAlbumArt("dev-art-race", "Spotify", urlA, "file:///cache/a.jpg") + select { + case ev := <-sub.C: + t.Fatalf("stale art must not publish, got %#v", ev) + case <-time.After(50 * time.Millisecond): + } + if got := storedArt(); got != urlB { + t.Fatalf("B's art URL must be untouched, got %q", got) + } + + // Negative: same art URL but a different player took over — drop. + seed("YouTube", "Song A", urlA) + plugin.stampAlbumArt("dev-art-race", "Spotify", urlA, "file:///cache/a.jpg") + select { + case ev := <-sub.C: + t.Fatalf("other-player art must not publish, got %#v", ev) + case <-time.After(50 * time.Millisecond): + } + + // Negative: no state at all — drop without panicking. + plugin.mu.Lock() + delete(plugin.remoteStates, "dev-art-race") + plugin.mu.Unlock() + plugin.stampAlbumArt("dev-art-race", "Spotify", urlA, "file:///cache/a.jpg") + select { + case ev := <-sub.C: + t.Fatalf("art with no state must not publish, got %#v", ev) + case <-time.After(50 * time.Millisecond): + } +} + func TestPollRemoteStatesOnlyTargetsKnownPlayers(t *testing.T) { plugin := NewMPRISPlugin(nil, events.NewBus(zap.NewNop()), false, zap.NewNop()) if plugin.watchCancel != nil { From 620246f6a825baeb0567393c4561be30ecce3677 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:40:57 +0300 Subject: [PATCH 10/25] fix(plugins): cap SMS downloads, sanitize icon IDs, end option parsing Bound MMS attachment streams with LimitReader and a 50MB cap. Restrict notification IDs to filename-safe characters and confine icon cache paths to the cache dir. Pass -- to notify-send and wtype so remote text can't be misparsed as flags, and harden SMS attachment filenames. --- internal/plugins/mousepad/mousepad.go | 4 +- internal/plugins/notification/notification.go | 34 +++++++- .../plugins/notification/notification_test.go | 81 ++++++++++++++++++- internal/plugins/sms/sms.go | 36 +++++++-- internal/plugins/sms/sms_test.go | 43 ++++++++++ 5 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 internal/plugins/sms/sms_test.go diff --git a/internal/plugins/mousepad/mousepad.go b/internal/plugins/mousepad/mousepad.go index f113816..a7b7388 100644 --- a/internal/plugins/mousepad/mousepad.go +++ b/internal/plugins/mousepad/mousepad.go @@ -304,8 +304,10 @@ func (p *MousepadPlugin) handleEvent(body MousepadBody) { } else if body.Key != "" { // Unicode strings cannot be typed via uinput directly. // Use display-server tool for text input regardless of uinput backend. + // "--" ends option parsing (supported by both wtype and xdotool) + // so phone-provided text starting with '-' can't be misparsed. if p.isWayland { - p.runCmd("wtype", body.Key) + p.runCmd("wtype", "--", body.Key) } else { p.runCmd("xdotool", "type", "--", body.Key) } diff --git a/internal/plugins/notification/notification.go b/internal/plugins/notification/notification.go index 991481f..1154b8d 100644 --- a/internal/plugins/notification/notification.go +++ b/internal/plugins/notification/notification.go @@ -250,6 +250,20 @@ func (p *NotificationPlugin) Handle(ctx context.Context, dev device.Sender, pkt return nil } +// notifIDChars restricts phone-provided notification IDs to filename-safe +// characters when they're embedded in icon cache paths. +var notifIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +// sanitizeNotifID strips everything but alphanumerics, dot, underscore and +// hyphen from a notification ID so it can't escape the icon cache directory +// via path separators. Empty results fall back to "default". +func sanitizeNotifID(id string) string { + if safe := notifIDChars.ReplaceAllString(id, "_"); safe != "" { + return safe + } + return "default" +} + // notifKey scopes a notification id to its device so two paired phones with // colliding Android notification keys don't replace each other's popups. func (p *NotificationPlugin) notifKey(devID, id string) string { @@ -273,8 +287,19 @@ func (p *NotificationPlugin) fetchIcon( // Use the notification ID as the filename so the same app reuses the // cached icon rather than downloading it on every notification. + // The ID comes from the phone, so restrict it to filename-safe chars + // (no separators) — otherwise ../ in an ID would escape the icon dir. safeName := nonAlphaNumeric.ReplaceAllString(appName, "_") - iconPath := filepath.Join(p.iconDir, fmt.Sprintf("%s-%s.png", safeName, notifID)) + safeID := sanitizeNotifID(notifID) + iconPath := filepath.Join(p.iconDir, fmt.Sprintf("%s-%s.png", safeName, safeID)) + + // Belt and braces: confine the result to the icon dir even if the + // sanitizer above ever regresses. + if rel, err := filepath.Rel(p.iconDir, iconPath); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + p.logger.Warn("notification: icon path escapes cache dir, refusing", + zap.String("id", notifID)) + return "" + } // Reuse the cached icon even when the phone re-posts the notification // without an icon payload (Android only sends the bytes when the icon @@ -377,9 +402,12 @@ func (p *NotificationPlugin) sendDesktopNotification(devID, appName, id, title, } } } - args = append(args, "--print-id", title, text) + // "--" ends option parsing so a phone-provided title/body starting with + // '-' can't be misparsed as a notify-send flag (notify-send uses + // GOption, which honors the POSIX separator). + args = append(args, "--print-id", "--", title, text) } else { - args = append(args, title, text) + args = append(args, "--", title, text) } out, err := p.newExec(context.Background(), "notify-send", args...).Output() diff --git a/internal/plugins/notification/notification_test.go b/internal/plugins/notification/notification_test.go index bcf81e9..e3fbb5d 100644 --- a/internal/plugins/notification/notification_test.go +++ b/internal/plugins/notification/notification_test.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "strconv" + "strings" "sync" "testing" "time" @@ -206,9 +207,10 @@ func TestNotificationPlugin_FetchIconReusesCacheOnRepost(t *testing.T) { id := "0|com.arn.scrobble|0|com.msob7y.namida|10247" // Seed the cached icon for this (app, id) — simulates the first post - // having downloaded the phone's icon. + // having downloaded the phone's icon. The seed uses the same + // sanitization as fetchIcon. cachedPath := filepath.Join(p.iconDir, fmt.Sprintf("%s-%s.png", - nonAlphaNumeric.ReplaceAllString(app, "_"), id)) + nonAlphaNumeric.ReplaceAllString(app, "_"), sanitizeNotifID(id))) if err := os.WriteFile(cachedPath, []byte("png"), 0o600); err != nil { t.Fatal(err) } @@ -226,6 +228,81 @@ func TestNotificationPlugin_FetchIconReusesCacheOnRepost(t *testing.T) { } } +func TestSanitizeNotifID(t *testing.T) { + cases := []struct{ in, want string }{ + {"0|com.arn.scrobble|10247", "0_com.arn.scrobble_10247"}, + {"plain-id_1.2", "plain-id_1.2"}, + {"../../../etc/cron.d/job", ".._.._.._etc_cron.d_job"}, + {"/abs/path", "_abs_path"}, + {"a/b\\c", "a_b_c"}, + {"", "default"}, + } + for _, tc := range cases { + if got := sanitizeNotifID(tc.in); got != tc.want { + t.Errorf("sanitizeNotifID(%q) = %q, want %q", tc.in, got, tc.want) + } + if strings.ContainsAny(sanitizeNotifID(tc.in), `/\`) { + t.Errorf("sanitizeNotifID(%q) keeps separators", tc.in) + } + } +} + +func TestFetchIconRefusesTraversal(t *testing.T) { + p := newPlugin(t) + p.tlsConfig = &tls.Config{} + p.iconDir = t.TempDir() + + // A traversal ID must resolve inside the icon dir. With no payload and + // no cache it returns "" — assert nothing escapes to the parent. + canary := filepath.Join(filepath.Dir(p.iconDir), "kcd-traversal-canary.png") + _ = os.Remove(canary) + traversal := "../" + filepath.Base(canary) + if got := p.fetchIcon(context.Background(), "App", traversal, nil, 0, 0, false); got != "" { + t.Fatalf("expected empty icon for traversal id, got %q", got) + } + if _, err := os.Stat(canary); !os.IsNotExist(err) { + t.Fatalf("traversal escaped icon dir: %s exists (%v)", canary, err) + } +} + +func TestNotifySendEndsOptions(t *testing.T) { + p, f := newFakePlugin(t, true) + dev := device.NewDevice("dev1", "Test", "phone", zaptest.NewLogger(t)) + + // A title starting with '-' must be passed after "--" so notify-send + // can't misparse it as a flag. + body := NotificationBody{ID: "dash", AppName: "App", Title: "-u", Text: "-t evil"} + pkt, _ := protocol.NewPacket("kdeconnect.notification", body) + if err := p.Handle(context.Background(), dev, pkt); err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) // sendDesktopNotification runs async + if len(f.calls) == 0 { + t.Fatal("expected notify-send invocation") + } + call := f.calls[len(f.calls)-1] + sep := -1 + for i, a := range call { + if a == "--" { + sep = i + } + } + if sep < 0 { + t.Fatalf("expected -- separator in notify-send args: %q", call) + } + for _, want := range []string{"-u", "-t evil"} { + found := false + for _, a := range call[sep+1:] { + if a == want { + found = true + } + } + if !found { + t.Errorf("expected %q after -- in %q", want, call) + } + } +} + func TestNotificationPlugin_ShowIconsGating(t *testing.T) { dev := device.NewDevice("dev1", "Test", "phone", zaptest.NewLogger(t)) diff --git a/internal/plugins/sms/sms.go b/internal/plugins/sms/sms.go index afda7f6..50280ed 100644 --- a/internal/plugins/sms/sms.go +++ b/internal/plugins/sms/sms.go @@ -10,7 +10,9 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" + "unicode" "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/device" @@ -29,6 +31,11 @@ const ( PacketTypeSMSAttachmentFile = "kdeconnect.sms.attachment_file" maxSMSMessages = 1000 // safety limit to prevent OOM from malicious payload + + // maxSMSAttachmentBytes caps a single MMS attachment download (mirrors + // the clipboard 50MB safety limit). Anything larger is refused before + // any bytes hit disk. + maxSMSAttachmentBytes = 50 * 1024 * 1024 ) // SMSPlugin implements SMS sending, receiving, conversation browsing, and MMS @@ -241,8 +248,13 @@ func (p *SMSPlugin) handleAttachmentFile(ctx context.Context, dev device.Sender, } // receiveAttachment connects to the phone's side-channel port and downloads -// the attachment file over TLS. -func (p *SMSPlugin) receiveAttachment(ctx context.Context, ip net.IP, port int, _ int64, destPath string) error { +// the attachment file over TLS. The stream is capped at the declared +// payload size (itself bounded by maxSMSAttachmentBytes) so a malicious +// peer can't fill the disk with an unbounded stream. +func (p *SMSPlugin) receiveAttachment(ctx context.Context, ip net.IP, port int, size int64, destPath string) error { + if size <= 0 || size > maxSMSAttachmentBytes { + return fmt.Errorf("sms: refusing attachment with invalid size %d (limit %d)", size, maxSMSAttachmentBytes) + } addr := net.JoinHostPort(ip.String(), strconv.Itoa(port)) dialer := &tls.Dialer{ @@ -264,7 +276,7 @@ func (p *SMSPlugin) receiveAttachment(ctx context.Context, ip net.IP, port int, } defer f.Close() - _, err = io.Copy(f, conn) + _, err = io.Copy(f, io.LimitReader(conn, size)) if err != nil { return fmt.Errorf("sms: receive attachment data: %w", err) } @@ -330,11 +342,25 @@ func (p *SMSPlugin) RequestAttachment(dev device.Sender, partID int64, uniqueIde return dev.Send(pkt) } +// maxFilenameLength caps attachment filenames to keep them manageable. +const maxFilenameLength = 128 + // cleanFilename strips path components to prevent directory traversal in -// attachment file paths. +// attachment file paths. Backslashes are normalized first (Windows-style +// paths), control characters dropped, and overlong names truncated. func cleanFilename(name string) string { + name = strings.ReplaceAll(name, "\\", "/") name = filepath.Base(name) - if name == "." || name == ".." || name == "" { + name = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, name) + if len(name) > maxFilenameLength { + name = strings.ToValidUTF8(name[:maxFilenameLength], "") + } + if name == "." || name == ".." || name == "/" || name == "" { return "downloaded_attachment" } return name diff --git a/internal/plugins/sms/sms_test.go b/internal/plugins/sms/sms_test.go new file mode 100644 index 0000000..9fac236 --- /dev/null +++ b/internal/plugins/sms/sms_test.go @@ -0,0 +1,43 @@ +package sms + +import ( + "context" + "strings" + "testing" + + "github.com/bethropolis/kcd/internal/config" + "go.uber.org/zap" +) + +func TestCleanFilename(t *testing.T) { + cases := []struct{ in, want string }{ + {"photo.jpg", "photo.jpg"}, + {"../../etc/passwd", "passwd"}, + {"/etc/passwd", "passwd"}, + {`..\..\evil.jpg`, "evil.jpg"}, + {`a\b\c.png`, "c.png"}, + {"", "downloaded_attachment"}, + {".", "downloaded_attachment"}, + {"..", "downloaded_attachment"}, + {"a\x00b.jpg", "ab.jpg"}, + {"a\nb.jpg", "ab.jpg"}, + } + for _, tc := range cases { + if got := cleanFilename(tc.in); got != tc.want { + t.Errorf("cleanFilename(%q) = %q, want %q", tc.in, got, tc.want) + } + } + if got := cleanFilename(strings.Repeat("a", 200) + ".jpg"); len(got) > maxFilenameLength+4 { + t.Errorf("long filename not truncated: %d bytes", len(got)) + } +} + +func TestReceiveAttachmentRejectsBadSize(t *testing.T) { + p := NewSMSPlugin(config.SMSConfig{}, nil, nil, zap.NewNop()) + for _, size := range []int64{0, -1, -999, maxSMSAttachmentBytes + 1} { + err := p.receiveAttachment(context.Background(), nil, 0, size, "/nonexistent/x") + if err == nil { + t.Errorf("receiveAttachment(size=%d) = nil, want error", size) + } + } +} From ea1c71e606e39664896e01aa91c0e85995532392 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:40:57 +0300 Subject: [PATCH 11/25] fix(plugins): validate sshfs args, lock xdg-open to http(s), block executable auto-open Strict user/host/port/path validation for the sshfs argv so remote values can't be reinterpreted as ssh options. Only http/https URLs reach xdg-open; anything else is logged and skipped. Never auto-open executable file types (auto_open stays off by default). --- internal/plugins/sftp/sftp.go | 92 ++++++++++++++++++------- internal/plugins/sftp/sftp_test.go | 70 +++++++++++++++++++ internal/plugins/share/sanitize.go | 34 +++++++++ internal/plugins/share/sanitize_test.go | 55 +++++++++++++++ internal/plugins/share/share.go | 35 +++++++--- 5 files changed, 253 insertions(+), 33 deletions(-) diff --git a/internal/plugins/sftp/sftp.go b/internal/plugins/sftp/sftp.go index 3080ce7..cee18b7 100644 --- a/internal/plugins/sftp/sftp.go +++ b/internal/plugins/sftp/sftp.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "fmt" + "net" "os" "os/exec" "path/filepath" + "regexp" "strconv" "strings" "sync" @@ -269,6 +271,68 @@ func (p *SftpPlugin) MountLocally(ctx context.Context, deviceID string) (string, return p.mountWithBody(ctx, deviceID, body, "") } +// sshUserPattern allows the generated Android SFTP usernames (alphanumerics, +// underscore, dot, hyphen) while rejecting anything starting with '-' — +// sshfs would parse that as an option flag (e.g. -oProxyCommand=...), +// yielding local command execution. +var sshUserPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]*$`) + +// sshHostPattern allows IPs (validated separately) and plain hostnames +// (.local, LAN names). Anything else — flags, spaces, shell metachars, +// userinfo (@) — is rejected. +var sshHostPattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$`) + +// buildSSHFSArgs validates the phone-provided (or IPC-provided) remote +// parameters and constructs the sshfs argv. Validation (not a "--" +// separator — unsupported by older sshfs 2.x) is what prevents option +// injection: no validated value can begin with '-', so sshfs/fuse option +// parsing can never reinterpret remoteRoot as a flag like -oProxyCommand. +func buildSSHFSArgs(body SftpBody, remotePath, mountPoint string, uid, gid int, keepaliveInterval, keepaliveCount int, extraOpts []string) ([]string, error) { + if !sshUserPattern.MatchString(body.User) || len(body.User) > 64 { + return nil, fmt.Errorf("sftp: refusing suspicious ssh user %q", body.User) + } + if body.IP == "" || strings.HasPrefix(body.IP, "-") { + return nil, fmt.Errorf("sftp: refusing suspicious ssh host %q", body.IP) + } + if net.ParseIP(body.IP) == nil && (!sshHostPattern.MatchString(body.IP) || len(body.IP) > 253) { + return nil, fmt.Errorf("sftp: refusing invalid ssh host %q", body.IP) + } + port, err := strconv.Atoi(strings.TrimSpace(body.Port.String())) + if err != nil || port < 1 || port > 65535 { + return nil, fmt.Errorf("sftp: refusing invalid ssh port %q", body.Port.String()) + } + if remotePath == "" || strings.HasPrefix(remotePath, "-") { + return nil, fmt.Errorf("sftp: refusing suspicious remote path %q", remotePath) + } + remotePath = filepath.Clean(remotePath) + remoteRoot := fmt.Sprintf("%s@%s:%s", body.User, body.IP, remotePath) + + args := []string{ + remoteRoot, + mountPoint, + "-p", strconv.Itoa(port), + "-s", + "-F", "/dev/null", + "-o", "password_stdin", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "reconnect", + "-o", "ServerAliveInterval=" + strconv.Itoa(keepaliveInterval), + "-o", "ServerAliveCountMax=" + strconv.Itoa(keepaliveCount), + "-o", "auto_cache", + "-o", "kernel_cache", + "-o", "uid=" + strconv.Itoa(uid), + "-o", "gid=" + strconv.Itoa(gid), + } + + // ExtraSshfsOpts comes from the local operator config, not the phone — + // passed through as-is. + for _, opt := range extraOpts { + args = append(args, "-o", opt) + } + return args, nil +} + // mountWithBody performs the sshfs mount and returns the local browse path. // volumePath specifies which storage volume to mount. If empty, the first // available volume is selected automatically. @@ -297,30 +361,10 @@ func (p *SftpPlugin) mountWithBody(ctx context.Context, deviceID string, body Sf remotePath = body.Path } } - remoteRoot := fmt.Sprintf("%s@%s:%s", body.User, body.IP, remotePath) - - args := []string{ - remoteRoot, - mountPoint, - "-p", body.Port.String(), - "-s", - "-F", "/dev/null", - "-o", "password_stdin", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "reconnect", - "-o", "ServerAliveInterval=" + strconv.Itoa(p.cfg.KeepaliveIntervalSecs), - "-o", "ServerAliveCountMax=" + strconv.Itoa(p.cfg.KeepaliveCount), - "-o", "auto_cache", - "-o", "kernel_cache", - "-o", "uid=" + strconv.Itoa(os.Getuid()), - "-o", "gid=" + strconv.Itoa(os.Getgid()), - } - - if len(p.cfg.ExtraSshfsOpts) > 0 { - for _, opt := range p.cfg.ExtraSshfsOpts { - args = append(args, "-o", opt) - } + args, err := buildSSHFSArgs(body, remotePath, mountPoint, os.Getuid(), os.Getgid(), p.cfg.KeepaliveIntervalSecs, p.cfg.KeepaliveCount, p.cfg.ExtraSshfsOpts) + if err != nil { + _ = os.Remove(mountPoint) + return "", err } cmd := exec.CommandContext(ctx, "sshfs", args...) diff --git a/internal/plugins/sftp/sftp_test.go b/internal/plugins/sftp/sftp_test.go index f514d76..c581ef8 100644 --- a/internal/plugins/sftp/sftp_test.go +++ b/internal/plugins/sftp/sftp_test.go @@ -91,3 +91,73 @@ func TestRemoteRoot_PathIsSlash(t *testing.T) { t.Errorf("remoteRoot = %q, want %q", got, want) } } + +func validBody() SftpBody { + return SftpBody{ + IP: "192.168.1.42", + Port: "1776", + User: "u0_a123", + Password: "sekret", + Path: "/storage/emulated/0", + } +} + +func TestBuildSSHFSArgs_Valid(t *testing.T) { + args, err := buildSSHFSArgs(validBody(), "/storage/emulated/0", "/mnt/kcd", 1000, 1000, 15, 3, nil) + if err != nil { + t.Fatalf("buildSSHFSArgs returned error: %v", err) + } + if len(args) < 3 || args[0] != "u0_a123@192.168.1.42:/storage/emulated/0" || args[1] != "/mnt/kcd" { + t.Fatalf("unexpected remote/mount args: %q", args) + } + for i, a := range args { + if i > 1 && a == "1776" { + break + } + if i == len(args)-1 { + t.Errorf("validated port missing from args: %q", args) + } + } + for _, a := range args { + if a == "-oProxyCommand=x" { + t.Errorf("injected option leaked into args: %q", args) + } + } +} + +func TestBuildSSHFSArgs_RejectsInjection(t *testing.T) { + cases := []struct { + name string + body SftpBody + path string + }{ + {"user flag", SftpBody{IP: "192.168.1.42", Port: "22", User: "-oProxyCommand=evil", Path: "/x"}, "/x"}, + {"user lone dash", SftpBody{IP: "192.168.1.42", Port: "22", User: "-", Path: "/x"}, "/x"}, + {"empty user", SftpBody{IP: "192.168.1.42", Port: "22", Path: "/x"}, "/x"}, + {"host flag", SftpBody{IP: "-oFoo", Port: "22", User: "u", Path: "/x"}, "/x"}, + {"host spaces", SftpBody{IP: "a b", Port: "22", User: "u", Path: "/x"}, "/x"}, + {"host at", SftpBody{IP: "a@b", Port: "22", User: "u", Path: "/x"}, "/x"}, + {"port zero", SftpBody{IP: "192.168.1.42", Port: "0", User: "u", Path: "/x"}, "/x"}, + {"port huge", SftpBody{IP: "192.168.1.42", Port: "99999", User: "u", Path: "/x"}, "/x"}, + {"port alpha", SftpBody{IP: "192.168.1.42", Port: "abc", User: "u", Path: "/x"}, "/x"}, + {"path flag", SftpBody{IP: "192.168.1.42", Port: "22", User: "u", Path: "/x"}, "-oFoo"}, + {"empty path", SftpBody{IP: "192.168.1.42", Port: "22", User: "u", Path: "/x"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := buildSSHFSArgs(tc.body, tc.path, "/mnt/kcd", 1000, 1000, 15, 3, nil); err == nil { + t.Errorf("buildSSHFSArgs accepted %q / %q, want error", tc.body, tc.path) + } + }) + } +} + +func TestBuildSSHFSArgs_Hostnames(t *testing.T) { + for _, host := range []string{"phone.local", "android-1", "192.168.1.42", "::1"} { + body := validBody() + body.IP = host + if _, err := buildSSHFSArgs(body, "/x", "/mnt/kcd", 1000, 1000, 15, 3, nil); err != nil { + t.Errorf("buildSSHFSArgs(%q) = %v, want nil", host, err) + } + } +} diff --git a/internal/plugins/share/sanitize.go b/internal/plugins/share/sanitize.go index 5ed8eff..adb2864 100644 --- a/internal/plugins/share/sanitize.go +++ b/internal/plugins/share/sanitize.go @@ -2,6 +2,7 @@ package share import ( "fmt" + "net/url" "os" "path/filepath" "strings" @@ -28,6 +29,39 @@ func SanitizeFilename(name string) string { return name } +// isOpenableURL reports whether a phone-shared URL may be handed to +// xdg-open. Only http(s) with a host qualifies: other schemes (file://, +// smb:, mailto:, custom app handlers) would dispatch phone-influenced input +// to unrelated local programs. +func isOpenableURL(raw string) bool { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return false + } + if u.Scheme != "http" && u.Scheme != "https" { + return false + } + return u.Host != "" +} + +// blockedAutoOpenExts are file types that must never be auto-opened after +// download. Handing them to the desktop handler can execute code (notably +// .desktop files, which most environments treat as launchers). +var blockedAutoOpenExts = map[string]struct{}{ + ".desktop": {}, ".sh": {}, ".bin": {}, ".run": {}, ".jar": {}, + ".py": {}, ".pl": {}, ".rb": {}, ".php": {}, ".js": {}, + ".exe": {}, ".msi": {}, ".bat": {}, ".cmd": {}, ".com": {}, + ".scr": {}, ".ps1": {}, ".vbs": {}, ".vbe": {}, ".jse": {}, + ".wsf": {}, ".wsh": {}, ".hta": {}, ".lnk": {}, ".gadget": {}, +} + +// autoOpenBlocked reports whether a downloaded file must be excluded from +// auto-open because of its extension. +func autoOpenBlocked(path string) bool { + _, blocked := blockedAutoOpenExts[strings.ToLower(filepath.Ext(path))] + return blocked +} + // EnsureUnique finds a non-conflicting filename in the destination directory. func EnsureUnique(dir, name string) (string, error) { ext := filepath.Ext(name) diff --git a/internal/plugins/share/sanitize_test.go b/internal/plugins/share/sanitize_test.go index d471f92..7239a03 100644 --- a/internal/plugins/share/sanitize_test.go +++ b/internal/plugins/share/sanitize_test.go @@ -28,6 +28,61 @@ func TestSanitizeFilename(t *testing.T) { } } +func TestIsOpenableURL(t *testing.T) { + open := []string{ + "https://example.com", + "http://example.com/path?q=1", + "HTTPS://EXAMPLE.COM", + " https://example.com/pad ", + } + for _, u := range open { + if !isOpenableURL(u) { + t.Errorf("isOpenableURL(%q) = false, want true", u) + } + } + blocked := []string{ + "", + "file:///etc/passwd", + "ftp://example.com/x", + "smb://server/share", + "mailto:foo@example.com", + "javascript:alert(1)", + "data:text/html,hi", + "kdeconnect:/something", + "myapp://action", + "http://", + "https://", + "example.com/no-scheme", + "/just/a/path", + "http:noslashes", + } + for _, u := range blocked { + if isOpenableURL(u) { + t.Errorf("isOpenableURL(%q) = true, want false", u) + } + } +} + +func TestAutoOpenBlocked(t *testing.T) { + blocked := []string{ + "evil.desktop", "run.sh", "payload.bin", "setup.run", "app.jar", + "x.py", "X.PY", "s.pl", "r.rb", "p.php", "a.exe", "b.msi", + "c.bat", "d.cmd", "e.com", "f.scr", "g.ps1", "h.vbs", "i.lnk", + "EVIL.Desktop", + } + for _, n := range blocked { + if !autoOpenBlocked(n) { + t.Errorf("autoOpenBlocked(%q) = false, want true", n) + } + } + allowed := []string{"photo.jpg", "song.mp3", "doc.pdf", "movie.mp4", "notes.txt", "archive.zip", "noext"} + for _, n := range allowed { + if autoOpenBlocked(n) { + t.Errorf("autoOpenBlocked(%q) = true, want false", n) + } + } +} + func TestEnsureUnique(t *testing.T) { dir := t.TempDir() name := "test.txt" diff --git a/internal/plugins/share/share.go b/internal/plugins/share/share.go index 00ad040..8b26da8 100644 --- a/internal/plugins/share/share.go +++ b/internal/plugins/share/share.go @@ -134,7 +134,16 @@ func (p *SharePlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc if p.bus != nil { p.bus.Publish(events.TypeShareURL, dev.ID(), map[string]string{"url": body.Url}) } - plugin.RunCommandAsync(p.Logger, "xdg-open", body.Url) + // xdg-open dispatches on URI scheme to arbitrary desktop handlers, + // so only http(s) may reach it: file://, smb:, mailto: and custom + // app schemes would hand phone-influenced input to unrelated local + // handlers. The URL event above still reaches clients either way. + if isOpenableURL(body.Url) { + plugin.RunCommandAsync(p.Logger, "xdg-open", body.Url) + } else { + p.Logger.Warn("share: refusing to open non-http(s) URL", + zap.String("url", body.Url)) + } return nil } @@ -198,15 +207,23 @@ func (p *SharePlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc }) } if p.cfg.AutoOpen { - cmd := p.cfg.OpenCommand - if cmd == "" { - cmd = "xdg-open" - } - absPath, err := filepath.Abs(destPath) - if err != nil { - absPath = destPath + // Never auto-open executable content: handing .desktop files + // (or scripts) to the desktop handler can execute code. The + // file itself is still saved and announced — open it manually. + if autoOpenBlocked(destPath) { + p.Logger.Warn("share: refusing to auto-open executable file", + zap.String("file", destPath)) + } else { + cmd := p.cfg.OpenCommand + if cmd == "" { + cmd = "xdg-open" + } + absPath, err := filepath.Abs(destPath) + if err != nil { + absPath = destPath + } + plugin.RunCommandAsync(p.Logger, cmd, absPath) } - plugin.RunCommandAsync(p.Logger, cmd, absPath) } } }() From 2b2752a6ffbebcd61b1c646709454318dc28f8ba Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:35:01 +0300 Subject: [PATCH 12/25] fix(plugins): verify side-channel peer against the paired certificate Side-channel TLS used the same trust-any-cert profile as the main link but never checked who presented it, so any holder of a cert could feed bytes to a receiver or pull files from a sender. Pin each transfer to the peer fingerprint snapshot at request time: receivers abort before writing, senders abort before streaming. Unpinned (unpaired) devices log a warning and proceed as before. --- internal/cert/cert.go | 28 ++++++ internal/cert/cert_test.go | 46 ++++++++++ internal/plugins/clipboard/clipboard.go | 15 +++- internal/plugins/mpris/mpris.go | 5 +- internal/plugins/notification/notification.go | 17 +++- .../plugins/notification/notification_test.go | 6 +- internal/plugins/share/share.go | 7 +- internal/plugins/share/share_test.go | 86 ++++++++++++++++++- internal/plugins/share/transfer.go | 36 +++++++- internal/plugins/sms/sms.go | 15 +++- internal/plugins/sms/sms_test.go | 2 +- 11 files changed, 246 insertions(+), 17 deletions(-) diff --git a/internal/cert/cert.go b/internal/cert/cert.go index 0b376b0..ce98e5a 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -16,6 +16,7 @@ import ( "fmt" "math/big" "os" + "strings" "time" ) @@ -128,6 +129,33 @@ func Fingerprint(cert *x509.Certificate) string { return hex.EncodeToString(hash[:]) } +// PinnedFingerprint returns the hex fingerprint of a pinned peer certificate, +// or "" when no certificate is available (unpaired or legacy device). Callers +// must log and proceed unverified in that case. +func PinnedFingerprint(peer *x509.Certificate) string { + if peer == nil { + return "" + } + return Fingerprint(peer) +} + +// VerifySideChannelPeer checks the certificate presented on a side-channel +// TLS connection against the fingerprint pinned when the transfer was +// requested. An empty expected fingerprint skips verification (the caller +// logs a warning) so unpaired devices keep working. +func VerifySideChannelPeer(state tls.ConnectionState, expectedFP string) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("cert: side-channel peer presented no certificate") + } + if expectedFP == "" { + return nil + } + if actual := Fingerprint(state.PeerCertificates[0]); !strings.EqualFold(actual, expectedFP) { + return fmt.Errorf("cert: side-channel peer fingerprint mismatch") + } + return nil +} + // VerificationKey generates a verification fingerprint used for out-of-band pairing verification. // It creates a SHA256 hash of the concatenated public keys to display a fingerprint. func VerificationKey(localCert, remoteCert *x509.Certificate) string { diff --git a/internal/cert/cert_test.go b/internal/cert/cert_test.go index a0a8a64..cf82f82 100644 --- a/internal/cert/cert_test.go +++ b/internal/cert/cert_test.go @@ -1,6 +1,7 @@ package cert import ( + "crypto/tls" "crypto/x509" "os" "path/filepath" @@ -77,3 +78,48 @@ func TestLoadOrGenerate(t *testing.T) { t.Error("loaded certificate mismatch with generated one") } } + +func TestPinnedFingerprint(t *testing.T) { + if got := PinnedFingerprint(nil); got != "" { + t.Errorf("expected empty fingerprint for nil cert, got %q", got) + } + + c, err := GenerateSelfSigned("pinned_fp_test") + if err != nil { + t.Fatal(err) + } + x509Cert, _ := x509.ParseCertificate(c.Certificate[0]) + if got := PinnedFingerprint(x509Cert); got != Fingerprint(x509Cert) { + t.Error("PinnedFingerprint mismatch with Fingerprint") + } +} + +func TestVerifySideChannelPeer(t *testing.T) { + c, err := GenerateSelfSigned("verify_peer_test") + if err != nil { + t.Fatal(err) + } + x509Cert, _ := x509.ParseCertificate(c.Certificate[0]) + fp := Fingerprint(x509Cert) + + other, err := GenerateSelfSigned("verify_peer_other") + if err != nil { + t.Fatal(err) + } + otherCert, _ := x509.ParseCertificate(other.Certificate[0]) + + state := tls.ConnectionState{PeerCertificates: []*x509.Certificate{x509Cert}} + + if err := VerifySideChannelPeer(state, fp); err != nil { + t.Errorf("matching fingerprint rejected: %v", err) + } + if err := VerifySideChannelPeer(state, Fingerprint(otherCert)); err == nil { + t.Error("mismatched fingerprint accepted") + } + if err := VerifySideChannelPeer(state, ""); err != nil { + t.Errorf("empty expected fingerprint should skip verification: %v", err) + } + if err := VerifySideChannelPeer(tls.ConnectionState{}, fp); err == nil { + t.Error("missing peer certificate accepted") + } +} diff --git a/internal/plugins/clipboard/clipboard.go b/internal/plugins/clipboard/clipboard.go index 4ab36eb..e779e0b 100644 --- a/internal/plugins/clipboard/clipboard.go +++ b/internal/plugins/clipboard/clipboard.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/protocol" "go.uber.org/zap" @@ -322,6 +323,7 @@ func (p *ClipboardPlugin) handleClipboardFile(ctx context.Context, dev device.Se payloadSize := pkt.PayloadSize payloadPort := pkt.PayloadTransferInfo.Port filename := body.Filename + expectedFP := cert.PinnedFingerprint(dev.PeerCert()) go func() { // Download to a temp file @@ -334,7 +336,7 @@ func (p *ClipboardPlugin) handleClipboardFile(ctx context.Context, dev device.Se tmpFile.Close() defer os.Remove(tmpPath) - if err := downloadToFile(ctx, remoteIP, payloadPort, payloadSize, tmpPath, p.tlsConfig, p.logger); err != nil { + if err := downloadToFile(ctx, remoteIP, payloadPort, payloadSize, tmpPath, p.tlsConfig, expectedFP, p.logger); err != nil { p.logger.Error("clipboard file: download failed", zap.Error(err)) return } @@ -369,7 +371,7 @@ func (p *ClipboardPlugin) handleClipboardFile(ctx context.Context, dev device.Se } // downloadToFile dials a TLS side-channel and streams the payload to dest. -func downloadToFile(ctx context.Context, ip net.IP, port int, size int64, dest string, tlsConfig *tls.Config, _ *zap.Logger) error { +func downloadToFile(ctx context.Context, ip net.IP, port int, size int64, dest string, tlsConfig *tls.Config, expectedFP string, logger *zap.Logger) error { addr := fmt.Sprintf("%s:%d", ip.String(), port) dialer := &tls.Dialer{ NetDialer: &net.Dialer{ @@ -384,6 +386,15 @@ func downloadToFile(ctx context.Context, ip net.IP, port int, size int64, dest s } defer conn.Close() + if tlsConn, ok := conn.(*tls.Conn); !ok { + return fmt.Errorf("clipboard: side-channel connection is not TLS") + } else if expectedFP == "" { + logger.Warn("clipboard: no pinned peer fingerprint, skipping side-channel verification", + zap.String("remote_addr", addr)) + } else if err := cert.VerifySideChannelPeer(tlsConn.ConnectionState(), expectedFP); err != nil { + return fmt.Errorf("clipboard: side-channel peer verification failed: %w", err) + } + f, err := os.Create(dest) if err != nil { return fmt.Errorf("clipboard: create file %s: %w", dest, err) diff --git a/internal/plugins/mpris/mpris.go b/internal/plugins/mpris/mpris.go index de6e3dd..40438cb 100644 --- a/internal/plugins/mpris/mpris.go +++ b/internal/plugins/mpris/mpris.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" @@ -526,7 +527,7 @@ func (p *MPRISPlugin) sendAlbumArt(ctx context.Context, dev device.Sender, playe } go func() { - _ = share.AcceptAndSend(ln, filePath, p.tlsConfig, dev.ID(), 10*time.Second, nil, p.logger) + _ = share.AcceptAndSend(ln, filePath, p.tlsConfig, dev.ID(), cert.PinnedFingerprint(dev.PeerCert()), 10*time.Second, nil, p.logger) }() pkt, err := protocol.NewPacket("kdeconnect.mpris", map[string]interface{}{ @@ -599,7 +600,7 @@ func (p *MPRISPlugin) receiveAlbumArt(_ context.Context, dev device.Sender, play // independent context so the side-channel dial isn't aborted. dlCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := share.ReceiveSideChannel(dlCtx, remoteIP, port, size, tmpPath, p.tlsConfig, nil, p.logger); err != nil { + if err := share.ReceiveSideChannel(dlCtx, remoteIP, port, size, tmpPath, p.tlsConfig, cert.PinnedFingerprint(dev.PeerCert()), nil, p.logger); err != nil { p.logger.Warn("mpris: album art transfer failed", zap.Error(err)) return } diff --git a/internal/plugins/notification/notification.go b/internal/plugins/notification/notification.go index 1154b8d..a510634 100644 --- a/internal/plugins/notification/notification.go +++ b/internal/plugins/notification/notification.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" @@ -229,12 +230,15 @@ func (p *NotificationPlugin) Handle(ctx context.Context, dev device.Sender, pkt payloadSize = pkt.PayloadSize payloadPort int remoteIP net.IP + expectedFP string ) if hasIcon { payloadPort = pkt.PayloadTransferInfo.Port remoteIP = dev.RemoteIP() if remoteIP == nil { hasIcon = false + } else { + expectedFP = cert.PinnedFingerprint(dev.PeerCert()) } } @@ -242,7 +246,7 @@ func (p *NotificationPlugin) Handle(ctx context.Context, dev device.Sender, pkt go func() { var iconPath string if p.cfg.ShowIcons { - iconPath = p.fetchIcon(ctx, appName, body.ID, remoteIP, payloadPort, payloadSize, hasIcon) + iconPath = p.fetchIcon(ctx, appName, body.ID, remoteIP, payloadPort, payloadSize, hasIcon, expectedFP) } p.sendDesktopNotification(dev.ID(), appName, body.ID, body.Title, text, iconPath) }() @@ -279,6 +283,7 @@ func (p *NotificationPlugin) fetchIcon( port int, size int64, hasIcon bool, + expectedFP string, ) string { if !p.cfg.FetchIcons || p.tlsConfig == nil || p.iconDir == "" { // Fall back to icon name derived from app name. @@ -325,6 +330,16 @@ func (p *NotificationPlugin) fetchIcon( } defer conn.Close() + if tlsConn, ok := conn.(*tls.Conn); !ok { + p.logger.Debug("notification: icon connection is not TLS") + return "" + } else if expectedFP == "" { + p.logger.Warn("notification: no pinned peer fingerprint, skipping icon verification") + } else if err := cert.VerifySideChannelPeer(tlsConn.ConnectionState(), expectedFP); err != nil { + p.logger.Warn("notification: icon peer verification failed, refusing download", zap.Error(err)) + return "" + } + f, err := os.Create(iconPath) if err != nil { return "" diff --git a/internal/plugins/notification/notification_test.go b/internal/plugins/notification/notification_test.go index e3fbb5d..3ecbe92 100644 --- a/internal/plugins/notification/notification_test.go +++ b/internal/plugins/notification/notification_test.go @@ -217,13 +217,13 @@ func TestNotificationPlugin_FetchIconReusesCacheOnRepost(t *testing.T) { // Re-post without an icon payload must reuse the cached file so the // popup keeps the real app icon instead of a placeholder. - got := p.fetchIcon(context.Background(), app, id, nil, 0, 0, false) + got := p.fetchIcon(context.Background(), app, id, nil, 0, 0, false, "") if got != cachedPath { t.Fatalf("expected cached icon reuse %q, got %q", cachedPath, got) } // Unknown id, no payload, no cache → empty (theme fallback downstream). - if got := p.fetchIcon(context.Background(), app, "unknown-id", nil, 0, 0, false); got != "" { + if got := p.fetchIcon(context.Background(), app, "unknown-id", nil, 0, 0, false, ""); got != "" { t.Fatalf("expected empty icon for uncached payload-less repost, got %q", got) } } @@ -257,7 +257,7 @@ func TestFetchIconRefusesTraversal(t *testing.T) { canary := filepath.Join(filepath.Dir(p.iconDir), "kcd-traversal-canary.png") _ = os.Remove(canary) traversal := "../" + filepath.Base(canary) - if got := p.fetchIcon(context.Background(), "App", traversal, nil, 0, 0, false); got != "" { + if got := p.fetchIcon(context.Background(), "App", traversal, nil, 0, 0, false, ""); got != "" { t.Fatalf("expected empty icon for traversal id, got %q", got) } if _, err := os.Stat(canary); !os.IsNotExist(err) { diff --git a/internal/plugins/share/share.go b/internal/plugins/share/share.go index 8b26da8..9b793a2 100644 --- a/internal/plugins/share/share.go +++ b/internal/plugins/share/share.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" @@ -172,6 +173,7 @@ func (p *SharePlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc payloadSize := pkt.PayloadSize payloadPort := pkt.PayloadTransferInfo.Port + expectedFP := cert.PinnedFingerprint(dev.PeerCert()) go func() { defer debug.FreeOSMemory() @@ -182,7 +184,7 @@ func (p *SharePlugin) Handle(ctx context.Context, dev device.Sender, pkt *protoc onProgress = throttle.Update } - err := ReceiveSideChannel(context.Background(), remoteIP, payloadPort, payloadSize, destPath, p.TLSConfig, onProgress, p.Logger) + err := ReceiveSideChannel(context.Background(), remoteIP, payloadPort, payloadSize, destPath, p.TLSConfig, expectedFP, onProgress, p.Logger) if err != nil { p.Logger.Error("share receive failed", zap.Error(err)) if p.bus != nil { @@ -257,6 +259,7 @@ func (p *SharePlugin) SendFile(ctx context.Context, dev device.Sender, filePath throttle := newProgressThrottle(p.bus, dev.ID(), filepath.Base(filePath), stat.Size()) onProgress = throttle.Update } + expectedFP := cert.PinnedFingerprint(dev.PeerCert()) // Handle the transfer in the background so IPC returns instantly go func() { @@ -266,7 +269,7 @@ func (p *SharePlugin) SendFile(ctx context.Context, dev device.Sender, filePath if timeout == 0 { timeout = 2 * time.Minute } - err := AcceptAndSend(ln, filePath, p.TLSConfig, dev.ID(), timeout, onProgress, p.Logger) + err := AcceptAndSend(ln, filePath, p.TLSConfig, dev.ID(), expectedFP, timeout, onProgress, p.Logger) if err != nil { p.Logger.Error("share: send failed", diff --git a/internal/plugins/share/share_test.go b/internal/plugins/share/share_test.go index ae7e1c5..07d3b55 100644 --- a/internal/plugins/share/share_test.go +++ b/internal/plugins/share/share_test.go @@ -3,9 +3,11 @@ package share import ( "context" "crypto/tls" + "crypto/x509" "net" "os" "path/filepath" + "strings" "testing" "time" @@ -39,6 +41,12 @@ func TestSharePlugin_SideChannelRoundTrip(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + t.Fatalf("failed to parse test cert: %v", err) + } + fp := cert.Fingerprint(leaf) + // 1. Start Sender cfg := config.ShareConfig{} cfg.Defaults() @@ -46,16 +54,28 @@ func TestSharePlugin_SideChannelRoundTrip(t *testing.T) { if err != nil { t.Fatalf("ListenSideChannel failed: %v", err) } + serverDone := make(chan error, 1) go func() { - _ = AcceptAndSend(ln, sourcePath, tlsConfig, "test_device_share", 2*time.Second, func(c, t int64) {}, logger) + serverDone <- AcceptAndSend(ln, sourcePath, tlsConfig, "test_device_share", fp, 2*time.Second, func(c, t int64) {}, logger) }() // 2. Run Receiver (dial loopback) - err = ReceiveSideChannel(ctx, net.ParseIP("127.0.0.1"), port, int64(len(content)), destPath, tlsConfig, nil, logger) + err = ReceiveSideChannel(ctx, net.ParseIP("127.0.0.1"), port, int64(len(content)), destPath, tlsConfig, fp, nil, logger) if err != nil { t.Fatalf("ReceiveSideChannel failed: %v", err) } + // Join the server goroutine before returning: it holds the zaptest + // logger, which panics on use after the test completes. + select { + case err := <-serverDone: + if err != nil { + t.Fatalf("AcceptAndSend failed: %v", err) + } + case <-time.After(15 * time.Second): + t.Fatal("timed out waiting for side-channel server to finish") + } + // 3. Verify got, err := os.ReadFile(destPath) if err != nil { @@ -66,3 +86,65 @@ func TestSharePlugin_SideChannelRoundTrip(t *testing.T) { t.Errorf("content mismatch") } } + +func TestSharePlugin_SideChannelRejectsWrongFingerprint(t *testing.T) { + logger := zaptest.NewLogger(t) + dir := t.TempDir() + sourcePath := filepath.Join(dir, "source.bin") + destPath := filepath.Join(dir, "dest.bin") + + content := []byte("fingerprint mismatch test") + if err := os.WriteFile(sourcePath, content, 0644); err != nil { + t.Fatalf("failed to write source: %v", err) + } + + tlsCert, err := cert.GenerateSelfSigned("test_device_share") + if err != nil { + t.Fatalf("failed to generate cert: %v", err) + } + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{*tlsCert}, + InsecureSkipVerify: true, + ClientAuth: tls.RequireAnyClientCert, + } + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + t.Fatalf("failed to parse test cert: %v", err) + } + fp := cert.Fingerprint(leaf) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cfg := config.ShareConfig{} + cfg.Defaults() + // Isolate from the round-trip test's ports: both bind from PortMin + // upward, and a lingering listener would otherwise collide. + cfg.PortMin = 1762 + cfg.PortMax = 1764 + ln, port, err := ListenSideChannel(ctx, cfg, tlsConfig) + if err != nil { + t.Fatalf("ListenSideChannel failed: %v", err) + } + serverDone := make(chan error, 1) + go func() { + serverDone <- AcceptAndSend(ln, sourcePath, tlsConfig, "test_device_share", fp, 2*time.Second, nil, logger) + }() + + // Dial with a wrong expected fingerprint: the receiver must refuse + // before writing anything. + err = ReceiveSideChannel(ctx, net.ParseIP("127.0.0.1"), port, int64(len(content)), destPath, tlsConfig, strings.Repeat("0", 64), nil, logger) + if err == nil || !strings.Contains(err.Error(), "verification failed") { + t.Fatalf("expected fingerprint verification failure, got: %v", err) + } + + // Join the server goroutine before returning: it holds the zaptest + // logger, which panics on use after the test completes. The server + // side is expected to fail streaming to the aborted client — any + // outcome is fine; the assertion above is on the receiver side. + select { + case <-serverDone: + case <-time.After(15 * time.Second): + t.Fatal("timed out waiting for side-channel server to finish") + } +} diff --git a/internal/plugins/share/transfer.go b/internal/plugins/share/transfer.go index 43f2fbf..9f35f75 100644 --- a/internal/plugins/share/transfer.go +++ b/internal/plugins/share/transfer.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/config" "go.uber.org/zap" ) @@ -29,7 +30,7 @@ func (pw *progressWriter) Write(p []byte) (int, error) { return n, nil } -func ReceiveSideChannel(ctx context.Context, ip net.IP, port int, size int64, dest string, tlsConfig *tls.Config, onProgress func(int64, int64), logger *zap.Logger) error { +func ReceiveSideChannel(ctx context.Context, ip net.IP, port int, size int64, dest string, tlsConfig *tls.Config, expectedFP string, onProgress func(int64, int64), logger *zap.Logger) error { if size < 0 { return fmt.Errorf("share: indefinite payload sizes (-1) are not supported") } @@ -49,6 +50,23 @@ func ReceiveSideChannel(ctx context.Context, ip net.IP, port int, size int64, de } defer conn.Close() + // The phone's TLS cert isn't verified against the paired fingerprint by + // default (self-signed), so confirm the peer is the device we expect + // before pulling bytes from it. + if tlsConn, ok := conn.(*tls.Conn); !ok { + return fmt.Errorf("share: side-channel connection is not TLS") + } else if expectedFP == "" { + logger.Warn("share: no pinned peer fingerprint, skipping side-channel verification", + zap.String("remote_addr", addr)) + } else if err := cert.VerifySideChannelPeer(tlsConn.ConnectionState(), expectedFP); err != nil { + logger.Error("share: side-channel peer verification failed", + zap.String("remote_addr", addr), + zap.String("expected_fp", expectedFP), + zap.Error(err), + ) + return fmt.Errorf("share: side-channel peer verification failed: %w", err) + } + f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { return fmt.Errorf("share: create file %s: %w", dest, err) @@ -91,7 +109,7 @@ func ListenSideChannel(ctx context.Context, cfg config.ShareConfig, tlsConfig *t } // AcceptAndSend waits for the phone to connect, performs the TLS handshake, and streams the file. -func AcceptAndSend(ln net.Listener, filePath string, tlsConfig *tls.Config, expectedDeviceID string, timeout time.Duration, onProgress func(int64, int64), logger *zap.Logger) error { +func AcceptAndSend(ln net.Listener, filePath string, tlsConfig *tls.Config, expectedDeviceID, expectedFP string, timeout time.Duration, onProgress func(int64, int64), logger *zap.Logger) error { defer ln.Close() addr := ln.Addr().String() @@ -163,6 +181,20 @@ func AcceptAndSend(ln net.Listener, filePath string, tlsConfig *tls.Config, expe ) return fmt.Errorf("share: cert CN mismatch: expected device %s, got %s", expectedDeviceID, certCN) } + // The CN check above only confirms the device ID claim; pin the actual + // paired certificate fingerprint so a holder of a different cert minted + // with the same CN can't pull the file. + if expectedFP == "" { + logger.Warn("share: no pinned peer fingerprint, skipping side-channel verification", + zap.String("expected_device", expectedDeviceID)) + } else if err := cert.VerifySideChannelPeer(state, expectedFP); err != nil { + logger.Error("share: side-channel peer verification failed", + zap.String("expected_device", expectedDeviceID), + zap.String("expected_fp", expectedFP), + zap.Error(err), + ) + return fmt.Errorf("share: side-channel peer verification failed: %w", err) + } logger.Info("share: TLS OK, streaming file", zap.String("file", filePath), diff --git a/internal/plugins/sms/sms.go b/internal/plugins/sms/sms.go index 50280ed..75bdeee 100644 --- a/internal/plugins/sms/sms.go +++ b/internal/plugins/sms/sms.go @@ -14,6 +14,7 @@ import ( "time" "unicode" + "github.com/bethropolis/kcd/internal/cert" "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" @@ -225,9 +226,10 @@ func (p *SMSPlugin) handleAttachmentFile(ctx context.Context, dev device.Sender, port := pkt.PayloadTransferInfo.Port payloadSize := pkt.PayloadSize + expectedFP := cert.PinnedFingerprint(dev.PeerCert()) go func() { - if err := p.receiveAttachment(ctx, remoteIP, port, payloadSize, destPath); err != nil { + if err := p.receiveAttachment(ctx, remoteIP, port, payloadSize, destPath, expectedFP); err != nil { p.logger.Error("sms: attachment download failed", zap.Error(err)) return } @@ -251,7 +253,7 @@ func (p *SMSPlugin) handleAttachmentFile(ctx context.Context, dev device.Sender, // the attachment file over TLS. The stream is capped at the declared // payload size (itself bounded by maxSMSAttachmentBytes) so a malicious // peer can't fill the disk with an unbounded stream. -func (p *SMSPlugin) receiveAttachment(ctx context.Context, ip net.IP, port int, size int64, destPath string) error { +func (p *SMSPlugin) receiveAttachment(ctx context.Context, ip net.IP, port int, size int64, destPath string, expectedFP string) error { if size <= 0 || size > maxSMSAttachmentBytes { return fmt.Errorf("sms: refusing attachment with invalid size %d (limit %d)", size, maxSMSAttachmentBytes) } @@ -270,6 +272,15 @@ func (p *SMSPlugin) receiveAttachment(ctx context.Context, ip net.IP, port int, } defer conn.Close() + if tlsConn, ok := conn.(*tls.Conn); !ok { + return fmt.Errorf("sms: attachment side-channel is not TLS") + } else if expectedFP == "" { + p.logger.Warn("sms: no pinned peer fingerprint, skipping side-channel verification", + zap.String("remote_addr", addr)) + } else if err := cert.VerifySideChannelPeer(tlsConn.ConnectionState(), expectedFP); err != nil { + return fmt.Errorf("sms: side-channel peer verification failed: %w", err) + } + f, err := os.Create(destPath) if err != nil { return fmt.Errorf("sms: create attachment file: %w", err) diff --git a/internal/plugins/sms/sms_test.go b/internal/plugins/sms/sms_test.go index 9fac236..796bfa4 100644 --- a/internal/plugins/sms/sms_test.go +++ b/internal/plugins/sms/sms_test.go @@ -35,7 +35,7 @@ func TestCleanFilename(t *testing.T) { func TestReceiveAttachmentRejectsBadSize(t *testing.T) { p := NewSMSPlugin(config.SMSConfig{}, nil, nil, zap.NewNop()) for _, size := range []int64{0, -1, -999, maxSMSAttachmentBytes + 1} { - err := p.receiveAttachment(context.Background(), nil, 0, size, "/nonexistent/x") + err := p.receiveAttachment(context.Background(), nil, 0, size, "/nonexistent/x", "") if err == nil { t.Errorf("receiveAttachment(size=%d) = nil, want error", size) } From 5e200388a8d104108936f1d2632c6bdd6ea6a6b1 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:35:01 +0300 Subject: [PATCH 13/25] feat(pair): constrain headless auto-accept with fingerprint and known-only flags Bare pair --yes accepted the first device that asked, stranger or not. Add --expected-fingerprint to pin the exact peer cert and --known-only to restrict candidates to previously recorded devices; anything else is rejected while listening continues. Print a warning when --yes runs unconstrained. The listen result now carries the candidate fingerprint. --- cmd/kcd/cli_devices.go | 69 ++++++++++++++++++++++++++++++++++++++++ cmd/kcd/cli_pair_test.go | 50 +++++++++++++++++++++++++++++ docs/CLI.md | 12 +++++++ internal/ipc/handler.go | 1 + internal/ipc/proto.go | 3 ++ 5 files changed, 135 insertions(+) create mode 100644 cmd/kcd/cli_pair_test.go diff --git a/cmd/kcd/cli_devices.go b/cmd/kcd/cli_devices.go index 91e5575..cf21d05 100644 --- a/cmd/kcd/cli_devices.go +++ b/cmd/kcd/cli_devices.go @@ -8,6 +8,7 @@ import ( "strings" "syscall" + "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/ipc" "github.com/bethropolis/kcd/internal/protocol" @@ -81,6 +82,14 @@ Without a device ID: enter listen mode to receive and verify incoming pairing re Aliases: []string{"y"}, Usage: "Automatically accept incoming requests without confirmation (headless mode)", }, + &cli.StringFlag{ + Name: "expected-fingerprint", + Usage: "Only accept a candidate whose TLS cert fingerprint matches (hex, colons optional)", + }, + &cli.BoolFlag{ + Name: "known-only", + Usage: "Only accept candidates already recorded in the known-devices file", + }, }, Action: func(c *cli.Context) error { cl, err := getClient(c) @@ -100,6 +109,18 @@ Without a device ID: enter listen mode to receive and verify incoming pairing re // Listen mode — wait for any incoming pair request fmt.Println("Listening for pair requests… (Ctrl+C to cancel)") + if c.Bool("yes") && c.String("expected-fingerprint") == "" && !c.Bool("known-only") { + fmt.Fprintln(os.Stderr, "WARNING: auto-accepting pairing requests from ANY device on the local network.") + fmt.Fprintln(os.Stderr, "Use --expected-fingerprint or --known-only to restrict which device may pair.") + } + + // Snapshot of previously seen devices for --known-only. A stranger + // that was never recorded in the state file is never auto-accepted. + var known map[string]bool + if c.Bool("known-only") { + known = loadKnownDeviceIDs() + } + if err := cl.BroadcastStart(); err != nil { return fmt.Errorf("failed to start broadcast: %w", err) } @@ -147,6 +168,12 @@ Without a device ID: enter listen mode to receive and verify incoming pairing re // Headless / auto-accept flag if c.Bool("yes") { + if err := checkPairCandidate(c.String("expected-fingerprint"), c.Bool("known-only"), r.result, known); err != nil { + fmt.Printf("Refusing candidate: %s\n", err) + _ = cl.Unpair(r.result.DeviceID) + fmt.Println("Still listening… (Ctrl+C to cancel)") + continue + } if err := cl.Pair(r.result.DeviceID); err != nil { return fmt.Errorf("failed to accept pairing: %w", err) } @@ -161,6 +188,11 @@ Without a device ID: enter listen mode to receive and verify incoming pairing re response = strings.TrimSpace(strings.ToLower(response)) if response == "y" || response == "yes" { + if err := checkPairCandidate(c.String("expected-fingerprint"), c.Bool("known-only"), r.result, known); err != nil { + fmt.Printf("Refusing candidate: %s\n", err) + _ = cl.Unpair(r.result.DeviceID) + return nil + } if err := cl.Pair(r.result.DeviceID); err != nil { return fmt.Errorf("failed to accept pairing: %w", err) } @@ -198,6 +230,43 @@ var unpairCmd = &cli.Command{ }, } +// normalizeFingerprint strips separators and lowercases a hex fingerprint so +// user-supplied values (aa:bb:.., AA BB ..) compare against daemon hex. +func normalizeFingerprint(fp string) string { + fp = strings.ReplaceAll(fp, ":", "") + fp = strings.ReplaceAll(fp, " ", "") + return strings.ToLower(fp) +} + +// loadKnownDeviceIDs returns the set of device IDs recorded in the daemon's +// persisted state file. Missing or unreadable state means nothing is known. +func loadKnownDeviceIDs() map[string]bool { + known := make(map[string]bool) + infos, err := device.LoadDevices(config.StatePath()) + if err != nil { + return known + } + for _, info := range infos { + known[info.ID] = true + } + return known +} + +// checkPairCandidate enforces the --expected-fingerprint and --known-only +// constraints on a pairing candidate. Fail-closed: a candidate without a +// reported fingerprint never satisfies an expected fingerprint. +func checkPairCandidate(expectedFP string, knownOnly bool, result *ipc.PairListenResult, known map[string]bool) error { + if expectedFP != "" { + if got := normalizeFingerprint(result.Fingerprint); got == "" || got != normalizeFingerprint(expectedFP) { + return fmt.Errorf("candidate fingerprint does not match --expected-fingerprint") + } + } + if knownOnly && !known[result.DeviceID] { + return fmt.Errorf("candidate %s is not in the known-devices file (--known-only)", result.DeviceID) + } + return nil +} + func printDeviceTable(devices []device.DeviceInfo) { if len(devices) == 0 { fmt.Println("No devices found.") diff --git a/cmd/kcd/cli_pair_test.go b/cmd/kcd/cli_pair_test.go new file mode 100644 index 0000000..e21e7fc --- /dev/null +++ b/cmd/kcd/cli_pair_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "testing" + + "github.com/bethropolis/kcd/internal/ipc" +) + +func TestNormalizeFingerprint(t *testing.T) { + cases := []struct{ in, want string }{ + {"abcdef1234", "abcdef1234"}, + {"AB:CD:EF:12:34", "abcdef1234"}, + {"ab cd ef 12 34", "abcdef1234"}, + {"", ""}, + } + for _, tc := range cases { + if got := normalizeFingerprint(tc.in); got != tc.want { + t.Errorf("normalizeFingerprint(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestCheckPairCandidate(t *testing.T) { + fp := "aa:bb:cc:dd" + candidate := &ipc.PairListenResult{DeviceID: "dev1", Fingerprint: "aabbccdd"} + known := map[string]bool{"dev1": true} + + if err := checkPairCandidate("", false, candidate, nil); err != nil { + t.Errorf("no constraints should accept: %v", err) + } + if err := checkPairCandidate(fp, false, candidate, nil); err != nil { + t.Errorf("matching fingerprint should accept: %v", err) + } + if err := checkPairCandidate("00:11:22:33", false, candidate, nil); err == nil { + t.Error("mismatched fingerprint should refuse") + } + if err := checkPairCandidate(fp, false, &ipc.PairListenResult{DeviceID: "dev1"}, nil); err == nil { + t.Error("missing candidate fingerprint should refuse when one is expected") + } + if err := checkPairCandidate("", true, candidate, known); err != nil { + t.Errorf("known device should accept with known-only: %v", err) + } + if err := checkPairCandidate("", true, &ipc.PairListenResult{DeviceID: "stranger"}, known); err == nil { + t.Error("unknown device should refuse with known-only") + } + // Nil known set behaves as "nothing known". + if err := checkPairCandidate("", true, candidate, nil); err == nil { + t.Error("empty known set should refuse with known-only") + } +} diff --git a/docs/CLI.md b/docs/CLI.md index 8bb681b..6ed783c 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -208,6 +208,18 @@ kcd pair --yes Broadcast stops when pairing completes or you press Ctrl+C. +> **Constraining auto-accept:** bare `--yes` accepts the first device that +> asks, which is risky on shared networks (a warning is printed). Pin the +> expected peer instead: +> +> ```bash +> kcd pair --yes --expected-fingerprint "aa:bb:cc:..." # exact cert match (colons optional) +> kcd pair --yes --known-only # only devices already in the known-devices file +> ``` +> +> Non-matching candidates are rejected and listening continues. Both flags +> also apply to the interactive confirmation prompt. + --- ## unpair diff --git a/internal/ipc/handler.go b/internal/ipc/handler.go index 116056e..0710a35 100644 --- a/internal/ipc/handler.go +++ b/internal/ipc/handler.go @@ -238,6 +238,7 @@ func (h *Handler) pairListenResult(dev *device.Device, vKey string) Response { DeviceID: dev.ID(), DeviceName: dev.Name(), VerificationKey: vKey, + Fingerprint: dev.CertFP, }) return Response{OK: true, Data: data} } diff --git a/internal/ipc/proto.go b/internal/ipc/proto.go index 1a79705..0221078 100644 --- a/internal/ipc/proto.go +++ b/internal/ipc/proto.go @@ -73,6 +73,9 @@ type PairListenResult struct { DeviceID string `json:"deviceId"` DeviceName string `json:"deviceName"` VerificationKey string `json:"verificationKey,omitempty"` + // Fingerprint is the SHA256 hex of the candidate's live TLS + // certificate, so callers can pin the expected peer. + Fingerprint string `json:"fingerprint,omitempty"` } // DevicePayload is sent in requests requiring a device ID (like pair/unpair/ping). From c36de482875b8797622437a85541a353ac0b89ca Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:34:27 +0300 Subject: [PATCH 14/25] fix(battery): omit battery state until the first real packet arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh device published charge 0 / charging false, indistinguishable from a genuine 0% reading — and at steady charge no later packet ever corrected it. Track first-receipt per device and leave the battery object out of summaries, the watch bootstrap dump, and the battery query (which now fails closed) until then. Summaries carry batteryAgeMs so clients can apply staleness rules. --- docs/IPC_PROTOCOL.md | 22 +++++--- internal/daemon/ipc_routes_battery.go | 10 +++- internal/device/device.go | 30 +++++++++++ internal/device/device_test.go | 30 +++++++++++ internal/ipc/server.go | 36 +++++++------ internal/ipc/snapshot.go | 17 ++++-- internal/ipc/snapshot_test.go | 74 ++++++++++++++++++++++++++- 7 files changed, 189 insertions(+), 30 deletions(-) diff --git a/docs/IPC_PROTOCOL.md b/docs/IPC_PROTOCOL.md index 8b5b8b2..16a040d 100644 --- a/docs/IPC_PROTOCOL.md +++ b/docs/IPC_PROTOCOL.md @@ -98,7 +98,7 @@ Fields: | `cert_fp` | string | Not populated in this response (empty) | | `last_seen` | string (RFC3339) | Last time the device was seen (announcement or connection) | | `connected` | bool | Whether the device currently has an active TCP connection. Note: `connected: true` alone does **not** mean usable — a stranger on the LAN can hold a raw connection while `state` is `UNPAIRED`. Clients must check `state == "PAIRED"` before sending commands or auto-selecting a device. | -| `battery` | object (optional) | `{"charge": 85, "charging": true}` — cached battery state | +| `battery` | object (optional) | `{"charge": 85, "charging": true, "batteryAgeMs": 1234}` — cached battery state; absent when the device never reported | | `media` | object (optional) | Cached `NowPlaying` plus `mediaAgeMs` (ms since the phone reported); absent when the device never reported media | | `signal` | object (optional) | Cached connectivity report (`{"signalStrengths": {...}}`); absent when never reported | @@ -235,13 +235,18 @@ Request battery state from a device. {"deviceId": "a1b2c3d4e5f6_..."} ``` -**Response data:** `{"charge": 85, "charging": true}` (the daemon waits for the -device to respond and returns the value). +**Response data:** `{"charge": 85, "charging": true, "batteryAgeMs": 1234}` (the +last cached reading plus its age in ms; the daemon does not live-query the +device). Returns `{"ok": false, "error": "no battery reading yet"}` when no +`kdeconnect.battery` packet was ever received — the `battery` object is +likewise absent from summaries until the first real packet, so clients must +treat absent as unknown, not 0%. | Field | Type | Description | |---|---|---| | `charge` | number | Battery percentage (0–100) | | `charging` | bool | Whether the device is currently charging | +| `batteryAgeMs` | number | ms since the phone reported (mirrors `mediaAgeMs`) | #### `connectivity` @@ -704,9 +709,10 @@ are delivered. {"type":"device.connected","deviceId":"...","timestamp":"2026-05-27T10:00:00Z","payload":{"id":"...","name":"Pixel 9","type":"phone"}} ``` - **2b. `battery.update`:** + **2b. `battery.update`** (only if the device already reported battery — + no reading yet means no event, never a zero-value): ```json - {"type":"battery.update","deviceId":"...","timestamp":"...","payload":{"charge":85,"charging":true}} + {"type":"battery.update","deviceId":"...","timestamp":"...","payload":{"charge":85,"charging":true,"batteryAgeMs":1234}} ``` **2c. `mpris.update`** (only if MPRIS plugin is registered AND the cached @@ -841,18 +847,20 @@ A pairing request was rejected, or a device was unpaired. #### `battery.update` -Battery state changed or was requested. +Battery state changed or was requested. Never emitted with zero values for +a device that never reported — absent reading means no event. **Payload:** ```json -{"charge": 85, "charging": true} +{"charge": 85, "charging": true, "batteryAgeMs": 1234} ``` | Field | Type | Description | |---|---|---| | `charge` | number | Battery percentage (0–100) | | `charging` | bool | Whether the device is currently charging | +| `batteryAgeMs` | number | ms since the phone reported (cached/dump events only) | #### `battery.threshold` diff --git a/internal/daemon/ipc_routes_battery.go b/internal/daemon/ipc_routes_battery.go index 2fe8a54..dab0fc1 100644 --- a/internal/daemon/ipc_routes_battery.go +++ b/internal/daemon/ipc_routes_battery.go @@ -17,10 +17,16 @@ func registerBatteryRoutes(handler *ipc.Handler, devices *device.Registry) { if !ok { return ipc.Response{OK: false, Error: "device not found"} } + // Fail closed when no packet was ever received: returning the + // zero values would be indistinguishable from a real 0% reading. + if !dev.HasBattery() { + return ipc.Response{OK: false, Error: "no battery reading yet"} + } charge, charging := dev.GetBattery() data, _ := json.Marshal(map[string]interface{}{ - "charge": charge, - "charging": charging, + "charge": charge, + "charging": charging, + "batteryAgeMs": dev.BatteryAge().Milliseconds(), }) return ipc.Response{OK: true, Data: data} }) diff --git a/internal/device/device.go b/internal/device/device.go index ffacf93..e206480 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -77,6 +77,15 @@ type Device struct { BatteryCharge int IsCharging bool + // batterySeen marks that at least one kdeconnect.battery packet was + // received. Until then the zero values above are not measurements — + // they must not be published (a fresh pair would otherwise report a + // stable, bogus 0% that no later packet corrects at steady charge). + batterySeen bool + // lastBatteryAt is when the last battery packet arrived, so clients + // can apply their own staleness rules (mirrors mediaAgeMs). + lastBatteryAt time.Time + mu sync.RWMutex // reconnecting is an atomic flag preventing multiple concurrent @@ -255,6 +264,8 @@ func (d *Device) UpdateBattery(charge int, charging bool) { d.mu.Lock() d.BatteryCharge = charge d.IsCharging = charging + d.batterySeen = true + d.lastBatteryAt = time.Now() bus := d.bus id := d.id d.mu.Unlock() @@ -274,6 +285,25 @@ func (d *Device) GetBattery() (int, bool) { return d.BatteryCharge, d.IsCharging } +// HasBattery reports whether at least one battery packet was received. +// Until then the charge values are zero-value defaults, not measurements. +func (d *Device) HasBattery() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.batterySeen +} + +// BatteryAge returns how long ago the last battery packet arrived, or a +// negative duration when no packet was ever received. +func (d *Device) BatteryAge() time.Duration { + d.mu.RLock() + defer d.mu.RUnlock() + if !d.batterySeen { + return -1 + } + return time.Since(d.lastBatteryAt) +} + // HasCapability checks if the device has a particular capability (incoming or outgoing). func (d *Device) HasCapability(cap string) bool { d.mu.RLock() diff --git a/internal/device/device_test.go b/internal/device/device_test.go index 5c3b2df..00d2c73 100644 --- a/internal/device/device_test.go +++ b/internal/device/device_test.go @@ -106,3 +106,33 @@ func TestDevice_ConnectionAge(t *testing.T) { } d.Disconnect() } + +func TestDevice_BatterySeen(t *testing.T) { + logger := zaptest.NewLogger(t) + d := NewDevice("bat-seen", "Phone", "phone", logger) + + if d.HasBattery() { + t.Error("fresh device must report no battery reading") + } + if got := d.BatteryAge(); got >= 0 { + t.Errorf("fresh device battery age must be negative, got %v", got) + } + + d.UpdateBattery(80, true) + if !d.HasBattery() { + t.Error("device must report a reading after UpdateBattery") + } + if charge, charging := d.GetBattery(); charge != 80 || !charging { + t.Errorf("GetBattery = (%d, %v), want (80, true)", charge, charging) + } + if got := d.BatteryAge(); got < 0 { + t.Errorf("battery age must be non-negative after a reading, got %v", got) + } + + // A true zero is a real reading, not an unknown. + d2 := NewDevice("bat-zero", "Phone", "phone", logger) + d2.UpdateBattery(0, false) + if !d2.HasBattery() { + t.Error("true 0% reading must count as seen") + } +} diff --git a/internal/ipc/server.go b/internal/ipc/server.go index e03a569..b02c888 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -165,21 +165,27 @@ func (s *Server) handleWatch(conn net.Conn, payload []byte) { return } - // Send initial battery event - charge, charging := dev.GetBattery() - batEv := map[string]interface{}{ - "type": "battery.update", - "deviceId": dev.ID(), - "timestamp": time.Now().UTC(), - "payload": map[string]interface{}{ - "charge": charge, - "charging": charging, - }, - } - data, _ = json.Marshal(batEv) - data = append(data, '\n') - if _, err := conn.Write(data); err != nil { - return + // Send initial battery event, but only when the daemon actually + // has a reading. Emitting zero values for a fresh pair would + // publish a bogus stable 0% (see Device.HasBattery) — same + // skip-if-absent rule as connectivity below. + if dev.HasBattery() { + charge, charging := dev.GetBattery() + batEv := map[string]interface{}{ + "type": "battery.update", + "deviceId": dev.ID(), + "timestamp": time.Now().UTC(), + "payload": map[string]interface{}{ + "charge": charge, + "charging": charging, + "batteryAgeMs": dev.BatteryAge().Milliseconds(), + }, + } + data, _ = json.Marshal(batEv) + data = append(data, '\n') + if _, err := conn.Write(data); err != nil { + return + } } // Send initial connectivity state if the device already reported. diff --git a/internal/ipc/snapshot.go b/internal/ipc/snapshot.go index 0a5dec0..89038ad 100644 --- a/internal/ipc/snapshot.go +++ b/internal/ipc/snapshot.go @@ -11,8 +11,9 @@ import ( // BatteryStatus mirrors the battery state for embedding in summaries. type BatteryStatus struct { - Charge int `json:"charge"` - Charging bool `json:"charging"` + Charge int `json:"charge"` + Charging bool `json:"charging"` + BatteryAgeMs int64 `json:"batteryAgeMs"` } // MediaState is the cached now-playing state plus its age, so clients can @@ -54,8 +55,16 @@ func SummarizeDevice(dev *device.Device, plugins *plugin.Registry) DeviceSummary }, } - charge, charging := dev.GetBattery() - sum.Battery = &BatteryStatus{Charge: charge, Charging: charging} + // Omit battery until the first real packet: the zero values are not + // a 0% measurement (see Device.HasBattery). + if dev.HasBattery() { + charge, charging := dev.GetBattery() + sum.Battery = &BatteryStatus{ + Charge: charge, + Charging: charging, + BatteryAgeMs: dev.BatteryAge().Milliseconds(), + } + } if plugins == nil { return sum diff --git a/internal/ipc/snapshot_test.go b/internal/ipc/snapshot_test.go index d5bca7f..05bc9a3 100644 --- a/internal/ipc/snapshot_test.go +++ b/internal/ipc/snapshot_test.go @@ -26,15 +26,20 @@ func TestBuildSnapshotCoversOfflineDevices(t *testing.T) { devReg.Add(online) offline := device.NewDevice("dev-off", "Offline Phone", "phone", logger) + offline.UpdateBattery(24, false) offline.SetState(device.StatePaired) devReg.Add(offline) stranger := device.NewDevice("dev-stranger", "Stranger", "phone", logger) devReg.Add(stranger) + unseen := device.NewDevice("dev-unseen", "Fresh Pair", "phone", logger) + unseen.SetState(device.StatePaired) + devReg.Add(unseen) + snap := ipc.BuildSnapshot(devReg, pluginReg) - if len(snap.Devices) != 3 { - t.Fatalf("expected all 3 devices in snapshot, got %d", len(snap.Devices)) + if len(snap.Devices) != 4 { + t.Fatalf("expected all 4 devices in snapshot, got %d", len(snap.Devices)) } byID := make(map[string]ipc.DeviceSummary) @@ -48,6 +53,9 @@ func TestBuildSnapshotCoversOfflineDevices(t *testing.T) { if byID["dev-off"].Battery == nil { t.Error("offline paired device should still carry battery (dump parity)") } + if byID["dev-unseen"].Battery != nil { + t.Errorf("device with no reading must omit battery, got %+v", byID["dev-unseen"].Battery) + } if byID["dev-stranger"].Media != nil || byID["dev-stranger"].Signal != nil { t.Error("absent plugins must omit media/signal sections") } @@ -66,6 +74,68 @@ func TestBuildSnapshotCoversOfflineDevices(t *testing.T) { t.Errorf("summary missing base field %q: %v", k, d) } } + if d["id"] == "dev-unseen" { + if _, ok := d["battery"]; ok { + t.Errorf("unseen device must serialize without a battery key: %v", d) + } + } + } +} + +// Battery omission contract: a fresh device serializes with no battery key +// (unknown, not 0%); one packet makes the key appear with the sent values; +// a real 0% packet is preserved as a present zero, not collapsed to unknown. +func TestSummarizeDeviceBatteryOmission(t *testing.T) { + logger := zaptest.NewLogger(t) + pluginReg := plugin.NewRegistry(logger) + + fresh := device.NewDevice("dev-fresh", "Fresh", "phone", logger) + sum := ipc.SummarizeDevice(fresh, pluginReg) + if sum.Battery != nil { + t.Fatalf("fresh device must omit battery, got %+v", sum.Battery) + } + raw, _ := json.Marshal(sum) + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if _, ok := decoded["battery"]; ok { + t.Errorf("fresh device must serialize without battery key: %s", raw) + } + + charged := device.NewDevice("dev-charged", "Charged", "phone", logger) + charged.UpdateBattery(80, true) + sum = ipc.SummarizeDevice(charged, pluginReg) + if sum.Battery == nil { + t.Fatal("device with a reading must carry battery") + } + if sum.Battery.Charge != 80 || !sum.Battery.Charging { + t.Errorf("battery values wrong: %+v", sum.Battery) + } + if sum.Battery.BatteryAgeMs < 0 { + t.Errorf("battery age must be non-negative after a reading: %+v", sum.Battery) + } + + dead := device.NewDevice("dev-dead", "Dead", "phone", logger) + dead.UpdateBattery(0, false) + sum = ipc.SummarizeDevice(dead, pluginReg) + if sum.Battery == nil { + t.Fatal("true 0% reading must still be published") + } + if sum.Battery.Charge != 0 || sum.Battery.Charging { + t.Errorf("true zero values wrong: %+v", sum.Battery) + } + raw, _ = json.Marshal(sum) + decoded = nil + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + bat, ok := decoded["battery"].(map[string]interface{}) + if !ok { + t.Fatalf("true 0%% must serialize a battery key: %s", raw) + } + if bat["charge"] != float64(0) { + t.Errorf("true 0%% charge wrong: %v", bat) } } From 9fa4a64acf0359a599fa413bcf12e3699487c1fb Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:43:43 +0300 Subject: [PATCH 15/25] feat(systemd): start the daemon on first client connection via socket activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon adopts a systemd-passed IPC listener (LISTEN_FDS, stdlib only) when configured with the default socket path, so any client command — including watch — summons a stopped daemon instead of retrying against an absent socket. Custom socket paths and non-systemd systems keep the self-bind path. The socket unit owns the runtime directory (declaring it in both units made service stops delete the socket file) with mode 0600, and the installer enables the socket instead of the always-on service. --- README.md | 4 ++-- docs/CLI.md | 8 ++++++- internal/ipc/server.go | 49 ++++++++++++++++++++++++++++++++++++-- packaging/kcd-user.service | 9 ++++--- packaging/kcd-user.socket | 37 ++++++++++++++++++++++++++++ scripts/install.sh | 49 ++++++++++++++++++++++++++++---------- 6 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 packaging/kcd-user.socket diff --git a/README.md b/README.md index 2981a03..8e34abb 100644 --- a/README.md +++ b/README.md @@ -109,10 +109,10 @@ 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 up — enable and start it: +is already set up — enable the socket and the daemon starts on first use: ```bash -systemctl --user enable --now kcd +systemctl --user enable --now kcd.socket ``` Check that it's running: diff --git a/docs/CLI.md b/docs/CLI.md index 6ed783c..49a3337 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -43,10 +43,16 @@ The daemon: ```bash cp packaging/kcd-user.service ~/.config/systemd/user/kcd.service +cp packaging/kcd-user.socket ~/.config/systemd/user/kcd.socket systemctl --user daemon-reload -systemctl --user enable --now kcd +systemctl --user enable --now kcd.socket ``` +The socket unit listens on the IPC socket and starts the daemon on the +first client connection — no manual start needed, cold commands just work. +(`install.sh` sets this up automatically.) Only the default socket path +activates this way; a custom `socket_path` in `kcd.toml` self-binds. + Check status: ```bash systemctl --user status kcd diff --git a/internal/ipc/server.go b/internal/ipc/server.go index b02c888..d28b9ca 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -8,8 +8,10 @@ import ( "net" "os" "path/filepath" + "strconv" "time" + "github.com/bethropolis/kcd/internal/config" "github.com/bethropolis/kcd/internal/events" "github.com/bethropolis/kcd/internal/plugins/connectivity" "github.com/bethropolis/kcd/internal/plugins/mpris" @@ -32,8 +34,42 @@ func NewServer(path string, handler *Handler, logger *zap.Logger) *Server { } } +// activatedListener adopts a Unix listener passed by systemd socket +// activation (fd 3+), or returns nil when not running socket-activated. +// Stdlib only: LISTEN_PID must match our pid and LISTEN_FDS must offer at +// least one socket. The adopted fd is dup'd by net.FileListener, so the +// *os.File wrapper is not retained. +func activatedListener() net.Listener { + if os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) { + return nil + } + n, err := strconv.Atoi(os.Getenv("LISTEN_FDS")) + if err != nil || n < 1 { + return nil + } + f := os.NewFile(3, "kcd-socket-activated") + l, err := net.FileListener(f) + if err != nil { + _ = f.Close() + return nil + } + // Children (sshfs, notify-send, …) must not inherit the activation + // environment and mistake it for sockets passed to them. + _ = os.Unsetenv("LISTEN_PID") + _ = os.Unsetenv("LISTEN_FDS") + return l +} + // Listen starts listening on the Unix socket and processes incoming connections. +// When running under systemd socket activation with the default socket path, +// the passed listener is adopted instead of binding cfg.SocketPath. func (s *Server) Listen(ctx context.Context) error { + if s.path == config.DefaultSocketPath() { + if l := activatedListener(); l != nil { + return s.serve(ctx, l, true) + } + } + dir := filepath.Dir(s.path) if err := os.MkdirAll(dir, 0700); err != nil { return err @@ -54,13 +90,22 @@ func (s *Server) Listen(ctx context.Context) error { return fmt.Errorf("failed to chmod ipc socket: %w", err) } + return s.serve(ctx, l, false) +} + +func (s *Server) serve(ctx context.Context, l net.Listener, activated bool) error { go func() { <-ctx.Done() l.Close() - os.Remove(s.path) + if !activated { + os.Remove(s.path) + } }() - s.logger.Info("ipc server started", zap.String("path", s.path)) + s.logger.Info("ipc server started", + zap.String("path", s.path), + zap.Bool("socket_activated", activated), + ) for { conn, err := l.Accept() diff --git a/packaging/kcd-user.service b/packaging/kcd-user.service index 7a69bd2..e32cd97 100644 --- a/packaging/kcd-user.service +++ b/packaging/kcd-user.service @@ -28,9 +28,12 @@ TimeoutStopSec=10 Restart=on-failure RestartSec=5 -# IPC socket directory, created automatically by systemd under $XDG_RUNTIME_DIR. -RuntimeDirectory=kcd -RuntimeDirectoryMode=0700 +# NOTE: no RuntimeDirectory= here on purpose. The kcd.socket unit owns +# $XDG_RUNTIME_DIR/kcd (creating it is required before the socket can be +# bound), and systemd tears a RuntimeDirectory down when its unit stops — +# if both units declared it, stopping the service would delete the socket +# file out from under the still-active socket unit. In non-socket mode the +# daemon creates the directory itself (ipc server MkdirAll, mode 0700). # ── Process hardening ───────────────────────────────────────────────────────── # Namespace-based hardening (ProtectSystem, PrivateTmp, etc.) is intentionally diff --git a/packaging/kcd-user.socket b/packaging/kcd-user.socket new file mode 100644 index 0000000..718dad5 --- /dev/null +++ b/packaging/kcd-user.socket @@ -0,0 +1,37 @@ +# kcd.socket — on-demand activation for the kcd daemon. +# +# Installed by scripts/install.sh as ~/.config/systemd/user/kcd.socket. +# Enable once for login-persistent on-demand startup: +# +# systemctl --user enable --now kcd.socket +# +# The first client connection to the IPC socket starts kcd.service +# automatically; the daemon adopts this listener (see LISTEN_FDS handling +# in internal/ipc/server.go) instead of binding the path itself. Clients +# block briefly on a cold start, then behave identically — no reconnect +# machinery needed to summon the daemon. +# +# Only applies to the default socket path ($XDG_RUNTIME_DIR/kcd/kcd.sock). +# A custom socket_path in kcd.toml falls back to daemon-side self-bind. + +[Unit] +Description=kcd — IPC socket (on-demand daemon activation) +Documentation=https://github.com/bethropolis/kcd +# The service stops at session end; the socket goes with it. +PartOf=graphical-session.target +Before=sockets.target + +[Socket] +# Must match the daemon's default socket path. %t is $XDG_RUNTIME_DIR. +ListenStream=%t/kcd/kcd.sock +# Mirror the daemon's self-bind permissions (0600) — without this the +# systemd-created socket would be world-accessible under a lax umask. +SocketMode=0600 +# Create $XDG_RUNTIME_DIR/kcd (mode 0700) so the socket path exists. +RuntimeDirectory=kcd +RuntimeDirectoryMode=0700 +# Single-instance stream socket (Accept=false default): systemd spawns one +# kcd.service and hands it every connection. Default trigger limits apply. + +[Install] +WantedBy=sockets.target diff --git a/scripts/install.sh b/scripts/install.sh index 6000ba6..ae08d3a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -89,6 +89,12 @@ if systemctl --user is-active --quiet kcd.service 2>/dev/null; then success "Service stopped" fi fi +if systemctl --user is-active --quiet kcd.socket 2>/dev/null; then + step "Stopping existing kcd socket" + if try systemctl --user stop kcd.socket; then + success "Socket stopped" + fi +fi # ── Build ───────────────────────────────────────────────────────────────────── step "Building static binary" @@ -184,25 +190,44 @@ if [[ "${INSTALL_SERVICE}" == true ]]; then warn "Could not install service file — skipping service setup" _systemd_ok=false fi + + # Socket unit for on-demand activation: the first client connection + # starts the daemon automatically. The service file stays installed + # (activation needs it) but is not enabled directly. + if ! try install -m 644 "${REPO_ROOT}/packaging/kcd-user.socket" "${SYSTEMD_DIR}/kcd.socket"; then + warn "Could not install socket file — continuing without on-demand activation" + fi fi if [[ "${_systemd_ok}" == true ]]; then try systemctl --user daemon-reload \ || warn "daemon-reload failed — service file may not be recognised yet" - try systemctl --user enable kcd.service \ - || warn "Could not enable kcd.service — you may need to enable it manually" - - try systemctl --user start kcd.service \ - || warn "Could not start kcd.service — check 'journalctl --user -u kcd -n 30'" - - # Wait briefly and check it actually started - sleep 2 - if systemctl --user is-active --quiet kcd.service 2>/dev/null; then - success "kcd.service enabled and running" + # Prefer socket activation: cold client commands summon the daemon. + if [[ -f "${SYSTEMD_DIR}/kcd.socket" ]] && try systemctl --user enable --now kcd.socket; then + try systemctl --user disable kcd.service 2>/dev/null + sleep 2 + if systemctl --user is-active --quiet kcd.socket 2>/dev/null; then + success "kcd.socket enabled and listening (daemon starts on first use)" + else + warn "kcd.socket is installed but does not appear to be listening." + fi else - warn "kcd.service is installed but does not appear to be running." - printf " Run ${BOLD}journalctl --user -u kcd -n 30${RESET} to see why.\n" + warn "Could not enable kcd.socket — falling back to persistent service" + try systemctl --user enable kcd.service \ + || warn "Could not enable kcd.service — you may need to enable it manually" + + try systemctl --user start kcd.service \ + || warn "Could not start kcd.service — check 'journalctl --user -u kcd -n 30'" + + # Wait briefly and check it actually started + sleep 2 + if systemctl --user is-active --quiet kcd.service 2>/dev/null; then + success "kcd.service enabled and running" + else + warn "kcd.service is installed but does not appear to be running." + printf " Run ${BOLD}journalctl --user -u kcd -n 30${RESET} to see why.\n" + fi fi fi fi From 7db56a1e0099f0df1e3f1341c1529756ebe20bbd Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:40:46 +0300 Subject: [PATCH 16/25] fix(reconnect): persist paired dial targets across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LastIP/LastPort lived only in memory, so every daemon restart left paired auto-dial with nothing to dial — reconnect waited entirely on the phone connecting inbound while we advertise nothing. Store the target in the device state file (validated on load; the file is user-editable) and restore it at startup. Pure state, no new timers. --- internal/daemon/daemon.go | 17 +++++++++-- internal/device/device.go | 8 +++++ internal/device/device_test.go | 55 ++++++++++++++++++++++++++++++++++ internal/device/state.go | 21 +++++++++++++ internal/ipc/handler.go | 19 +++++++----- 5 files changed, 111 insertions(+), 9 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 28bdf24..6a5493e 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -90,6 +90,13 @@ func Run(ctx context.Context, cfg *config.Config) error { dev.SetState(info.State) dev.CertFP = info.CertFP dev.SetLastSeen(info.LastSeen) + // Restore the dial target so paired auto-dial works immediately + // after a restart (validation inside DialTarget — the file is + // user-editable). + if ip, port := info.DialTarget(); ip != nil { + dev.SetLastIP(ip) + dev.SetLastPort(port) + } devices.Add(dev) } } else { @@ -107,14 +114,20 @@ func Run(ctx context.Context, cfg *config.Config) error { devs := devices.List() infos := make([]device.DeviceInfo, 0, len(devs)) for _, dev := range devs { - infos = append(infos, device.DeviceInfo{ + info := device.DeviceInfo{ ID: dev.ID(), Name: dev.Name(), Type: dev.Type, State: dev.State(), CertFP: dev.CertFP, LastSeen: dev.LastSeen(), - }) + LastPort: dev.LastPort(), + } + // net.IP.String() on nil renders "" — store empty instead. + if ip := dev.LastIP(); ip != nil { + info.LastIP = ip.String() + } + infos = append(infos, info) } _ = device.SaveDevices(statePath, infos) } diff --git a/internal/device/device.go b/internal/device/device.go index e206480..98a1f8b 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -461,6 +461,14 @@ func (d *Device) SetLastPort(port int) { d.lastPort = port } +// SetLastIP records a dial target, used when restoring persisted state. +// A nil IP clears the target. +func (d *Device) SetLastIP(ip net.IP) { + d.mu.Lock() + defer d.mu.Unlock() + d.lastIP = ip +} + // ShouldDiscoveryDial reports whether enough time has passed since the last // discovery-triggered dial for this device, and marks this dial if so. It // bounds redial storms to a stale LastIP (DHCP roam) or spoofed sightings. diff --git a/internal/device/device_test.go b/internal/device/device_test.go index 00d2c73..e483814 100644 --- a/internal/device/device_test.go +++ b/internal/device/device_test.go @@ -136,3 +136,58 @@ func TestDevice_BatterySeen(t *testing.T) { t.Error("true 0% reading must count as seen") } } + +func TestDeviceInfoDialTargetRoundTrip(t *testing.T) { + dir := t.TempDir() + path := dir + "/devices.json" + + infos := []DeviceInfo{ + {ID: "paired-1", Name: "Phone", Type: "phone", State: StatePaired, LastIP: "192.168.1.20", LastPort: 1716}, + {ID: "fresh-1", Name: "New", Type: "phone", State: StateUnpaired}, + } + if err := SaveDevices(path, infos); err != nil { + t.Fatalf("SaveDevices failed: %v", err) + } + loaded, err := LoadDevices(path) + if err != nil { + t.Fatalf("LoadDevices failed: %v", err) + } + if len(loaded) != 2 { + t.Fatalf("expected 2 devices, got %d", len(loaded)) + } + + ip, port := loaded[0].DialTarget() + if ip == nil || ip.String() != "192.168.1.20" || port != 1716 { + t.Errorf("dial target not preserved: ip=%v port=%d", ip, port) + } + if ip, port := loaded[1].DialTarget(); ip != nil || port != 0 { + t.Errorf("device without target must yield nil/0, got %v/%d", ip, port) + } +} + +func TestDeviceInfoDialTargetRejectsGarbage(t *testing.T) { + cases := []struct { + name string + info DeviceInfo + wantIP bool + wantPort int + }{ + {"valid", DeviceInfo{LastIP: "10.0.0.5", LastPort: 1716}, true, 1716}, + {"garbage ip", DeviceInfo{LastIP: "not-an-ip", LastPort: 1716}, false, 0}, + {"empty ip", DeviceInfo{LastPort: 1716}, false, 0}, + {"hostname not ip", DeviceInfo{LastIP: "phone.local", LastPort: 1716}, false, 0}, + {"zero port falls back", DeviceInfo{LastIP: "10.0.0.5"}, true, 0}, + {"port out of range", DeviceInfo{LastIP: "10.0.0.5", LastPort: 99999}, true, 0}, + {"negative port", DeviceInfo{LastIP: "10.0.0.5", LastPort: -1}, true, 0}, + {"ipv6", DeviceInfo{LastIP: "fd00::5", LastPort: 1716}, true, 1716}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ip, port := tc.info.DialTarget() + if (ip != nil) != tc.wantIP || port != tc.wantPort { + t.Errorf("DialTarget() = (%v, %d), want ip-present=%v port=%d", + ip, port, tc.wantIP, tc.wantPort) + } + }) + } +} diff --git a/internal/device/state.go b/internal/device/state.go index 200f23b..a0886f8 100644 --- a/internal/device/state.go +++ b/internal/device/state.go @@ -4,6 +4,7 @@ package device import ( "encoding/json" "fmt" + "net" "os" "path/filepath" "time" @@ -81,6 +82,26 @@ type DeviceInfo struct { CertFP string `json:"cert_fp"` LastSeen time.Time `json:"last_seen"` Connected bool `json:"connected"` + // LastIP/LastPort is the dial target from the most recent successful + // connection, so paired auto-dial survives daemon restarts. Both are + // validated on load (the file is user-editable); garbage is dropped. + LastIP string `json:"last_ip,omitempty"` + LastPort int `json:"last_port,omitempty"` +} + +// DialTarget returns the persisted dial target for auto-dial after a +// restart. A garbage IP yields nil (no target); an out-of-range port +// yields 0 and callers fall back to the default port. The state file is +// user-editable, so nothing here is trusted blindly. +func (info DeviceInfo) DialTarget() (net.IP, int) { + ip := net.ParseIP(info.LastIP) + if ip == nil { + return nil, 0 + } + if info.LastPort <= 0 || info.LastPort > 65535 { + return ip, 0 + } + return ip, info.LastPort } // LoadDevices loads known devices from the given JSON file path. diff --git a/internal/ipc/handler.go b/internal/ipc/handler.go index 0710a35..3fafa9d 100644 --- a/internal/ipc/handler.go +++ b/internal/ipc/handler.go @@ -97,13 +97,18 @@ func (h *Handler) saveDevices() { devs := h.devices.List() infos := make([]device.DeviceInfo, 0, len(devs)) for _, dev := range devs { - infos = append(infos, device.DeviceInfo{ - ID: dev.ID(), - Name: dev.Name(), - Type: dev.Type, - State: dev.State(), - CertFP: dev.CertFP, - }) + info := device.DeviceInfo{ + ID: dev.ID(), + Name: dev.Name(), + Type: dev.Type, + State: dev.State(), + CertFP: dev.CertFP, + LastPort: dev.LastPort(), + } + if ip := dev.LastIP(); ip != nil { + info.LastIP = ip.String() + } + infos = append(infos, info) } _ = device.SaveDevices(h.statePath, infos) } From ee01fb422535d0f981f09e2ba80ccf3f542ef100 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:44:27 +0300 Subject: [PATCH 17/25] fix(reconnect): dial the sighted address for paired devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On hearing a paired phone, kcd redialed the remembered LastIP and ignored the fresh source address in the sighting — so a phone that roamed to a new DHCP address triggered dials to the dead old one while the proof of its new address sat in the packet. Dial the sighted ip:port (throttled as before); whoever answers must still present the paired certificate or setup fails, so spoofed sightings cost a dial, never a session. LastIP stays the fallback for silent peers via the backoff loop. --- internal/daemon/transport.go | 23 +++++++++-------------- internal/device/device.go | 5 ++--- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/internal/daemon/transport.go b/internal/daemon/transport.go index 40aff20..4f01dc0 100644 --- a/internal/daemon/transport.go +++ b/internal/daemon/transport.go @@ -201,22 +201,17 @@ func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.Broadcaste } if known && dev.State() == device.StatePaired { - // Paired devices never trust discovery for their dial target: - // the DeviceID/TCPPort in these packets are unauthenticated, so - // a spoofed sighting must not redirect the auto-dial or clobber - // the name. Redial the last known-good address from the - // authenticated exchange instead; after a restart (no LastIP - // yet) wait for the phone's inbound connection. + // A sighting proves the peer is alive at the sighted address, + // so dial it: whoever answers must present the paired + // certificate (CN + pinned fingerprint are verified in + // handleNewConnection) or setup fails. A spoofed sighting can + // only cost a throttled dial, never a session. The persisted + // LastIP remains the fallback for a silent (non-broadcasting) + // peer via the backoff loop. dev.SetLastSeen(time.Now()) if !dev.IsConnected() { - if lastIP := dev.LastIP(); lastIP != nil { - port := dev.LastPort() - if !validDialPort(port) { - port = 1716 - } - if dev.ShouldDiscoveryDial(10 * time.Second) { - go DialDevice(ctx, lastIP, port, body.DeviceID, body.ProtocolVersion, identity, cfg, devices, plugins, localDeviceID, logger) - } + if dev.ShouldDiscoveryDial(10 * time.Second) { + go DialDevice(ctx, ip, tcpPort, body.DeviceID, body.ProtocolVersion, identity, cfg, devices, plugins, localDeviceID, logger) } } return diff --git a/internal/device/device.go b/internal/device/device.go index 98a1f8b..bb6c2a1 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -55,9 +55,8 @@ type Device struct { pairIntentUntil atomic.Int64 // lastDiscoveryDial is when onDeviceFound last spawned a dial for this - // device. It throttles paired auto-redials to a dead LastIP so a phone - // that changed DHCP address (or a spoofed broadcast storm) can't cause - // a dial per announcement. + // device. It throttles sighting-triggered redials so announcements + // (or a spoofed broadcast storm) can't cause a dial per packet. lastDiscoveryDial time.Time // ephemeralDialed marks that this device already received its one From 935da822ce65960668c514635a0bc23681b5b59a Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:45:56 +0300 Subject: [PATCH 18/25] fix(reconnect): restart backoff escalation when the peer roams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sighting from an address other than the failing target proves the old backoff was aimed at a dead address, so later cycles restart at the floor. Same-address sightings leave the counter alone, preserving flap protection for a dying peer. No new timers — pure counter state. --- internal/daemon/transport.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/daemon/transport.go b/internal/daemon/transport.go index 4f01dc0..5e1ce3e 100644 --- a/internal/daemon/transport.go +++ b/internal/daemon/transport.go @@ -210,6 +210,16 @@ func runTransport(ctx context.Context, cfg *tls.Config, bc *discovery.Broadcaste // peer via the backoff loop. dev.SetLastSeen(time.Now()) if !dev.IsConnected() { + // New information beats old backoff: a sighting from an + // address other than the failing target proves the peer + // roamed, so later cycles restart escalation at the floor. + // (The throttled dial below is what hurries this cycle; + // an in-flight backoff keeps its own counter.) Same-address + // sightings leave the counter alone, preserving flap + // protection for a dying peer that keeps announcing. + if lastIP := dev.LastIP(); lastIP == nil || !lastIP.Equal(ip) { + dev.ResetReconnectAttempt() + } if dev.ShouldDiscoveryDial(10 * time.Second) { go DialDevice(ctx, ip, tcpPort, body.DeviceID, body.ProtocolVersion, identity, cfg, devices, plugins, localDeviceID, logger) } From 1bdc6d1a260b9139f3771ee7f4921b422dea9afc Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:49:54 +0300 Subject: [PATCH 19/25] feat(discovery): advertise mDNS for the daemon lifetime, not just while broadcasting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mDNS registration lived inside the UDP broadcast loop, which is stopped by default — so a quiet daemon was invisible to phones on both discovery channels. Registration is responder-only (no idle timers), so it now runs from daemon startup at negligible cost while UDP broadcast keeps its on-demand lifecycle. --- internal/daemon/daemon.go | 5 +++ internal/discovery/discovery.go | 65 ++++++++++++++++++--------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 6a5493e..a616992 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -152,6 +152,11 @@ func Run(ctx context.Context, cfg *config.Config) error { bc := discovery.NewBroadcasterController(identity, 30*time.Second, logger, devices.AllPairedDevicesConnected) + // mDNS advertisement is always on: unlike UDP broadcast it is + // responder-only (zero idle timers), so phones keep a standing + // discovery path even while UDP broadcast is stopped. + go discovery.AdvertiseMDNS(ctx, identity, logger) + // 5. IPC Server handler := ipc.NewHandler(devices, plugins, pairPlugin, statePath, bus, pruneThreshold) diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index 5f4911a..80c8da4 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -84,6 +84,41 @@ func (bc *BroadcasterController) IsRunning() bool { return bc.running } +// AdvertiseMDNS registers the local identity as _kdeconnect._udp until +// ctx ends. Unlike UDP broadcast this is responder-only (it wakes on +// incoming queries), so it stays up for the daemon lifetime at negligible +// idle cost and gives phones a standing discovery path even while UDP +// broadcast is stopped. +func AdvertiseMDNS(ctx context.Context, identityPacket *protocol.Packet, logger *zap.Logger) { + var idBody protocol.IdentityBody + if err := json.Unmarshal(identityPacket.Body, &idBody); err != nil { + logger.Warn("failed to parse identity for mDNS", zap.Error(err)) + return + } + server, err := zeroconf.Register( + idBody.DeviceName, + "_kdeconnect._udp", + "local.", + idBody.TCPPort, + []string{ + "id=" + idBody.DeviceID, + "name=" + idBody.DeviceName, + "type=" + idBody.DeviceType, + "protocol=8", + }, + nil, + ) + if err != nil { + logger.Warn("failed to register mDNS service", zap.Error(err)) + return + } + go func() { + <-ctx.Done() + server.Shutdown() + logger.Info("mDNS service shut down") + }() +} + // Broadcaster sends identity packets over UDP to advertise the local device. type Broadcaster struct { identityPacket *protocol.Packet @@ -103,36 +138,8 @@ func NewBroadcaster(identity *protocol.Packet, interval time.Duration, logger *z // Run periodically sends the identity packet to 255.255.255.255:1716. // If shouldReduce is provided and returns true, the broadcast frequency // is reduced to 60 seconds to save CPU and network resources while idle. +// (mDNS advertisement is no longer tied to this loop — see AdvertiseMDNS.) func (b *Broadcaster) Run(ctx context.Context, shouldReduce func() bool) { - // Register mDNS - var idBody protocol.IdentityBody - if err := json.Unmarshal(b.identityPacket.Body, &idBody); err == nil { - server, err := zeroconf.Register( - idBody.DeviceName, - "_kdeconnect._udp", - "local.", - idBody.TCPPort, - []string{ - "id=" + idBody.DeviceID, - "name=" + idBody.DeviceName, - "type=" + idBody.DeviceType, - "protocol=8", - }, - nil, - ) - if err != nil { - b.logger.Warn("failed to register mDNS service", zap.Error(err)) - } else { - go func() { - <-ctx.Done() - server.Shutdown() - b.logger.Info("mDNS service shut down") - }() - } - } else { - b.logger.Warn("failed to parse identity for mDNS", zap.Error(err)) - } - normalInterval := b.interval reducedInterval := 60 * time.Second From a788bffcd4a68a389d0fcbd264c853a9ba7fb945 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:02:55 +0300 Subject: [PATCH 20/25] feat(discovery): broadcast UDP while a paired device is offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the UDP broadcaster while any paired device is disconnected so roamed or restarted phones can find us back; fully stopped otherwise, so connected steady state keeps zero timers. Starts are reference-counted per owner, so reconnect needs and kcd pair neither start nor stop each other's loop. Driven by connect/disconnect events plus a startup sync — no polling. --- internal/daemon/daemon.go | 29 +++++++++++++ internal/daemon/transport_test.go | 61 ++++++++++++++++++++++++++++ internal/discovery/discovery.go | 36 +++++++++++++--- internal/discovery/discovery_test.go | 52 ++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 internal/discovery/discovery_test.go diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a616992..e0ccee4 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -28,6 +28,19 @@ import ( // version is set via ldflags at build time. var version = "dev" +// syncReconnectBroadcast starts UDP broadcast (reconnect owner) while any +// paired device is offline, and withdraws it otherwise. Pure state, no +// timers — callers invoke it from event handlers and startup. +func syncReconnectBroadcast(ctx context.Context, devices *device.Registry, bc *discovery.BroadcasterController) { + for _, dev := range devices.List() { + if dev.State() == device.StatePaired && !dev.IsConnected() { + bc.StartOwned(ctx, discovery.OwnerReconnect) + return + } + } + bc.StopOwned(discovery.OwnerReconnect) +} + // Run starts the core daemon lifecycle. func Run(ctx context.Context, cfg *config.Config) error { startedAt := time.Now() @@ -157,6 +170,22 @@ func Run(ctx context.Context, cfg *config.Config) error { // discovery path even while UDP broadcast is stopped. go discovery.AdvertiseMDNS(ctx, identity, logger) + // Advertise over UDP while a paired device is offline so it can find + // us back (DHCP roam, restart, mutual loss). Fully stopped otherwise — + // connected steady state keeps zero timers. Ownership is tracked, so + // this neither starts pairing broadcasts nor stops `kcd pair`'s. The + // subscription is channel-driven (no polling); the initial sync covers + // restarts with offline pairs. + go func() { + sub := bus.Subscribe(0, events.TypeDeviceConnected, events.TypeDeviceDisconnected) + defer sub.Close() + sync := func() { syncReconnectBroadcast(ctx, devices, bc) } + sync() + for range sub.C { + sync() + } + }() + // 5. IPC Server handler := ipc.NewHandler(devices, plugins, pairPlugin, statePath, bus, pruneThreshold) diff --git a/internal/daemon/transport_test.go b/internal/daemon/transport_test.go index 9762438..f03487d 100644 --- a/internal/daemon/transport_test.go +++ b/internal/daemon/transport_test.go @@ -1,10 +1,13 @@ package daemon import ( + "context" "testing" "time" "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/discovery" + "github.com/bethropolis/kcd/internal/protocol" "go.uber.org/zap" ) @@ -130,3 +133,61 @@ func TestShouldDiscoveryDialThrottle(t *testing.T) { t.Fatal("zero interval must always allow") } } + +func newTestIdentity() (*protocol.Packet, error) { + return protocol.NewIdentityPacket("test-id", "Test", "desktop", 1716, nil, nil) +} + +func TestSyncReconnectBroadcast(t *testing.T) { + logger := zap.NewNop() + ctx := context.Background() + identity, err := newTestIdentity() + if err != nil { + t.Fatalf("identity: %v", err) + } + newBC := func() *discovery.BroadcasterController { + return discovery.NewBroadcasterController(identity, time.Hour, logger, nil) + } + + // Offline paired device starts the reconnect broadcast. + reg := device.NewRegistry(nil) + offline := device.NewDevice("off", "Phone", "phone", logger) + offline.SetState(device.StatePaired) + reg.Add(offline) + bc := newBC() + syncReconnectBroadcast(ctx, reg, bc) + if !bc.IsRunning() { + t.Error("offline paired device must start reconnect broadcast") + } + + // Unpaired strangers never trigger it. + reg2 := device.NewRegistry(nil) + stranger := device.NewDevice("str", "Stranger", "phone", logger) + reg2.Add(stranger) + bc2 := newBC() + syncReconnectBroadcast(ctx, reg2, bc2) + if bc2.IsRunning() { + t.Error("unpaired devices must not start reconnect broadcast") + bc2.StopOwned(discovery.OwnerReconnect) + } + + // Clearing the registry withdraws the owner again. + reg.Remove("off") + syncReconnectBroadcast(ctx, reg, bc) + if bc.IsRunning() { + t.Error("no offline pairs must stop reconnect broadcast") + bc.StopOwned(discovery.OwnerReconnect) + } + + // Pairing-owned broadcast survives the sync withdrawing reconnect. + bc3 := newBC() + bc3.Start(ctx) // pairing owner + syncReconnectBroadcast(ctx, reg2, bc3) + if !bc3.IsRunning() { + t.Error("sync must not stop pairing-owned broadcast") + } + bc3.Stop() + if bc3.IsRunning() { + t.Error("pairing stop must end an unowned loop") + } +} diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index 80c8da4..8b79fb8 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -14,6 +14,14 @@ import ( "go.uber.org/zap" ) +// Broadcast ownership: pairing mode (`kcd pair`) and the reconnect +// watcher share one loop but must not cancel each other, so starts are +// reference-counted per owner. The loop runs while any owner holds it. +const ( + OwnerPairing = "pairing" + OwnerReconnect = "reconnect" +) + // BroadcasterController manages the broadcast lifecycle — start/stop on demand. // Starts in stopped state. Broadcast is only active while Start() is in effect. type BroadcasterController struct { @@ -25,6 +33,7 @@ type BroadcasterController struct { mu sync.Mutex running bool cancel context.CancelFunc + owners map[string]struct{} } // NewBroadcasterController creates a controller that starts in stopped state. @@ -34,15 +43,28 @@ func NewBroadcasterController(identity *protocol.Packet, interval time.Duration, interval: interval, shouldReduce: shouldReduce, logger: logger.With(zap.String("component", "broadcaster")), + owners: make(map[string]struct{}), } } -// Start launches the UDP broadcaster loop in a background goroutine using a -// child of parentCtx. No-op if already running. +// Start launches the UDP broadcaster loop for the pairing owner. +// No-op if already running (ownership is still recorded). func (bc *BroadcasterController) Start(parentCtx context.Context) { + bc.StartOwned(parentCtx, OwnerPairing) +} + +// Stop withdraws the pairing owner. The loop stops only when no owners +// remain, so a reconnect-driven broadcast survives `kcd pair` exiting. +func (bc *BroadcasterController) Stop() { + bc.StopOwned(OwnerPairing) +} + +// StartOwned launches the loop (if needed) and records owner as needing it. +func (bc *BroadcasterController) StartOwned(parentCtx context.Context, owner string) { bc.mu.Lock() defer bc.mu.Unlock() + bc.owners[owner] = struct{}{} if bc.running { return } @@ -64,12 +86,16 @@ func (bc *BroadcasterController) Start(parentCtx context.Context) { }() } -// Stop cancels the broadcast loop. No-op if not running. -func (bc *BroadcasterController) Stop() { +// StopOwned withdraws owner's need. No-op if the owner holds nothing. +func (bc *BroadcasterController) StopOwned(owner string) { bc.mu.Lock() defer bc.mu.Unlock() - if !bc.running || bc.cancel == nil { + if _, ok := bc.owners[owner]; !ok { + return + } + delete(bc.owners, owner) + if len(bc.owners) > 0 || !bc.running || bc.cancel == nil { return } bc.cancel() diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go new file mode 100644 index 0000000..5011171 --- /dev/null +++ b/internal/discovery/discovery_test.go @@ -0,0 +1,52 @@ +package discovery + +import ( + "context" + "testing" + "time" + + "github.com/bethropolis/kcd/internal/protocol" + "go.uber.org/zap" +) + +func testIdentity(t *testing.T) *protocol.Packet { + t.Helper() + pkt, err := protocol.NewIdentityPacket("test-id", "Test", "desktop", 1716, nil, nil) + if err != nil { + t.Fatalf("NewIdentityPacket failed: %v", err) + } + return pkt +} + +// Pairing and reconnect needs share one loop but must not cancel each +// other: withdrawing one owner leaves the loop up while the other holds it, +// and the loop stops only when the last owner withdraws. +func TestBroadcasterOwners(t *testing.T) { + logger := zap.NewNop() + bc := NewBroadcasterController(testIdentity(t), time.Hour, logger, nil) + ctx := context.Background() + + if bc.IsRunning() { + t.Fatal("controller must start stopped") + } + + bc.Start(ctx) // pairing owner + if !bc.IsRunning() { + t.Fatal("Start must run the loop") + } + bc.StopOwned(OwnerReconnect) // never held: no-op + if !bc.IsRunning() { + t.Error("withdrawing a non-owner must not stop the loop") + } + + bc.StartOwned(ctx, OwnerReconnect) + bc.Stop() // pairing withdraws; reconnect still holds + if !bc.IsRunning() { + t.Error("loop must survive while reconnect owner holds it") + } + + bc.StopOwned(OwnerReconnect) // last owner out + if bc.IsRunning() { + t.Error("loop must stop when no owners remain") + } +} From 230a96c1e8eccf1710947be87bac592071c9647f Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:05:43 +0300 Subject: [PATCH 21/25] docs: describe on-demand discovery and persisted dial targets --- AGENTS.md | 11 ++++++++++- packaging/kcd.example.toml | 8 +++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b9cb4a9..1c1deb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -248,7 +248,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. --- diff --git a/packaging/kcd.example.toml b/packaging/kcd.example.toml index c6f7334..caefb02 100644 --- a/packaging/kcd.example.toml +++ b/packaging/kcd.example.toml @@ -21,9 +21,11 @@ # Default: 1716 # tcp_port = 1716 -# Broadcast is disabled by default. It starts automatically when you run -# `kcd pair` (listen mode) and stops when pairing completes. -# Paired phones reconnect automatically via the last known IP. +# Broadcast is on demand. mDNS advertisement runs always (responder-only, +# negligible cost); UDP broadcast starts automatically when you run +# `kcd pair` (listen mode) and while any paired device is offline, and +# stops otherwise. Paired phones reconnect via the last known address, +# refreshed from sightings and persisted across restarts. # ─── Pairing ────────────────────────────────────────────────────────────────── From 7da88670f1f77b10f59e95534f000098afdda04a Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:57:18 +0300 Subject: [PATCH 22/25] feat(contacts): sync and browse the phone address book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request UID/timestamp lists, fetch vCards for new or changed contacts, and cache per-device .vcf files plus an index sidecar. Syncs trigger on connect and via kcd contacts sync; kcd contacts list reads the cache (empty when never synced — unknown, never zero). Unpairing wipes the cached address book. Defensive handling throughout: UIDs are filename-sanitized and confined, empty UID lists never wipe the cache, response sizes are capped, string and numeric timestamps both parse, display fields are stripped of control characters, cache files are 0600, and the update event carries counts only. --- cmd/kcd/cli_contacts.go | 73 +++ cmd/kcd/main.go | 1 + docs/CLI.md | 26 + docs/CLIENT_GUIDE.md | 14 + docs/IPC_PROTOCOL.md | 58 +- internal/config/plugins.go | 2 + internal/daemon/ipc_routes.go | 3 + internal/daemon/ipc_routes_contacts.go | 51 ++ internal/daemon/plugins.go | 4 + internal/events/bus.go | 1 + internal/ipc/handler.go | 18 + internal/ipc/proto.go | 2 + internal/plugins/contacts/contacts.go | 586 +++++++++++++++++++++ internal/plugins/contacts/contacts_test.go | 300 +++++++++++ packaging/kcd.example.toml | 9 + pkg/client/client.go | 22 + 16 files changed, 1168 insertions(+), 2 deletions(-) create mode 100644 cmd/kcd/cli_contacts.go create mode 100644 internal/daemon/ipc_routes_contacts.go create mode 100644 internal/plugins/contacts/contacts.go create mode 100644 internal/plugins/contacts/contacts_test.go diff --git a/cmd/kcd/cli_contacts.go b/cmd/kcd/cli_contacts.go new file mode 100644 index 0000000..8d810b2 --- /dev/null +++ b/cmd/kcd/cli_contacts.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/urfave/cli/v2" +) + +var contactsCmd = &cli.Command{ + Name: "contacts", + Usage: "Sync and browse the phone address book", + Subcommands: []*cli.Command{ + { + Name: "sync", + Usage: "Request a contacts sync from a 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 + } + if err := cl.ContactsSync(c.Args().Get(0)); err != nil { + return err + } + fmt.Println("Contacts sync requested. Use `kcd watch --events contacts.updated` to see results.") + return nil + }, + }, + { + Name: "list", + Usage: "List cached contacts for a device", + ArgsUsage: "", + 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)) + if err != nil { + return err + } + if len(list) == 0 { + fmt.Println("No cached contacts. Run `kcd contacts sync ` first.") + return nil + } + if c.Bool("json") { + out, _ := json.MarshalIndent(list, "", " ") + fmt.Println(string(out)) + return nil + } + for _, ct := range list { + phones := strings.Join(ct.Phones, ", ") + fmt.Printf("%-30s %s\n", ct.Name, phones) + } + return nil + }, + }, + }, +} diff --git a/cmd/kcd/main.go b/cmd/kcd/main.go index 87f8a8c..05665b1 100644 --- a/cmd/kcd/main.go +++ b/cmd/kcd/main.go @@ -125,6 +125,7 @@ func main() { clipboardCmd, runCmd, smsCmd, + contactsCmd, mprisCmd, volumeCmd, { diff --git a/docs/CLI.md b/docs/CLI.md index 49a3337..12a1efd 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -790,6 +790,32 @@ kcd sms attachment --- +## contacts + +Sync and browse the phone address book. The phone gates this on +`READ_CONTACTS` plus per-device opt-in — unanswered syncs simply produce +no events. Unpairing wipes the cached address book. + +### contacts sync + +Request a sync round (UID/timestamp list, then vCards for new or changed +contacts). Progress arrives as `contacts.updated` events (counts only). + +``` +kcd contacts sync +``` + +### contacts list + +List cached contact summaries (empty when never synced — absent means +unknown). + +``` +kcd contacts list [--json] +``` + +--- + ## volume Control the remote device's audio volume (requires `remotesystemvolume` plugin). diff --git a/docs/CLIENT_GUIDE.md b/docs/CLIENT_GUIDE.md index a95f138..efee2ec 100644 --- a/docs/CLIENT_GUIDE.md +++ b/docs/CLIENT_GUIDE.md @@ -290,6 +290,7 @@ except KeyboardInterrupt: | `share.complete` | File transfer finished | | `mpris.update` | Now-playing state changed (deduplicated — only on real changes) | | `sms.incoming` | SMS/MMS received | +| `contacts.updated` | Contacts sync progress (counts only; call `contacts_list` for data) | | `pair.requested` | Remote device wants to pair | | `ping.received` | Ping from device | @@ -372,6 +373,19 @@ ipc_request(sock, "findmyphone", {"deviceId": dev_id}) # or: ipc_request(sock, "ring", {"deviceId": dev_id}) ``` +### 5.7 Sync Contacts + +```python +ipc_request(sock, "contacts_sync", {"deviceId": dev_id}) +# progress arrives as contacts.updated events (counts only) + +resp = ipc_request(sock, "contacts_list", {"deviceId": dev_id}) +if resp["ok"]: + for c in resp["data"]: + print(c["name"], c.get("phones", [])) +# empty list = never synced (unknown, not zero contacts) +``` + ### 5.7 Lock/Unlock ```python diff --git a/docs/IPC_PROTOCOL.md b/docs/IPC_PROTOCOL.md index 16a040d..aa52927 100644 --- a/docs/IPC_PROTOCOL.md +++ b/docs/IPC_PROTOCOL.md @@ -359,6 +359,37 @@ Request an MMS attachment file from a device. **Response data:** none (attachment arrives via side-channel transfer, emitted as `sms.attachment` event). +#### `contacts_sync` + +Request a contacts sync round from a device (UID/timestamp list, then +vCards for new or changed contacts). + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none (progress arrives as `contacts.updated` events; +requires a connected device). + +#### `contacts_list` + +List cached contact summaries for a device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** array of `{"uid", "name", "phones"?, "emails"?, "timestamp"}`. +Empty when never synced — absent means unknown. Example: + +```json +[{"uid": "1", "name": "Ada Lovelace", "phones": ["+1-555-0100"], "timestamp": 973486597}] +``` + #### `call_mute` Mute an incoming phone call. @@ -1138,7 +1169,26 @@ An MMS attachment has been downloaded. {"filename": "image.jpg", "path": "/tmp/kcd-sms-attachment-...", "thread_id": 42} ``` -### 5.12 Ring Events +### 5.12 Contacts Events + +#### `contacts.updated` + +A contacts sync round made progress. Counts only — no contact content on +the event stream; call `contacts_list` for data. + +**Payload** (uids round): + +```json +{"phase": "uids", "added": 3, "updated": 1, "deleted": 0, "pending": 4} +``` + +**Payload** (vCards round): + +```json +{"phase": "vcards", "stored": 4, "skipped": 0} +``` + +### 5.13 Ring Events #### `ring.received` @@ -1147,7 +1197,7 @@ this daemon to ring). **Payload:** none (`null`) -### 5.13 MPRIS Events +### 5.14 MPRIS Events #### `mpris.update` @@ -1246,6 +1296,8 @@ who may want to implement a full network-level implementation. | `kdeconnect.sms.request_conversations` | SMS | Request conversation list | | `kdeconnect.sms.request_conversation` | SMS | Request a specific thread's messages | | `kdeconnect.sms.request_attachment` | SMS | Request an MMS attachment file | +| `kdeconnect.contacts.request_all_uids_timestamps` | Contacts | Request all contact UIDs + timestamps (empty body) | +| `kdeconnect.contacts.request_vcards_by_uid` | Contacts | Request vCards (`{"uids": [...]}`) | | `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 | @@ -1274,6 +1326,8 @@ plugin processes it and a link to the body struct definition. | `kdeconnect.telephony` | Telephony | `TelephonyBody{Event, ContactName, PhoneNumber, IsCancel}` | | `kdeconnect.sms.messages` | SMS | `SMSMessagesPacket{Version, Messages []SMSMessage}` | | `kdeconnect.sms.attachment_file` | SMS | `AttachmentFileBody{Filename, ThreadID}` | +| `kdeconnect.contacts.response_uids_timestamps` | Contacts | `{"uids": [...], "": }` | +| `kdeconnect.contacts.response_vcards` | Contacts | `{"uids": [...], "": ""}` | | `kdeconnect.findmyphone.request` | FindMyPhone | (empty, triggers ring event) | | `kdeconnect.connectivity_report` | Connectivity | `ConnectivityBody{SignalStrengths map[string]SignalStrength}` | | `kdeconnect.clipboard` | Clipboard | `ClipboardBody{Content string, Timestamp int64}` | diff --git a/internal/config/plugins.go b/internal/config/plugins.go index 2323e8d..994b5b6 100644 --- a/internal/config/plugins.go +++ b/internal/config/plugins.go @@ -22,6 +22,7 @@ type PluginConfig struct { SystemVolume bool `toml:"systemvolume"` PauseMusic bool `toml:"pausemusic"` SMS bool `toml:"sms"` + Contacts bool `toml:"contacts"` Presenter bool `toml:"presenter"` FindThisDevice bool `toml:"findthisdevice"` RemoteSystemVolume bool `toml:"remotesystemvolume"` @@ -113,6 +114,7 @@ func (p *PluginConfig) Defaults() { p.SystemVolume = true p.PauseMusic = true p.SMS = true + p.Contacts = true p.Presenter = true p.FindThisDevice = true p.RemoteSystemVolume = true diff --git a/internal/daemon/ipc_routes.go b/internal/daemon/ipc_routes.go index bcc5977..30e8d8d 100644 --- a/internal/daemon/ipc_routes.go +++ b/internal/daemon/ipc_routes.go @@ -35,6 +35,9 @@ func registerIPCRoutes(handler *ipc.Handler, cfg *config.Config, devices *device if cfg.Plugins.Clipboard { registerClipboardRoutes(handler, devices, plugins) } + if cfg.Plugins.Contacts { + registerContactsRoutes(handler, devices, plugins) + } if cfg.Plugins.RunCommand { registerRunCommandRoutes(handler, devices) } diff --git a/internal/daemon/ipc_routes_contacts.go b/internal/daemon/ipc_routes_contacts.go new file mode 100644 index 0000000..0834aba --- /dev/null +++ b/internal/daemon/ipc_routes_contacts.go @@ -0,0 +1,51 @@ +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/contacts" +) + +func registerContactsRoutes(handler *ipc.Handler, devices *device.Registry, plugins *plugin.Registry) { + handler.Register(ipc.CmdContactsSync, 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("Contacts") + if !ok { + return ipc.Response{OK: false, Error: "contacts plugin not enabled"} + } + dev, ok := devices.Get(p.DeviceID) + if !ok { + return ipc.Response{OK: false, Error: "device not found"} + } + if !dev.IsConnected() { + return ipc.Response{OK: false, Error: "device not connected"} + } + if err := pl.(*contacts.ContactsPlugin).RequestSync(dev); err != nil { + return ipc.Response{OK: false, Error: err.Error()} + } + return ipc.Response{OK: true} + }) + + handler.Register(ipc.CmdContactsList, 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("Contacts") + if !ok { + return ipc.Response{OK: false, Error: "contacts plugin not enabled"} + } + list := pl.(*contacts.ContactsPlugin).List(p.DeviceID) + if list == nil { + list = []contacts.ContactSummary{} + } + data, _ := json.Marshal(list) + return ipc.Response{OK: true, Data: data} + }) +} diff --git a/internal/daemon/plugins.go b/internal/daemon/plugins.go index 1d66d40..9eaedf9 100644 --- a/internal/daemon/plugins.go +++ b/internal/daemon/plugins.go @@ -11,6 +11,7 @@ import ( "github.com/bethropolis/kcd/internal/plugins/battery" "github.com/bethropolis/kcd/internal/plugins/clipboard" "github.com/bethropolis/kcd/internal/plugins/connectivity" + "github.com/bethropolis/kcd/internal/plugins/contacts" "github.com/bethropolis/kcd/internal/plugins/findmyphone" "github.com/bethropolis/kcd/internal/plugins/findthisdevice" "github.com/bethropolis/kcd/internal/plugins/lockdevice" @@ -84,6 +85,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.Contacts { + plugins.Register(contacts.NewContactsPlugin(bus, logger)) + } if cfg.Plugins.RemoteSystemVolume { plugins.Register(remotesystemvolume.NewRemoteSystemVolumePlugin(bus, logger)) } diff --git a/internal/events/bus.go b/internal/events/bus.go index 2a8f5d0..448cbf2 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -38,6 +38,7 @@ const ( TypeSMSIncoming EventType = "sms.incoming" TypeSMSAttachment EventType = "sms.attachment" TypeRingReceived EventType = "ring.received" + TypeContactsUpdated EventType = "contacts.updated" TypeMprisUpdate EventType = "mpris.update" TypeStateSnapshot EventType = "state.snapshot" ) diff --git a/internal/ipc/handler.go b/internal/ipc/handler.go index 3fafa9d..2e320bc 100644 --- a/internal/ipc/handler.go +++ b/internal/ipc/handler.go @@ -7,6 +7,7 @@ import ( "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" "github.com/bethropolis/kcd/internal/plugin" + "github.com/bethropolis/kcd/internal/plugins/contacts" "github.com/bethropolis/kcd/internal/plugins/pair" "github.com/bethropolis/kcd/internal/protocol" ) @@ -182,6 +183,7 @@ func (h *Handler) handleUnpair(payload []byte) Response { return Response{OK: false, Error: "failed to unpair: " + err.Error()} } dev.Disconnect() + h.forgetContacts(p.DeviceID) return Response{OK: true} } @@ -190,11 +192,27 @@ func (h *Handler) handleUnpair(payload []byte) Response { _ = dev.Send(pkt) dev.Disconnect() h.devices.Remove(p.DeviceID) + h.forgetContacts(p.DeviceID) h.saveDevices() return Response{OK: true} } +// forgetContacts drops a device's cached contacts on unpair: revoked trust +// drops the address book. Absent plugin or cache is a no-op. +func (h *Handler) forgetContacts(deviceID string) { + if h.plugins == nil { + return + } + pl, ok := h.plugins.GetByName("Contacts") + if !ok { + return + } + if cpl, ok := pl.(*contacts.ContactsPlugin); ok { + _ = cpl.ForgetDevice(deviceID) + } +} + func (h *Handler) handlePairListen() Response { // Report any device already in StatePairRequestedByPeer WITHOUT // accepting it. The caller (CLI / GUI / script) inspects the candidate diff --git a/internal/ipc/proto.go b/internal/ipc/proto.go index 0221078..e0adfb2 100644 --- a/internal/ipc/proto.go +++ b/internal/ipc/proto.go @@ -33,6 +33,8 @@ const ( CmdSmsRequestConvs = "sms_request_conversations" CmdSmsRequestConv = "sms_request_conversation" CmdSmsRequestAttachment = "sms_request_attachment" + CmdContactsSync = "contacts_sync" + CmdContactsList = "contacts_list" CmdSftpMountLocal = "sftp_mount_local" CmdSftpUnmount = "sftp_unmount" CmdStatus = "status" diff --git a/internal/plugins/contacts/contacts.go b/internal/plugins/contacts/contacts.go new file mode 100644 index 0000000..8c5cf21 --- /dev/null +++ b/internal/plugins/contacts/contacts.go @@ -0,0 +1,586 @@ +package contacts + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/events" + "github.com/bethropolis/kcd/internal/protocol" + "go.uber.org/zap" +) + +// KDE Connect contacts packet types (see kdeconnect-kde +// plugins/contacts/contactsplugin.h and the Android ContactsPlugin). +const ( + PacketTypeContactsRequestUIDs = "kdeconnect.contacts.request_all_uids_timestamps" + PacketTypeContactsRequestVCards = "kdeconnect.contacts.request_vcards_by_uid" + PacketTypeContactsResponseUIDs = "kdeconnect.contacts.response_uids_timestamps" + PacketTypeContactsResponseVCards = "kdeconnect.contacts.response_vcards" +) + +const ( + // maxContactUIDs bounds a single uids response (address books are in + // the hundreds; this is two orders of magnitude of headroom). + maxContactUIDs = 20000 + // maxVCardBytes caps one vCard (text contact cards are ~1KB). + maxVCardBytes = 64 * 1024 + // maxSyncBytes caps a whole vcards response before anything hits disk. + maxSyncBytes = 64 * 1024 * 1024 + // vcardChunkSize bounds our outbound request packets. + vcardChunkSize = 100 + // maxDisplayLen truncates contact fields shown to clients. + maxDisplayLen = 256 +) + +// uidSafeChars restricts phone-provided contact UIDs (Android LOOKUP_KEYs) +// to filename-safe characters so they can't escape the cache directory. +var uidSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +// sanitizeUID strips everything but alphanumerics, dot, underscore and +// hyphen. Empty results are rejected by the caller. +func sanitizeUID(uid string) string { + return uidSafeChars.ReplaceAllString(uid, "_") +} + +// ContactSummary is the parsed, display-safe view of one contact. +type ContactSummary struct { + UID string `json:"uid"` + Name string `json:"name"` + Phones []string `json:"phones,omitempty"` + Emails []string `json:"emails,omitempty"` + Timestamp int64 `json:"timestamp"` +} + +// indexEntry is the persisted per-contact record (index.json sidecar, so +// listing never reparses hundreds of vCards). Fetched marks that the vCard +// round completed; entries with only a timestamp are pending fetch. +type indexEntry struct { + Timestamp int64 `json:"timestamp"` + Fetched bool `json:"fetched"` + Name string `json:"name"` + Phones []string `json:"phones,omitempty"` + Emails []string `json:"emails,omitempty"` +} + +// ContactsPlugin syncs the phone address book: it requests UID/timestamp +// lists, fetches vCards for new or changed contacts, caches them per +// device, and deletes stale entries. +type ContactsPlugin struct { + bus *events.Bus + logger *zap.Logger + baseDir string + + // mu serializes sync processing across devices. Syncs are rare; + // one lock avoids per-device lock bookkeeping. + mu sync.Mutex +} + +// NewContactsPlugin creates a contacts plugin caching under +// $XDG_DATA_HOME/kcd/contacts (0600 files, 0700 dirs). +func NewContactsPlugin(bus *events.Bus, logger *zap.Logger) *ContactsPlugin { + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + home, _ := os.UserHomeDir() + dataHome = filepath.Join(home, ".local", "share") + } + baseDir := filepath.Join(dataHome, "kcd", "contacts") + _ = os.MkdirAll(baseDir, 0700) + + return &ContactsPlugin{ + bus: bus, + logger: logger.With(zap.String("plugin", "contacts")), + baseDir: baseDir, + } +} + +func (p *ContactsPlugin) Name() string { return "Contacts" } + +func (p *ContactsPlugin) Timeout() time.Duration { return 5 * time.Second } + +func (p *ContactsPlugin) IncomingTypes() []string { + return []string{PacketTypeContactsResponseUIDs, PacketTypeContactsResponseVCards} +} + +func (p *ContactsPlugin) OutgoingTypes() []string { + return []string{PacketTypeContactsRequestUIDs, PacketTypeContactsRequestVCards} +} + +// OnConnect starts a sync for freshly connected paired devices, mirroring +// upstream's connected() -> synchronizeRemoteWithLocal(). +func (p *ContactsPlugin) OnConnect(dev device.Sender) { + if dev.State() != device.StatePaired { + return + } + if err := p.RequestSync(dev); err != nil { + p.logger.Debug("contacts: initial sync request failed", zap.Error(err)) + } +} + +func (p *ContactsPlugin) OnDisconnect(dev device.Sender) {} + +// Handle routes response packets; all parsing and disk I/O runs in a +// worker goroutine so Handle returns immediately (rule 9). +func (p *ContactsPlugin) Handle(ctx context.Context, dev device.Sender, pkt *protocol.Packet) error { + switch pkt.Type { + case PacketTypeContactsResponseUIDs: + body := append([]byte(nil), pkt.Body...) + go p.handleUIDsResponse(dev, body) + return nil + case PacketTypeContactsResponseVCards: + body := append([]byte(nil), pkt.Body...) + devID := dev.ID() + go p.handleVCardsResponse(devID, body) + return nil + default: + return nil + } +} + +// RequestSync asks the phone for all contact UIDs and timestamps, which +// starts the sync round trips. Responses arrive async via Handle. +func (p *ContactsPlugin) RequestSync(dev device.Sender) error { + pkt, err := protocol.NewPacket(PacketTypeContactsRequestUIDs, nil) + if err != nil { + return err + } + return dev.Send(pkt) +} + +// requestVCards asks for vCards of the given UIDs, chunked to bound +// outbound packet size. +func (p *ContactsPlugin) requestVCards(dev device.Sender, uids []string) error { + for _, chunk := range chunkUIDs(uids, vcardChunkSize) { + body := map[string]any{"uids": chunk} + pkt, err := protocol.NewPacket(PacketTypeContactsRequestVCards, body) + if err != nil { + return err + } + if err := dev.Send(pkt); err != nil { + return err + } + } + return nil +} + +func chunkUIDs(uids []string, size int) [][]string { + var chunks [][]string + for len(uids) > 0 { + n := size + if len(uids) < n { + n = len(uids) + } + chunks = append(chunks, uids[:n]) + uids = uids[n:] + } + return chunks +} + +// cacheDir resolves the cache dir for a device without creating it. +// The device ID comes from our own registry, but confine defensively. +func (p *ContactsPlugin) cacheDir(deviceID string) (string, error) { + safe := sanitizeUID(deviceID) + if safe == "" { + return "", fmt.Errorf("contacts: unusable device id") + } + dir := filepath.Join(p.baseDir, safe) + if rel, err := filepath.Rel(p.baseDir, dir); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("contacts: device dir escapes cache") + } + return dir, nil +} + +// deviceDir returns the cache dir for a device, creating it (0700). +func (p *ContactsPlugin) deviceDir(deviceID string) (string, error) { + dir, err := p.cacheDir(deviceID) + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0700); err != nil { + return "", fmt.Errorf("contacts: create dir: %w", err) + } + return dir, nil +} + +// contactPath resolves the vcf file for a UID inside dir, confined to dir. +func contactPath(dir, uid string) (string, error) { + safe := sanitizeUID(uid) + if safe == "" { + return "", fmt.Errorf("contacts: unusable uid") + } + path := filepath.Join(dir, safe+".vcf") + if rel, err := filepath.Rel(dir, path); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("contacts: uid escapes cache") + } + return path, nil +} + +// loadIndex reads the sidecar index (missing file = empty cache, not error). +func loadIndex(dir string) map[string]indexEntry { + idx := make(map[string]indexEntry) + data, err := os.ReadFile(filepath.Join(dir, "index.json")) + if err != nil { + return idx + } + _ = json.Unmarshal(data, &idx) + if idx == nil { + idx = make(map[string]indexEntry) + } + return idx +} + +// saveIndex persists the sidecar index (0600). +func saveIndex(dir string, idx map[string]indexEntry) error { + data, err := json.MarshalIndent(idx, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "index.json"), data, 0600) +} + +// coerceTimestamp parses Android's string-encoded timestamps as well as +// plain JSON numbers (the header doc shows ints). Garbage yields 0, which +// forces a re-fetch — the safe direction, never a false "unchanged". +func coerceTimestamp(raw json.RawMessage) int64 { + var asAny any + if err := json.Unmarshal(raw, &asAny); err != nil { + return 0 + } + switch v := asAny.(type) { + case string: + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return 0 + } + return n + case float64: + if v < 0 { + return 0 + } + return int64(v) + default: + return 0 + } +} + +// handleUIDsResponse diffs the phone's UID/timestamp list against the local +// cache, deletes stale entries, and requests vCards for new/changed ones. +// dev is the live sender (device.Send is channel-safe across goroutines). +func (p *ContactsPlugin) handleUIDsResponse(dev device.Sender, body []byte) { + deviceID := dev.ID() + + // Cache diff under lock; the vCard request round below does network + // I/O and must not hold it. + toFetch, added, updated, deleted := func() ([]string, int, int, int) { + p.mu.Lock() + defer p.mu.Unlock() + + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + p.logger.Debug("contacts: malformed uids response", zap.Error(err)) + return nil, 0, 0, 0 + } + uidsRaw, ok := raw["uids"] + if !ok { + p.logger.Debug("contacts: uids response without uids key") + return nil, 0, 0, 0 + } + var uids []string + if err := json.Unmarshal(uidsRaw, &uids); err != nil { + p.logger.Debug("contacts: malformed uids list", zap.Error(err)) + return nil, 0, 0, 0 + } + if len(uids) > maxContactUIDs { + p.logger.Warn("contacts: uids response exceeds cap, refusing", + zap.Int("count", len(uids))) + return nil, 0, 0, 0 + } + + dir, err := p.deviceDir(deviceID) + if err != nil { + p.logger.Warn("contacts: bad device dir", zap.Error(err)) + return nil, 0, 0, 0 + } + idx := loadIndex(dir) + + seen := make(map[string]bool, len(uids)) + var toFetch []string + var added, updated int + for _, uid := range uids { + if uid == "" { + continue + } + seen[uid] = true + ts := coerceTimestamp(raw[uid]) + entry, known := idx[uid] + if !known { + added++ + toFetch = append(toFetch, uid) + } else if entry.Timestamp != ts { + updated++ + toFetch = append(toFetch, uid) + } + // Record the authoritative timestamp now (the vCards round + // carries no stamps); the summary fields arrive with the vCard. + entry.Timestamp = ts + idx[uid] = entry + } + + // Delete locally-known contacts the phone no longer reports — but + // never on an empty list: a buggy/empty response must not wipe + // the cache. + var deleted int + if len(uids) > 0 { + for uid := range idx { + if !seen[uid] { + if path, err := contactPath(dir, uid); err == nil { + _ = os.Remove(path) + } + delete(idx, uid) + deleted++ + } + } + } + if err := saveIndex(dir, idx); err != nil { + p.logger.Warn("contacts: failed to save index", zap.Error(err)) + } + return toFetch, added, updated, deleted + }() + + if len(toFetch) > 0 { + if err := p.requestVCards(dev, toFetch); err != nil { + p.logger.Warn("contacts: failed to request vcards", + zap.String("device_id", deviceID), + zap.Error(err)) + } + } + p.emit(deviceID, map[string]any{ + "phase": "uids", + "added": added, + "updated": updated, + "deleted": deleted, + "pending": len(toFetch), + }) +} + +// handleVCardsResponse stores one .vcf per UID, refreshes the index, and +// reports counts (never contact content) on the event bus. +func (p *ContactsPlugin) handleVCardsResponse(deviceID string, body []byte) { + p.mu.Lock() + defer p.mu.Unlock() + + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + p.logger.Debug("contacts: malformed vcards response", zap.Error(err)) + return + } + uidsRaw, ok := raw["uids"] + if !ok { + p.logger.Debug("contacts: vcards response without uids key") + return + } + var uids []string + if err := json.Unmarshal(uidsRaw, &uids); err != nil { + p.logger.Debug("contacts: malformed vcards uids list", zap.Error(err)) + return + } + if len(uids) > maxContactUIDs { + p.logger.Warn("contacts: vcards response exceeds cap, refusing", + zap.Int("count", len(uids))) + return + } + + dir, err := p.deviceDir(deviceID) + if err != nil { + p.logger.Warn("contacts: bad device dir", zap.Error(err)) + return + } + idx := loadIndex(dir) + + var stored, skipped int + var total int64 + for _, uid := range uids { + if uid == "" { + continue + } + vRaw, ok := raw[uid] + if !ok { + continue + } + var vcard string + if err := json.Unmarshal(vRaw, &vcard); err != nil { + p.logger.Debug("contacts: non-string vcard, skipping", + zap.String("uid", sanitizeUID(uid))) + skipped++ + continue + } + if len(vcard) > maxVCardBytes { + p.logger.Warn("contacts: oversized vcard, skipping", + zap.String("uid", sanitizeUID(uid)), + zap.Int("bytes", len(vcard))) + skipped++ + continue + } + total += int64(len(vcard)) + if total > maxSyncBytes { + p.logger.Warn("contacts: sync exceeds total cap, stopping") + break + } + path, err := contactPath(dir, uid) + if err != nil { + skipped++ + continue + } + if err := os.WriteFile(path, []byte(vcard), 0600); err != nil { + p.logger.Warn("contacts: failed to store vcard", zap.Error(err)) + skipped++ + continue + } + name, phones, emails := parseVCard(vcard) + // The vCards round carries no per-UID stamps — keep the timestamp + // established by the uids round and mark the entry fetched. + entry := idx[uid] + entry.Fetched = true + entry.Name = name + entry.Phones = phones + entry.Emails = emails + idx[uid] = entry + stored++ + } + if err := saveIndex(dir, idx); err != nil { + p.logger.Warn("contacts: failed to save index", zap.Error(err)) + } + + p.emit(deviceID, map[string]any{ + "phase": "vcards", + "stored": stored, + "skipped": skipped, + }) +} + +// List returns cached contact summaries sorted by name. Entries whose +// vCard hasn't arrived yet are skipped; empty when never synced — absent +// means unknown, never a fabricated entry. +func (p *ContactsPlugin) List(deviceID string) []ContactSummary { + p.mu.Lock() + defer p.mu.Unlock() + + dir, err := p.cacheDir(deviceID) + if err != nil { + return nil + } + idx := loadIndex(dir) + out := make([]ContactSummary, 0, len(idx)) + for uid, entry := range idx { + if !entry.Fetched { + continue + } + out = append(out, ContactSummary{ + UID: uid, + Name: entry.Name, + Phones: entry.Phones, + Emails: entry.Emails, + Timestamp: entry.Timestamp, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + return out[i].UID < out[j].UID + }) + return out +} + +// ForgetDevice deletes a device's cached contacts. Called on unpair: +// revoked trust drops the address book; re-pair re-syncs from scratch. +func (p *ContactsPlugin) ForgetDevice(deviceID string) error { + p.mu.Lock() + defer p.mu.Unlock() + + dir, err := p.cacheDir(deviceID) + if err != nil { + return err + } + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("contacts: forget device: %w", err) + } + return nil +} + +func (p *ContactsPlugin) emit(deviceID string, payload map[string]any) { + if p.bus == nil { + return + } + p.bus.Publish(events.TypeContactsUpdated, deviceID, payload) +} + +// cleanDisplayValue strips control characters (terminal-escape injection +// via contact names is a classic) and truncates overlong fields. +func cleanDisplayValue(s string) string { + s = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, s) + s = strings.TrimSpace(s) + if len(s) > maxDisplayLen { + s = s[:maxDisplayLen] + } + return s +} + +// parseVCard extracts display fields from vCard 3.0 text with a stdlib +// line scan: unfold continuations, split Name:Value, drop parameters +// (TEL;TYPE=CELL:...). Returns the first FN and all TEL/EMAIL values. +func parseVCard(vcard string) (name string, phones, emails []string) { + // Normalize newlines, then unfold continuation lines (leading SP/HT). + raw := strings.ReplaceAll(vcard, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + var lines []string + for _, line := range strings.Split(raw, "\n") { + if line == "" { + continue + } + if (line[0] == ' ' || line[0] == '\t') && len(lines) > 0 { + lines[len(lines)-1] += line[1:] + continue + } + lines = append(lines, line) + } + for _, line := range lines { + sep := strings.IndexByte(line, ':') + if sep < 0 { + continue + } + field := strings.ToUpper(line[:sep]) + if i := strings.IndexByte(field, ';'); i >= 0 { + field = field[:i] + } + value := cleanDisplayValue(line[sep+1:]) + if value == "" { + continue + } + switch field { + case "FN": + if name == "" { + name = value + } + case "TEL": + phones = append(phones, value) + case "EMAIL": + emails = append(emails, value) + } + } + return name, phones, emails +} diff --git a/internal/plugins/contacts/contacts_test.go b/internal/plugins/contacts/contacts_test.go new file mode 100644 index 0000000..8a1e17e --- /dev/null +++ b/internal/plugins/contacts/contacts_test.go @@ -0,0 +1,300 @@ +package contacts + +import ( + "crypto/x509" + "encoding/json" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bethropolis/kcd/internal/device" + "github.com/bethropolis/kcd/internal/protocol" + "go.uber.org/zap/zaptest" +) + +// fakeSender captures outbound packets for round-trip tests. +type fakeSender struct { + id string + sent []*protocol.Packet +} + +func (f *fakeSender) ID() string { return f.id } +func (f *fakeSender) Name() string { return "Fake" } +func (f *fakeSender) SetName(name string) {} +func (f *fakeSender) State() device.PairingState { return device.StatePaired } +func (f *fakeSender) SetState(state device.PairingState) {} +func (f *fakeSender) Send(p *protocol.Packet) error { f.sent = append(f.sent, p); return nil } +func (f *fakeSender) IsConnected() bool { return true } +func (f *fakeSender) RemoteIP() net.IP { return nil } +func (f *fakeSender) PeerCert() *x509.Certificate { return nil } +func (f *fakeSender) HasCapability(cap string) bool { return false } +func (f *fakeSender) UpdateBattery(charge int, ch bool) {} +func (f *fakeSender) GetBattery() (int, bool) { return 0, false } + +func testPlugin(t *testing.T) *ContactsPlugin { + t.Helper() + t.Setenv("XDG_DATA_HOME", t.TempDir()) + return NewContactsPlugin(nil, zaptest.NewLogger(t)) +} + +func TestParseVCard(t *testing.T) { + vcard := "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nTEL;TYPE=CELL:+1-555-0100\r\nTEL;TYPE=HOME:+1-555-0101\r\nEMAIL:ada@example.com\r\nREV:973486597\r\nEND:VCARD" + name, phones, emails := parseVCard(vcard) + if name != "Ada Lovelace" { + t.Errorf("name = %q", name) + } + if len(phones) != 2 || phones[0] != "+1-555-0100" { + t.Errorf("phones = %v", phones) + } + if len(emails) != 1 || emails[0] != "ada@example.com" { + t.Errorf("emails = %v", emails) + } +} + +func TestParseVCardFoldingAndControls(t *testing.T) { + // Folded FN line + control char + overlong value. + vcard := "BEGIN:VCARD\nFN:John\x00 Smi\n th\nTEL:123\nEND:VCARD" + name, phones, _ := parseVCard(vcard) + if name != "John Smith" { + t.Errorf("folded/controls name = %q", name) + } + if len(phones) != 1 { + t.Errorf("phones = %v", phones) + } + + long := "FN:" + strings.Repeat("x", 500) + name, _, _ = parseVCard("BEGIN:VCARD\n" + long + "\nEND:VCARD") + if len(name) != maxDisplayLen { + t.Errorf("name not truncated: len=%d", len(name)) + } +} + +func TestCoerceTimestamp(t *testing.T) { + cases := []struct { + name string + raw string + want int64 + }{ + {"string", `"973486597"`, 973486597}, + {"string spaces", `" 42 "`, 42}, + {"int", `973486597`, 973486597}, + {"garbage string", `"soon"`, 0}, + {"bool", `true`, 0}, + {"null", `null`, 0}, + {"negative float", `-5`, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := coerceTimestamp(json.RawMessage(tc.raw)); got != tc.want { + t.Errorf("coerceTimestamp(%s) = %d, want %d", tc.raw, got, tc.want) + } + }) + } +} + +func TestSanitizeUID(t *testing.T) { + if got := sanitizeUID("1234iabc"); got != "1234iabc" { + t.Errorf("plain uid mangled: %q", got) + } + if got := sanitizeUID("../../etc"); strings.ContainsAny(got, "/\\") { + t.Errorf("traversal not neutralized: %q", got) + } + if sanitizeUID("") != "" { + t.Error("empty uid must stay empty (caller rejects)") + } +} + +func TestContactPathConfined(t *testing.T) { + dir := t.TempDir() + if _, err := contactPath(dir, "../evil"); err == nil { + // sanitized to plain underscores — must still resolve inside dir + t.Log("traversal neutralized by sanitizer") + } + p, err := contactPath(dir, "abc123") + if err != nil { + t.Fatalf("valid uid rejected: %v", err) + } + if filepath.Dir(p) != dir { + t.Errorf("path escapes dir: %q", p) + } + if _, err := contactPath(dir, ""); err == nil { + t.Error("empty uid must be rejected") + } +} + +func TestChunkUIDs(t *testing.T) { + var uids []string + for i := 0; i < 250; i++ { + uids = append(uids, string(rune('a'+i%26))+strings.Repeat("x", i%5)) + } + chunks := chunkUIDs(uids, vcardChunkSize) + if len(chunks) != 3 { + t.Fatalf("expected 3 chunks, got %d", len(chunks)) + } + for _, c := range chunks { + if len(c) > vcardChunkSize { + t.Errorf("chunk exceeds size: %d", len(c)) + } + } + if len(chunkUIDs(nil, vcardChunkSize)) != 0 { + t.Error("nil must yield no chunks") + } +} + +// Full round trip: uids response -> vcard request emitted -> vcards +// response -> files + index + List output. +func TestSyncRoundTrip(t *testing.T) { + p := testPlugin(t) + dev := &fakeSender{id: "dev1"} + + uidsBody, _ := json.Marshal(map[string]any{ + "uids": []string{"u1", "u2"}, + "u1": "100", + "u2": 200, + }) + p.handleUIDsResponse(dev, uidsBody) + + if len(dev.sent) != 1 { + t.Fatalf("expected 1 vcard request, got %d", len(dev.sent)) + } + if dev.sent[0].Type != PacketTypeContactsRequestVCards { + t.Errorf("wrong request type: %s", dev.sent[0].Type) + } + + vcardsBody, _ := json.Marshal(map[string]any{ + "uids": []string{"u1", "u2"}, + "u1": "BEGIN:VCARD\nFN:Alice\nTEL:111\nEND:VCARD", + "u2": "BEGIN:VCARD\nFN:Bob\nEMAIL:bob@x.y\nEND:VCARD", + }) + p.handleVCardsResponse("dev1", vcardsBody) + + list := p.List("dev1") + if len(list) != 2 { + t.Fatalf("expected 2 contacts, got %d", len(list)) + } + // Sorted by name. + if list[0].Name != "Alice" || list[1].Name != "Bob" { + t.Errorf("bad sort/content: %+v", list) + } + if len(list[0].Phones) != 1 || list[0].Phones[0] != "111" { + t.Errorf("phones wrong: %+v", list[0]) + } + // Timestamps preserved from the uids round (string and int forms). + if list[0].Timestamp != 100 || list[1].Timestamp != 200 { + t.Errorf("timestamps wrong: %+v", list) + } + + // Files on disk, 0600. + for _, uid := range []string{"u1", "u2"} { + path, err := contactPath(filepath.Join(p.baseDir, "dev1"), uid) + if err != nil { + t.Fatal(err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("missing vcf for %s: %v", uid, err) + } + if fi.Mode().Perm() != 0600 { + t.Errorf("vcf perms = %o, want 600", fi.Mode().Perm()) + } + } +} + +// Second sync with changed timestamp refetches; unchanged contacts don't. +func TestSyncDiff(t *testing.T) { + p := testPlugin(t) + dev := &fakeSender{id: "dev1"} + + first, _ := json.Marshal(map[string]any{"uids": []string{"u1"}, "u1": "100"}) + p.handleUIDsResponse(dev, first) + if len(dev.sent) != 1 { + t.Fatalf("first sync must request vcard, sent=%d", len(dev.sent)) + } + vcards, _ := json.Marshal(map[string]any{ + "uids": []string{"u1"}, + "u1": "BEGIN:VCARD\nFN:Alice\nEND:VCARD", + }) + p.handleVCardsResponse("dev1", vcards) + + dev.sent = nil + same, _ := json.Marshal(map[string]any{"uids": []string{"u1"}, "u1": "100"}) + p.handleUIDsResponse(dev, same) + if len(dev.sent) != 0 { + t.Error("unchanged contact must not be refetched") + } + + dev.sent = nil + changed, _ := json.Marshal(map[string]any{"uids": []string{"u1"}, "u1": "101"}) + p.handleUIDsResponse(dev, changed) + if len(dev.sent) != 1 { + t.Error("changed timestamp must trigger refetch") + } +} + +// Stale entries are deleted on non-empty responses, never on empty ones. +func TestSyncDeleteGuard(t *testing.T) { + p := testPlugin(t) + dev := &fakeSender{id: "dev1"} + + seed, _ := json.Marshal(map[string]any{"uids": []string{"u1"}, "u1": "1"}) + p.handleUIDsResponse(dev, seed) + vcards, _ := json.Marshal(map[string]any{ + "uids": []string{"u1"}, + "u1": "BEGIN:VCARD\nFN:Gone\nEND:VCARD", + }) + p.handleVCardsResponse("dev1", vcards) + if len(p.List("dev1")) != 1 { + t.Fatal("seed failed") + } + + // Empty response must not wipe. + empty, _ := json.Marshal(map[string]any{"uids": []string{}}) + p.handleUIDsResponse(dev, empty) + if len(p.List("dev1")) != 1 { + t.Error("empty uids response must not delete cache") + } + + // Non-empty response without u1 deletes it. + without, _ := json.Marshal(map[string]any{"uids": []string{"u2"}, "u2": "2"}) + p.handleUIDsResponse(dev, without) + for _, ct := range p.List("dev1") { + if ct.UID == "u1" { + t.Error("stale contact must be deleted on non-empty response") + } + } +} + +func TestForgetDevice(t *testing.T) { + p := testPlugin(t) + dev := &fakeSender{id: "dev1"} + + seed, _ := json.Marshal(map[string]any{"uids": []string{"u1"}, "u1": "1"}) + p.handleUIDsResponse(dev, seed) + vcards, _ := json.Marshal(map[string]any{ + "uids": []string{"u1"}, + "u1": "BEGIN:VCARD\nFN:Gone\nEND:VCARD", + }) + p.handleVCardsResponse("dev1", vcards) + if len(p.List("dev1")) != 1 { + t.Fatal("seed failed") + } + + if err := p.ForgetDevice("dev1"); err != nil { + t.Fatalf("ForgetDevice failed: %v", err) + } + if len(p.List("dev1")) != 0 { + t.Error("cache must be empty after forget") + } + if _, err := os.Stat(filepath.Join(p.baseDir, "dev1")); !os.IsNotExist(err) { + t.Error("device dir must be removed") + } +} + +func TestListEmptyWhenNeverSynced(t *testing.T) { + p := testPlugin(t) + if list := p.List("ghost"); len(list) != 0 { + t.Errorf("never-synced device must list empty, got %v", list) + } +} diff --git a/packaging/kcd.example.toml b/packaging/kcd.example.toml index caefb02..dfff97c 100644 --- a/packaging/kcd.example.toml +++ b/packaging/kcd.example.toml @@ -148,6 +148,15 @@ systemvolume = true # See the [sms] section below for notification settings. sms = true +# Sync the phone address book (vCards cached under +# ~/.local/share/kcd/contacts//). +# Commands: +# kcd contacts sync — Request a sync (results via `kcd watch --events contacts.updated`) +# kcd contacts list [--json] — List cached contacts +# The phone gates this on READ_CONTACTS plus per-device opt-in; unpairing +# wipes the cached address book. +contacts = true + # Presenter remote — control slideshow presentations from the phone. # Gyroscope-based pointer: requires xdotool (X11) or ydotool (Wayland). # Screen size detection: requires xdpyinfo (X11) or wlr-randr (Wayland). diff --git a/pkg/client/client.go b/pkg/client/client.go index f0b3bd4..9968929 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -12,6 +12,7 @@ import ( "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/events" "github.com/bethropolis/kcd/internal/ipc" + "github.com/bethropolis/kcd/internal/plugins/contacts" ) // Client connects to the kcd daemon via Unix socket. @@ -348,6 +349,27 @@ func (c *Client) SmsRequestAttachment(deviceID string, partID int64, uniqueIdent return err } +// ContactsSync asks the daemon to start a contacts sync round with a device. +// Results arrive async via the contacts.updated event. +func (c *Client) ContactsSync(deviceID string) error { + _, err := c.Call(ipc.CmdContactsSync, ipc.DevicePayload{DeviceID: deviceID}) + return err +} + +// ContactsList returns cached contact summaries for a device (empty when +// never synced — absent means unknown). +func (c *Client) ContactsList(deviceID string) ([]contacts.ContactSummary, error) { + res, err := c.Call(ipc.CmdContactsList, ipc.DevicePayload{DeviceID: deviceID}) + if err != nil { + return nil, err + } + var list []contacts.ContactSummary + if err := json.Unmarshal(res.Data, &list); err != nil { + return nil, fmt.Errorf("decode contacts: %w", err) + } + return list, nil +} + // MprisStatus returns MPRIS plugin debug information. func (c *Client) MprisStatus() (*ipc.MprisStatusResponse, error) { res, err := c.Call(ipc.CmdMprisStatus, nil) From 9b3bf1ef40b3aade29ef1a1317d9a76246ab99aa Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:32:09 +0300 Subject: [PATCH 23/25] feat(packaging): ship socket activation in distro packages Add packaging/kcd-pkg.socket (distro variant without the graphical-session tie-in) and include both socket units in the tarballs, deb/rpm contents, and AUR package body. kcd-pkg.service no longer declares RuntimeDirectory=kcd so stopping the service can't tear the socket out from under the socket unit; the daemon self-creates the dir in non-socket mode. Drop the AUR post-install hook (packaging/kcd-bin.install); post-install enablement is documented in the README and release notes instead. Point deb/rpm postinstall and the uninstall script at kcd.socket, and document the one-command wake (paired phones reconnect on their own afterwards). Also fix the Hyprland keybind snippet for the Lua config format (hl.bind) and correct stale constructor signatures in AGENTS.md. --- .goreleaser.yaml | 7 +++++++ AGENTS.md | 7 ++++--- README.md | 35 ++++++++++++++++----------------- packaging/kcd-bin.install | 21 -------------------- packaging/kcd-pkg.service | 9 ++++++--- packaging/kcd-pkg.socket | 41 +++++++++++++++++++++++++++++++++++++++ scripts/postinstall.sh | 7 +++++-- scripts/uninstall.sh | 23 +++++++++++++++++++++- 8 files changed, 102 insertions(+), 48 deletions(-) delete mode 100644 packaging/kcd-bin.install create mode 100644 packaging/kcd-pkg.socket diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6f67a37..9e65cf5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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 @@ -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" @@ -170,6 +173,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 @@ -255,6 +261,7 @@ release: **Arch Linux:** ```bash yay -S kcd-bin + systemctl --user enable --now kcd.socket ``` **Debian / Ubuntu:** diff --git a/AGENTS.md b/AGENTS.md index 1c1deb6..0e0ac4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | --- @@ -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 diff --git a/README.md b/README.md index 8e34abb..5fa1c02 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 up — enable the socket and the daemon starts on first use: +is already set up, you need to enable the socket and the daemon starts on first use: ```bash 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 @@ -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`): diff --git a/packaging/kcd-bin.install b/packaging/kcd-bin.install deleted file mode 100644 index e46e7c8..0000000 --- a/packaging/kcd-bin.install +++ /dev/null @@ -1,21 +0,0 @@ -post_install() { - echo "=================================================================" - echo " kcd installed successfully." - echo "=================================================================" - echo "" - echo " To enable the user-level service (recommended), run:" - echo " systemctl --user enable --now kcd" - echo "" - echo " To enable the system-level service template, run:" - echo " sudo systemctl enable --now kcd@\$USER" - echo "" - echo " If you use a firewall, allow the KDE Connect service by name:" - echo " UFW: sudo ufw allow kcd" - echo " Firewalld: sudo firewall-cmd --permanent --add-service=kcd" - echo " sudo firewall-cmd --reload" - echo "=================================================================" -} - -post_upgrade() { - post_install -} diff --git a/packaging/kcd-pkg.service b/packaging/kcd-pkg.service index 4f83e96..fbaa1c9 100644 --- a/packaging/kcd-pkg.service +++ b/packaging/kcd-pkg.service @@ -23,9 +23,12 @@ TimeoutStopSec=10 Restart=on-failure RestartSec=5 -# IPC socket directory, created automatically by systemd under $XDG_RUNTIME_DIR. -RuntimeDirectory=kcd -RuntimeDirectoryMode=0700 +# NOTE: no RuntimeDirectory= here on purpose. The kcd.socket unit owns +# $XDG_RUNTIME_DIR/kcd (creating it is required before the socket can be +# bound), and systemd tears a RuntimeDirectory down when its unit stops — +# if both units declared it, stopping the service would delete the socket +# file out from under the still-active socket unit. In non-socket mode the +# daemon creates the directory itself (ipc server MkdirAll, mode 0700). # ── Process hardening ───────────────────────────────────────────────────────── # Namespace-based hardening (ProtectSystem, PrivateTmp, etc.) is intentionally diff --git a/packaging/kcd-pkg.socket b/packaging/kcd-pkg.socket new file mode 100644 index 0000000..c48f888 --- /dev/null +++ b/packaging/kcd-pkg.socket @@ -0,0 +1,41 @@ +# kcd.socket — on-demand activation for the kcd daemon (distro packages). +# +# Installed by distro packages as /usr/lib/systemd/user/kcd.socket +# (see dist/config.yaml: nfpms contents + AUR package body). +# Enable once for login-persistent on-demand startup: +# +# systemctl --user enable --now kcd.socket +# +# The first client connection to the IPC socket starts kcd.service +# automatically; the daemon adopts this listener (see LISTEN_FDS handling +# in internal/ipc/server.go) instead of binding the path itself. Clients +# block briefly on a cold start, then behave identically — no reconnect +# machinery needed to summon the daemon. +# +# Only applies to the default socket path ($XDG_RUNTIME_DIR/kcd/kcd.sock). +# A custom socket_path in kcd.toml falls back to daemon-side self-bind. +# +# NOTE: unlike packaging/kcd-user.socket this unit has no PartOf= +# graphical-session.target — distro packages serve headless servers and +# minimal window managers too, so the socket follows the default target +# graph like packaging/kcd-pkg.service does. + +[Unit] +Description=kcd — IPC socket (on-demand daemon activation) +Documentation=https://github.com/bethropolis/kcd +Before=sockets.target + +[Socket] +# Must match the daemon's default socket path. %t is $XDG_RUNTIME_DIR. +ListenStream=%t/kcd/kcd.sock +# Mirror the daemon's self-bind permissions (0600) — without this the +# systemd-created socket would be world-accessible under a lax umask. +SocketMode=0600 +# Create $XDG_RUNTIME_DIR/kcd (mode 0700) so the socket path exists. +RuntimeDirectory=kcd +RuntimeDirectoryMode=0700 +# Single-instance stream socket (Accept=false default): systemd spawns one +# kcd.service and hands it every connection. Default trigger limits apply. + +[Install] +WantedBy=sockets.target diff --git a/scripts/postinstall.sh b/scripts/postinstall.sh index 39aca5f..0034736 100755 --- a/scripts/postinstall.sh +++ b/scripts/postinstall.sh @@ -7,8 +7,11 @@ echo "=================================================================" echo "kcd installed successfully." echo "=================================================================" echo "" -echo "To enable the user service, run:" -echo " systemctl --user enable --now kcd" +echo "To enable on-demand startup (recommended), run:" +echo " systemctl --user enable --now kcd.socket" +echo "" +echo "The daemon then starts automatically on first use — run any kcd" +echo "command once after login and a paired phone reconnects by itself." echo "" echo "To enable the system-level service (per-user), run:" echo " sudo systemctl enable --now kcd@\$USER" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 27d1165..5805291 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -52,7 +52,7 @@ printf "${BOLD}${RED}│${RESET} ${BOLD}kcd${RESET} — Uninstall printf "${BOLD}${RED}└─────────────────────────────────────────┘${RESET}\n" printf "\n" -# ── Stop and disable service ────────────────────────────────────────────────── +# ── Stop and disable service + socket ───────────────────────────────────────── step "systemd service" if systemctl --user is-active --quiet kcd.service 2>/dev/null; then @@ -63,11 +63,24 @@ else skip "kcd.service is not running" fi +if systemctl --user is-active --quiet kcd.socket 2>/dev/null; then + info "Stopping kcd.socket …" + systemctl --user stop kcd.socket + success "Socket stopped" +else + skip "kcd.socket is not listening" +fi + if systemctl --user is-enabled --quiet kcd.service 2>/dev/null; then systemctl --user disable kcd.service success "Service disabled" fi +if systemctl --user is-enabled --quiet kcd.socket 2>/dev/null; then + systemctl --user disable kcd.socket + success "Socket disabled" +fi + if [[ -f "${SYSTEMD_DIR}/kcd.service" ]]; then rm "${SYSTEMD_DIR}/kcd.service" systemctl --user daemon-reload @@ -76,6 +89,14 @@ else skip "No service file found at ${SYSTEMD_DIR}/kcd.service" fi +if [[ -f "${SYSTEMD_DIR}/kcd.socket" ]]; then + rm "${SYSTEMD_DIR}/kcd.socket" + systemctl --user daemon-reload + success "Removed ${SYSTEMD_DIR}/kcd.socket" +else + skip "No socket file found at ${SYSTEMD_DIR}/kcd.socket" +fi + # ── Remove binary ───────────────────────────────────────────────────────────── step "Binary" From 38001bab23a7262b37fbba7bd4a570647ae0a4c3 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:58:10 +0300 Subject: [PATCH 24/25] fix(test): synchronize fakeNotifier reads with the async notify path Handle spawns sendDesktopNotification in a goroutine, so the fake's calls slice was written off-test-goroutine while TestNotifySendEndsOptions read it directly (and argFor read it unlocked). Guard argFor with the existing mutex, add a locked lastCall snapshot, and poll it via waitFor instead of a fixed sleep. --- .../plugins/notification/notification_test.go | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/plugins/notification/notification_test.go b/internal/plugins/notification/notification_test.go index 3ecbe92..8f66d11 100644 --- a/internal/plugins/notification/notification_test.go +++ b/internal/plugins/notification/notification_test.go @@ -112,6 +112,8 @@ func (f *fakeNotifier) command(name string, args ...string) *exec.Cmd { } func (f *fakeNotifier) argFor(call int, flag string) string { + f.mu.Lock() + defer f.mu.Unlock() for i, a := range f.calls[call] { if a == flag { if i+1 < len(f.calls[call]) { @@ -122,6 +124,19 @@ func (f *fakeNotifier) argFor(call int, flag string) string { return "" } +// lastCall returns a copy of the most recent recorded invocation, or nil +// if none has arrived yet. The plugin invokes notify-send from a goroutine +// (Handle must return immediately), so tests must poll this under the lock +// instead of reading f.calls directly. +func (f *fakeNotifier) lastCall() []string { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.calls) == 0 { + return nil + } + return append([]string(nil), f.calls[len(f.calls)-1]...) +} + func newFakePlugin(t *testing.T, replace bool) (*NotificationPlugin, *fakeNotifier) { t.Helper() logger := zaptest.NewLogger(t) @@ -277,10 +292,11 @@ func TestNotifySendEndsOptions(t *testing.T) { t.Fatal(err) } time.Sleep(100 * time.Millisecond) // sendDesktopNotification runs async - if len(f.calls) == 0 { - t.Fatal("expected notify-send invocation") - } - call := f.calls[len(f.calls)-1] + var call []string + waitFor(t, "notify-send invocation", func() bool { + call = f.lastCall() + return call != nil + }) sep := -1 for i, a := range call { if a == "--" { From 5a520af8f43b2139eed57c199aed7d40084e0dec Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:58:10 +0300 Subject: [PATCH 25/25] fix(packaging): drop deprecated homebrew_casks url.verified key goreleaser check fails on the deprecated property and CI gates on it. The verified hint is cosmetic; removing it changes nothing about the published cask. --- .goreleaser.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9e65cf5..ec0008f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -138,8 +138,6 @@ homebrew_casks: owner: bethropolis name: homebrew-tap token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" - url: - verified: github.com/bethropolis/kcd binaries: - kcd completions: