diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0e4fa21..6f67a37 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -87,7 +87,6 @@ aurs: - "xdotool: for X11 mousepad support" - "wtype: for Wayland keyboard emulation" - "python-nautilus: for Nautilus file manager integration" - install: packaging/kcd-bin.install package: |- install -Dm755 "./kcd" "${pkgdir}/usr/bin/kcd" @@ -115,6 +114,8 @@ dockers_v2: - "v{{ .Version }}" - latest dockerfile: Dockerfile.goreleaser + extra_files: + - entrypoint.sh ids: - kcd labels: diff --git a/AGENTS.md b/AGENTS.md index 720c54b..3fe4477 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,18 @@ const CmdMyAction = "my_action" **7. Add config field to `packaging/kcd.example.toml`** +**8. Document in `docs/IPC_PROTOCOL.md` and `docs/CLIENT_GUIDE.md`** + +- Add the new IPC command to the command reference table (section 3) with + request/response payload schemas. +- If the plugin introduces new event types, add them to the event types + section (section 5) with full JSON payload examples. +- If the plugin sends or receives new KDE Connect packet types, add entries + to the outbound (section 6) or inbound (section 7) packet reference tables. +- Add the new CLI command to the command reference in `docs/CLI.md`. +- Cover error cases in the walkthrough—what happens when the device is + disconnected, the plugin is disabled, or the payload is malformed. + ### Plugin lookup To get a plugin and type-assert to its concrete type from an IPC handler: diff --git a/Dockerfile b/Dockerfile index 7df6f20..6a1fda1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,15 @@ # ─── Builder ─────────────────────────────────────────────────────────────────── FROM golang:1.25-alpine AS builder -# Build-time version info (injected by GoReleaser / docker buildx) ARG VERSION=dev ARG COMMIT=none ARG DATE=unknown WORKDIR /build -# Fetch dependencies in a separate layer so they are cached between code changes COPY go.mod go.sum ./ RUN go mod download && go mod verify -# Build a fully static binary COPY . . RUN CGO_ENABLED=0 GOOS=linux go build \ -trimpath \ @@ -23,23 +20,18 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ -o /out/kcd \ ./cmd/kcd -# Pre-create empty directories owned by nobody:nogroup (65534:65534) -# so the runtime container can run unprivileged and still write to them. RUN mkdir -p /out/empty && \ for d in config state data run/kcd; do \ mkdir -p "/out/$d"; \ done && \ chown -R 65534:65534 /out/config /out/state /out/data /out/run -# Smoke-test: binary must be statically linked RUN apk add --no-cache file && \ file /out/kcd | grep -q "statically linked" || \ (echo "ERROR: binary is not statically linked" && exit 1) # ─── Runtime ─────────────────────────────────────────────────────────────────── -# `scratch` gives us a zero-footprint image. -# The binary is fully static, so no libc or shell is needed. -FROM scratch +FROM alpine:3.20 LABEL org.opencontainers.image.title="kcd" \ org.opencontainers.image.description="Headless KDE Connect daemon" \ @@ -47,43 +39,27 @@ LABEL org.opencontainers.image.title="kcd" \ org.opencontainers.image.source="https://github.com/bethropolis/kcd" \ org.opencontainers.image.licenses="MIT" -# TLS root certificates (needed for outbound TLS when communicating with devices) COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ - -# The binary COPY --from=builder /out/kcd /usr/bin/kcd -# Pre-populate volume mount points with directories owned by nobody -# so the container runs unprivileged without permission errors. COPY --from=builder --chown=65534:65534 /out/config /config COPY --from=builder --chown=65534:65534 /out/state /state COPY --from=builder --chown=65534:65534 /out/data /data COPY --from=builder --chown=65534:65534 /out/run /run -# Volume mount points — must be provided at runtime -# /config → $XDG_CONFIG_HOME/kcd (kcd.toml, cert.pem, key.pem) -# /state → $XDG_STATE_HOME/kcd (devices.json — persisted pairs) -# /data → download_dir (received files) -VOLUME ["/config", "/state", "/data"] +COPY entrypoint.sh /entrypoint.sh -# Drop privileges — nobody can still bind ports ≥1024 and write to volumes. -USER 65534:65534 +VOLUME ["/config", "/state", "/data"] -# kcd reads these to find its paths without a real home directory ENV XDG_CONFIG_HOME=/config \ XDG_STATE_HOME=/state \ XDG_RUNTIME_DIR=/run \ XDG_CACHE_HOME=/tmp -# KDE Connect control port (TCP + UDP) -EXPOSE 1716/tcp -EXPOSE 1716/udp - -# File-transfer side-channels -EXPOSE 1739-1764/tcp +EXPOSE 1716/tcp 1716/udp 1739-1764/tcp HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["/usr/bin/kcd", "devices"] -ENTRYPOINT ["/usr/bin/kcd"] +ENTRYPOINT ["/entrypoint.sh"] CMD ["daemon"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index 407d0d0..ef88caa 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -5,7 +5,7 @@ RUN apk add --no-cache ca-certificates && \ chown -R 65534:65534 /out/config /out/state /out/data /out/run # ─── Runtime ─────────────────────────────────────────────────────────────────── -FROM scratch +FROM alpine:3.20 LABEL org.opencontainers.image.title="kcd" \ org.opencontainers.image.description="Headless KDE Connect daemon" \ @@ -19,13 +19,13 @@ COPY --from=base --chown=65534:65534 /out/state /state COPY --from=base --chown=65534:65534 /out/data /data COPY --from=base --chown=65534:65534 /out/run /run +COPY entrypoint.sh /entrypoint.sh + ARG TARGETPLATFORM COPY $TARGETPLATFORM/kcd /usr/bin/kcd VOLUME ["/config", "/state", "/data"] -USER 65534:65534 - ENV XDG_CONFIG_HOME=/config \ XDG_STATE_HOME=/state \ XDG_RUNTIME_DIR=/run \ @@ -36,5 +36,5 @@ EXPOSE 1716/tcp 1716/udp 1739-1764/tcp HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["/usr/bin/kcd", "devices"] -ENTRYPOINT ["/usr/bin/kcd"] +ENTRYPOINT ["/entrypoint.sh"] CMD ["daemon"] diff --git a/cmd/kcd/cli_sftp.go b/cmd/kcd/cli_sftp.go index 99938bd..7ef2e46 100644 --- a/cmd/kcd/cli_sftp.go +++ b/cmd/kcd/cli_sftp.go @@ -137,5 +137,60 @@ Safe to call even if already unmounted (returns error in that case).`, return nil }, }, + { + Name: "browse", + Usage: "Request fresh credentials and browse or mount a storage volume", + ArgsUsage: " [volume-index|volume-name|volume-path]", + Description: `Request fresh SFTP credentials from the phone and either list +available volumes or mount a specific one. + +Without a volume argument, lists available volumes with their index, name, and path. + +With a volume argument (index, name, or path), mounts that volume via sshfs and +opens it in the default file manager. + +Examples: + kcd sftp browse myphone + kcd sftp browse myphone 0 + kcd sftp browse myphone "SD card" + kcd sftp browse myphone /storage/ABCD-1234`, + 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 + } + + volume := "" + if c.NArg() > 1 { + volume = c.Args().Get(1) + } + + fmt.Println("Requesting SFTP credentials from phone (waiting up to 20s)…") + path, volumes, err := cl.SftpBrowse(c.Args().First(), volume) + if err != nil { + return err + } + + if path != "" { + fmt.Printf("Mounted at: %s\n", path) + return nil + } + + if len(volumes) == 0 { + fmt.Println("No storage volumes reported by device.") + return nil + } + + fmt.Println("Available volumes:") + for i, v := range volumes { + fmt.Printf(" %d. %-30s %s\n", i, v.Name, v.Path) + } + fmt.Println("\nMount a volume: kcd sftp browse ") + return nil + }, + }, }, } diff --git a/docs/CLI.md b/docs/CLI.md index 42ebcf5..a5e66e2 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -381,6 +381,29 @@ kcd mpris seek -10s kcd mpris seek 1m30s ``` +### mpris raw + +Dump raw MPRIS debug state for all local media players as JSON. Shows player +names, their running status, and device-to-player mappings. Useful for +diagnosing "no player found" issues. + +``` +kcd mpris raw +``` + +Returns the same data as the `mpris_status` IPC command. + +```json +{ + "watcherRunning": true, + "deviceCount": 2, + "players": ["spotify", "firefox"], + "playerMappings": { + "a1b2c3d4e5f6_...": "spotify" + } +} +``` + ### Scripting examples ```bash @@ -570,6 +593,46 @@ Calls `fusermount3` (or `fusermount` on older systems) and removes the temporary mount point directory. Returns an error if the device was never mounted in this daemon session. +### sftp browse + +Request fresh SFTP credentials and list available storage volumes or mount +a specific one immediately. + +``` +kcd sftp browse [volume-index|volume-name|volume-path] +``` + +**Without a volume argument** — requests fresh credentials and lists volumes: + +``` +$ kcd sftp browse a1b2c3d4 +Requesting SFTP credentials from phone (waiting up to 20s)… +Available volumes: + 0. Internal shared storage /storage/emulated/0 + 1. SD card /storage/ABCD-1234 + +Mount a volume: kcd sftp browse +``` + +**With a volume argument** — requests fresh credentials and mounts the +specified volume via sshfs, opening it in the default file manager: + +``` +$ kcd sftp browse a1b2c3d4 "SD card" +Requesting SFTP credentials from phone (waiting up to 20s)… +Mounted at: /home/user/Downloads/kcd/mnt/kcd-sftp-a1b2c3d4 +``` + +The volume argument is resolved in this order: + +1. **Index** (0-based) — `0`, `1`, etc. +2. **Name** (case-insensitive) — `"SD card"`, `"internal shared storage"` +3. **Path** (exact then case-insensitive) — `/storage/ABCD-1234` + +This is the recommended way to mount a specific phone volume without having +to `request` + `mount` separately. It always fetches fresh credentials, +so the credentials are guaranteed valid. + --- ## run @@ -615,19 +678,46 @@ kcd run exec a1b2... uptime ## sms -Send an SMS via a connected phone. +Send and receive SMS via a connected phone. + +### sms send + +Send an SMS message. ``` -kcd sms +kcd sms send ``` **Example** ```bash -kcd sms a1b2... +1555000111 "Heading home in 10" +kcd sms send a1b2... +1555000111 "Heading home in 10" ``` -> Incoming SMS threads are not yet supported. Only sending is implemented. +### sms conversations + +Request a list of SMS conversation threads. Results arrive as `sms.incoming` events. + +``` +kcd sms conversations +``` + +### sms conversation + +Request messages from a specific conversation thread. + +``` +kcd sms conversation +``` + +### sms attachment + +Request an MMS attachment file from a device. The file is saved locally and an +`sms.attachment` event is emitted with the path. + +``` +kcd sms attachment +``` --- @@ -666,7 +756,7 @@ kcd watch [--events ] [--json] | `notification` | Notification from the phone: `{appName, title, text, id, ...}` | | `notification.canceled` | The phone dismissed a notification: `{id}` | | `share.progress` | File transfer progress: `{file, current, total}` | -| `share.complete` | File transfer finished: `{file, path}` | +| `share.complete` | File transfer finished: `{file, success, error?}` | | `share.text` | Plain text received: `{text}` | | `share.url` | URL received: `{url}` | | `ping.received` | A ping arrived | @@ -674,8 +764,14 @@ kcd watch [--events ] [--json] | `telephony.missed` | Missed call: `{contactName, phoneNumber}` | | `telephony.canceled` | Call ended | | `connectivity.update` | Signal strength: `{signal, networkType}` | +| `volume.update` | Device volume changed: `{name, volume, muted}` | | `mpris.update` | Now playing: `{player, title, artist, album, isPlaying, pos, length, volume}` | | `sftp.mount` | SFTP credentials: `{uri, ip, port, user, password, path, multiPaths, pathNames, errorMessage}` | +| `battery.threshold` | Battery low/full alert: `{charge, charging, event}` | +| `telephony.talking` | Call in progress: `{contactName, phoneNumber}` | +| `sms.incoming` | SMS/MMS received: `{body, sender, date, thread_id, read}` | +| `sms.attachment` | MMS attachment downloaded: `{filename, path, thread_id}` | +| `ring.received` | Phone wants this PC to ring | ### Examples diff --git a/docs/CLIENT_GUIDE.md b/docs/CLIENT_GUIDE.md new file mode 100644 index 0000000..2f7af7d --- /dev/null +++ b/docs/CLIENT_GUIDE.md @@ -0,0 +1,696 @@ +# Client Developer Guide + +This guide explains how to write a client (GUI, CLI, widget) that communicates +with the `kcd` daemon through its Unix socket IPC interface. The daemon is +headless — there is no built-in GUI. Everything a client can do goes through +the IPC protocol documented in [`IPC_PROTOCOL.md`](IPC_PROTOCOL.md). + +If you are building a Rust/GTK4 client, see the separate reference documents +in [`../../rust/kcd-client/`](../../rust/kcd-client/) (`PROJECT_GUIDE.md`, +`AGENT.md`, `ROADMAP.md`). + +--- + +## 1. Finding the Socket + +The IPC socket path depends on the platform: + +- **Linux (systemd user session):** `/run/user//kcd/kcd.sock` +- **Linux (no systemd):** `$XDG_RUNTIME_DIR/kcd/kcd.sock` +- **Fallback:** Run `kcd doctor` which prints the socket path and whether the + daemon is reachable. + +The socket is a Unix stream socket with `S_IRUSR|S_IWUSR` permissions (the +owning user only). The directory `kcd/` is created inside the runtime dir. + +**Python: Helper to find the socket:** + +```python +import os +import pwd + +def socket_path(): + uid = os.getuid() + runtime_dir = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{uid}") + return os.path.join(runtime_dir, "kcd", "kcd.sock") +``` + +--- + +## 2. Connecting + +All IPC communication uses newline-delimited JSON (NDJSON) over a Unix stream +socket. Send one JSON object per line, terminated by `\n`. Read one line per +response. + +**Python: Basic request-response:** + +```python +import json +import socket + +def ipc_request(sock, cmd, payload=None): + req = {"cmd": cmd} + if payload is not None: + req["payload"] = payload + sock.sendall((json.dumps(req) + "\n").encode()) + # Read one line + buf = b"" + while True: + c = sock.recv(1) + if c == b"\n" or not c: + break + buf += c + return json.loads(buf.decode()) + +sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +sock.connect(socket_path()) + +resp = ipc_request(sock, "devices") +if resp["ok"]: + for dev in resp["data"]: + print(f'{dev["name"]} ({dev["id"]}) — {dev["state"]}') +``` + +--- + +## 3. Device Lifecycle + +### 3.1 Listing Devices + +The `devices` command returns all known devices (paired and unpaired). + +```python +resp = ipc_request(sock, "devices") +for dev in resp["data"]: + print(f'{dev["name"]} — {dev["state"]} — connected={dev["connected"]}') +``` + +States: `UNPAIRED`, `PAIR_REQUESTED`, `PAIR_REQUESTED_BY_PEER`, `PAIRED`. + +### 3.2 Pairing Flow + +Pairing requires a persistent watch connection to receive the pairing request +event. + +**Step 1: Start listening for a pair request (in a thread or second socket):** + +```python +import threading, json, socket + +def listen_for_pair(): + lsock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + lsock.connect(socket_path()) + lsock.sendall(b'{"cmd":"pair_listen"}\n') + buf = b"" + while True: + c = lsock.recv(1) + if c == b"\n" or not c: + break + buf += c + resp = json.loads(buf.decode()) + if resp["ok"]: + data = resp["data"] + print(f'Pair request from: {data["deviceName"]}') + print(f'Verification key: {data["verificationKey"]}') + # User verifies the key matches on both devices + return data["deviceId"] + return None + +# Run in background: +pair_thread = threading.Thread(target=listen_for_pair, daemon=True) +pair_thread.start() +``` + +**Step 2: Have the user initiate pairing on the phone** (KDE Connect → kcd +device → Request Pair). The `pair_listen` handler will return the request. + +**Step 3: Accept the pairing:** + +```python +resp = ipc_request(sock, "pair", {"deviceId": device_id, "accept": True}) +if resp["ok"]: + print("Paired successfully!") +``` + +### 3.3 Unpairing + +```python +resp = ipc_request(sock, "unpair", {"deviceId": device_id}) +``` + +--- + +## 4. Watching Events + +The `watch` command creates a persistent connection that streams live events. + +### 4.1 Basic Watch Loop + +```python +import json +import socket +import threading + +class KcdWatcher: + def __init__(self, socket_path, event_types=None): + self.socket_path = socket_path + self.event_types = event_types + self._running = False + self._callbacks = {} + + def on(self, event_type, callback): + self._callbacks.setdefault(event_type, []).append(callback) + + def _read_line(self, sock): + buf = b"" + while True: + c = sock.recv(1) + if c == b"\n" or not c: + break + buf += c + return buf.decode() + + def start(self): + self._running = True + thread = threading.Thread(target=self._run, daemon=True) + thread.start() + + def stop(self): + self._running = False + + def _run(self): + import time + backoff = 1 + while self._running: + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(30) + sock.connect(self.socket_path) + + payload = None + if self.event_types: + payload = {"events": self.event_types} + req = {"cmd": "watch"} + if payload: + req["payload"] = payload + sock.sendall((json.dumps(req) + "\n").encode()) + + # Consume the ack line + ack = self._read_line(sock) + if not json.loads(ack).get("ok"): + continue + + backoff = 1 # Reset on successful connect + while self._running: + line = self._read_line(sock) + if not line: + break + event = json.loads(line) + etype = event.get("type") + if etype in self._callbacks: + for cb in self._callbacks[etype]: + cb(event["deviceId"], event.get("payload")) + + except (socket.error, json.JSONDecodeError, ConnectionError) as e: + print(f"Watch error: {e}") + finally: + try: + sock.close() + except Exception: + pass + + # Exponential backoff 1s → 30s + if self._running: + time.sleep(backoff) + backoff = min(backoff * 2, 30) +``` + +### 4.2 Using the Watcher + +```python +w = KcdWatcher(socket_path(), ["battery.update", "notification", "mpris.update"]) + +@w.on("battery.update") +def on_battery(device_id, payload): + print(f'Battery: {payload["charge"]}% ({"charging" if payload["charging"] else "discharging"})') + +@w.on("notification") +def on_notification(device_id, payload): + print(f'{payload["appName"]}: {payload["title"]} — {payload["text"]}') + +@w.on("mpris.update") +def on_mpris(device_id, payload): + if payload and payload.get("isPlaying"): + print(f'Now playing: {payload["title"]} by {payload["artist"]}') + else: + print("Paused/stopped") + +w.start() + +# Keep main thread alive +import time +try: + while True: + time.sleep(1) +except KeyboardInterrupt: + w.stop() +``` + +### 4.3 Event Types Quick Reference + +| Filter string | When it fires | +|---|---| +| `device.connected` | TCP connection established | +| `device.disconnected` | TCP connection lost | +| `battery.update` | Battery level or charging state changed | +| `notification` | Push notification from device | +| `share.progress` | File transfer progress update | +| `share.complete` | File transfer finished | +| `mpris.update` | Now-playing state changed | +| `sms.incoming` | SMS/MMS received | +| `pair.requested` | Remote device wants to pair | +| `ping.received` | Ping from device | + +See [`IPC_PROTOCOL.md §5`](IPC_PROTOCOL.md#5-event-types) for the full list. + +--- + +## 5. Sending Commands + +### 5.1 Request Battery State + +```python +resp = ipc_request(sock, "battery", {"deviceId": dev_id}) +if resp["ok"]: + data = resp["data"] + print(f'Battery: {data["charge"]}% (charging: {data["charging"]})') +``` + +### 5.2 Send a Ping + +```python +ipc_request(sock, "ping", {"deviceId": dev_id}) +``` + +The phone should vibrate/show a notification. + +### 5.3 Share a File + +```python +ipc_request(sock, "share", {"deviceId": dev_id, "file": "/path/to/file.pdf"}) +``` + +### 5.4 MPRIS Control + +```python +# Play/Pause +ipc_request(sock, "mpris_action", {"deviceId": dev_id, "action": "playpause"}) + +# Next track +ipc_request(sock, "mpris_action", {"deviceId": dev_id, "action": "next"}) + +# Set volume +ipc_request(sock, "mpris_action", {"deviceId": dev_id, "action": "setVolume", "value": 50}) +``` + +### 5.5 Send SMS + +```python +ipc_request(sock, "send_sms", { + "deviceId": dev_id, + "phoneNumber": "+1234567890", + "message": "Hello from kcd!" +}) +``` + +### 5.6 Ring the Phone + +```python +ipc_request(sock, "findmyphone", {"deviceId": dev_id}) +# or: ipc_request(sock, "ring", {"deviceId": dev_id}) +``` + +### 5.7 Lock/Unlock + +```python +ipc_request(sock, "lock", {"deviceId": dev_id}) +ipc_request(sock, "unlock", {"deviceId": dev_id}) +``` + +### 5.8 Push Clipboard + +```python +ipc_request(sock, "clipboard_push", {"deviceId": dev_id}) +``` + +The daemon reads the local clipboard (`wl-paste`/`xclip`) and sends it. + +### 5.9 Get SFTP Connection Info + +```python +resp = ipc_request(sock, "sftp_info", {"deviceId": dev_id}) +if resp["ok"]: + info = resp["data"] + print(f'SSH: {info["user"]}@{info["ip"]} -p {info["port"]}') +``` + +### 5.10 Get Daemon Status + +```python +resp = ipc_request(sock, "status") +if resp["ok"]: + s = resp["data"] + print(f'kcd v{s["version"]} — {s["uptimeHuman"]} uptime') + print(f'Plugins: {", ".join(s["plugins"])}') + print(f'{s["connectedCount"]}/{s["deviceCount"]} devices connected') +``` + +--- + +## 6. Handling Payloads + +Each event type carries a different payload shape. Here are the common ones: + +### Battery Update + +```json +{"charge": 85, "charging": true} +``` + +### Notification + +```json +{ + "appName": "Signal", + "title": "Alice", + "text": "See you later", + "requestReplyId": "reply-123", + "id": "notif-456" +} +``` + +- `requestReplyId` is present only for notifications that support inline + replies. Use it with the `notify_reply` command. + +### Share Progress + +```json +{"file": "video.mp4", "current": 1048576, "total": 8388608} +``` + +### MPRIS Update + +```json +{ + "player": "spotify", + "title": "Song Title", + "artist": "Artist", + "album": "Album", + "isPlaying": true, + "pos": 45000, + "length": 240000, + "volume": 80, + "canControl": true, + "shuffle": false, + "loopStatus": "None" +} +``` + +### SMS Incoming + +```json +{ + "body": "Hello!", + "sender": "+1234567890", + "date": 1716800000000, + "type": 1, + "thread_id": 42, + "read": false +} +``` + +### Device Connected + +```json +{"id": "a1b2...", "name": "Pixel 9", "type": "phone"} +``` + +--- + +## 7. Building Features + +### 7.1 File Transfer Tracker + +Watch for `share.progress` and `share.complete` events to build a transfer +progress UI: + +```python +transfers = {} + +@w.on("share.progress") +def on_progress(dev_id, payload): + fname = payload["file"] + cur, total = payload["current"], payload["total"] + pct = (cur / total) * 100 if total > 0 else 0 + transfers[fname] = pct + print(f'{fname}: {pct:.0f}%') + +@w.on("share.complete") +def on_complete(dev_id, payload): + fname = payload["file"] + if payload["success"]: + print(f'{fname}: complete!') + else: + print(f'{fname}: FAILED — {payload.get("error", "unknown")}') + transfers.pop(fname, None) +``` + +### 7.2 Notification Inbox + +Subscribe to all `notification` events and build an inbox: + +```python +inbox = [] + +@w.on("notification") +def on_notification(dev_id, payload): + inbox.append({ + "app": payload["appName"], + "title": payload["title"], + "text": payload["text"], + "time": import_time.time(), + "can_reply": "requestReplyId" in payload + }) + # Keep last 50 + if len(inbox) > 50: + inbox.pop(0) + +def reply_to_notification(dev_id, notif_id, message): + ipc_request(command_sock, "notify_reply", { + "deviceId": dev_id, + "replyId": notif_id, + "message": message + }) +``` + +### 7.3 SMS Viewer + +```python +conversations = {} + +@w.on("sms.incoming") +def on_sms(dev_id, payload): + thread_id = payload["thread_id"] + if thread_id not in conversations: + conversations[thread_id] = [] + conversations[thread_id].append({ + "from": payload["sender"], + "body": payload["body"], + "date": payload["date"], + "type": "received" if payload["type"] == 1 else "sent" + }) + +def send_sms(dev_id, phone_number, message): + ipc_request(command_sock, "send_sms", { + "deviceId": dev_id, + "phoneNumber": phone_number, + "message": message + }) +``` + +### 7.4 Remote Media Controller + +```python +class MediaController: + def __init__(self, sock, dev_id): + self.sock = sock + self.dev_id = dev_id + self.now_playing = None + + def refresh(self): + resp = ipc_request(self.sock, "mpris_action", { + "deviceId": self.dev_id, + "action": "playpause" # triggers state push + }) + + def play_pause(self): + ipc_request(self.sock, "mpris_action", { + "deviceId": self.dev_id, "action": "playpause" + }) + + def next(self): + ipc_request(self.sock, "mpris_action", { + "deviceId": self.dev_id, "action": "next" + }) + + def previous(self): + ipc_request(self.sock, "mpris_action", { + "deviceId": self.dev_id, "action": "previous" + }) + + def set_volume(self, vol): + ipc_request(self.sock, "mpris_action", { + "deviceId": self.dev_id, "action": "setVolume", "value": vol + }) +``` + +--- + +## 8. Error Recovery + +### 8.1 Daemon Not Running + +```python +def check_daemon(path): + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(2) + sock.connect(path) + sock.close() + return True + except (FileNotFoundError, ConnectionRefusedError, socket.error): + return False +``` + +### 8.2 Watch Reconnection + +The `KcdWatcher` class above implements exponential backoff (1s → 30s max). +The reconnection strategy should: + +1. Close the old socket on any read error. +2. Wait with backoff (1s, 2s, 4s, 8s, 16s, 30s) before retrying. +3. Reset backoff to 1s on successful connection. +4. Re-send the `watch` request with the same event type filter. +5. After reconnecting, wait for the initial state dump to catch up on missed + state (device.connected events will re-establish connectivity). + +### 8.3 Device Disconnection + +When a device disconnects: +- A `device.disconnected` event is emitted. +- The device remains in `PAIRED` state and will auto-reconnect when the phone + re-enters the network (if LAN broadcast is enabled). +- Your UI should show the device as offline but keep its configuration. +- On reconnection, a `device.connected` event is emitted followed by the state + dump for that device. + +### 8.4 Pairing Loss + +If the phone is reset or the app is reinstalled: +- The device ID changes (permanent identifier generated on first app launch). +- The old device entry will never reconnect. +- Remove the stale entry with `unpair` and initiate a fresh pairing. + +--- + +## 9. Integration Examples + +### 9.1 Desktop Widget (Waybar) + +The `desktop-integration/` directory contains ready-to-use scripts. For a +simple waybar custom module: + +```bash +#!/bin/bash +# ~/.config/waybar/scripts/kcd-custom.sh +while true; do + socat - UNIX-CONNECT:/run/user/$(id -u)/kcd/kcd.sock <<'EOF' | while read -r line; do +{"cmd":"watch","payload":{"events":["battery.update","mpris.update"]}} +EOF + [ "$line" = '{"ok":true}' ] && continue + echo "$line" | jq -c 'select(.type=="battery.update") | {text: ("🔋 \(.payload.charge)%")}' + done + sleep 1 +done +``` + +See `desktop-integration/README.md` for the maintained integration scripts. + +### 9.2 Minimal Python Event Monitor + +```python +#!/usr/bin/env python3 +"""Simple kcd event monitor.""" +import json, socket, sys, os + +SOCK = os.path.join( + os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"), + "kcd", "kcd.sock" +) + +def main(): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(SOCK) + sock.sendall(b'{"cmd":"watch","payload":{"events":' + + sys.argv[1:].encode() + b'}}\n') + # Discard ack + ack = sock.makefile("r").readline() + for line in sock.makefile("r"): + event = json.loads(line) + print(json.dumps(event, indent=2)) + +if __name__ == "__main__": + main() +``` + +Usage: `python3 monitor.py '["battery.update","mpris.update"]'` + +### 9.3 GTK4/Shell Proxy + +For desktop shell widgets (eww, ags, quickshell), run `kcd watch` in the +background and pipe the JSON output to a named pipe or parse it directly: + +```bash +# In the shell widget's startup: +kcd watch --json '["battery.update","mpris.update"]' | while read -r line; do + [ "$line" = '{"ok":true}' ] && continue + # Parse and update widget state +done +``` + +--- + +## 10. Going Further + +### Rust/GTK4 Client + +A reference Rust GTK4 client implementation is being developed separately at +[`../../rust/kcd-client/`](../../rust/kcd-client/). Its `PROJECT_GUIDE.md`, +`AGENT.md`, and `ROADMAP.md` documents provide additional architectural +context for building a full-featured GUI client. + +### Protocol-Level Implementation + +If you are implementing a full KDE Connect network-level client (not just IPC), +refer to the upstream protocol spec at +. + +### Getting Help + +- **Daemon issues:** `/home/bet/Projects/go/kde-connect` — open a GitHub + issue. +- **Protocol questions:** The IPC protocol reference in + [`IPC_PROTOCOL.md`](IPC_PROTOCOL.md) is the source of truth. +- **API stability:** The IPC protocol is considered stable within a major + version. Breaking changes will be documented in release notes. diff --git a/docs/CONTAINER.md b/docs/CONTAINER.md index cf1670e..20dc3d3 100644 --- a/docs/CONTAINER.md +++ b/docs/CONTAINER.md @@ -53,9 +53,9 @@ docker compose run --rm kcd-cli devices | `/data` | Received files | Optional | | `/run` | IPC Unix socket | Yes (tmpfs recommended) | -The container runs as UID 65534 (nobody). All volume directories are pre-created -in the image with correct ownership, so bind mounts must grant write access to -UID 65534. +The entrypoint auto-fixes ownership on all volumes at startup (remaps host-root +owned paths to UID 65534). A default `kcd.toml` is created at `/config/kcd/` if +none exists. ## Building Locally diff --git a/docs/IPC_PROTOCOL.md b/docs/IPC_PROTOCOL.md new file mode 100644 index 0000000..033936f --- /dev/null +++ b/docs/IPC_PROTOCOL.md @@ -0,0 +1,1187 @@ +# IPC Protocol Reference + +kcd exposes a Unix socket for inter-process communication. This document is the +authoritative reference for client authors who want to build tools against the +daemon without importing Go packages. + +--- + +## 1. Transport + +- **Socket path:** `$XDG_RUNTIME_DIR/kcd/kcd.sock` (typically + `/run/user//kcd/kcd.sock`) +- **Type:** Unix stream socket (`SOCK_STREAM`) +- **Framing:** Newline-delimited JSON (NDJSON). Every message is a single JSON + object terminated by `\n`. The daemon reads one line per request from its + `bufio.Scanner`. +- **Max request payload:** No explicit limit (scanner reads a single line; Unix + socket buffer is the practical bound). Do not send payloads exceeding ~1 MiB + over IPC; use the side-channel TLS port for file transfers instead. + +--- + +## 2. Request / Response Format + +### Request + +```json +{"cmd": "", "payload": } +``` + +- `cmd` (`string`, required) — the command name. +- `payload` (`JSON`, optional) — per-command parameters. Parsed as + `json.RawMessage` by the handler. If absent, the field must be omitted or + `null`. + +### Response (non-watch commands) + +```json +{"ok": true, "data": } +{"ok": false, "error": "human-readable message"} +``` + +- `ok` (`bool`) — success. +- `error` (`string`, present only when `ok` is `false`) — error description. +- `data` (`JSON`, present only when `ok` is `true` and the command has a + payload) — response payload. Refer to the per-command reference for its shape. + +### Error responses + +All errors return the same shape. The `error` string is human-readable and may +change between releases. Do not parse it programmatically beyond logging. + +```json +{"ok": false, "error": "device not found"} +``` + +--- + +## 3. Command Reference + +Every command, its request payload, and its response data shape. + +### 3.1 Built-in Commands (handler.go) + +#### `devices` + +List all known devices (paired + unpaired). + +**Request payload:** none + +**Response data:** `[]DeviceInfo` + +```json +{ + "ok": true, + "data": [ + { + "id": "a1b2c3d4e5f6_...", + "name": "Pixel 9", + "type": "phone", + "state": "PAIRED", + "cert_fp": "", + "last_seen": "0001-01-01T00:00:00Z", + "connected": true + } + ] +} +``` + +Fields: + +| Field | Type | Description | +|---|---|---| +| `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"` | +| `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 | + +#### `pair` + +Initiate pairing with a device, or respond to an incoming pairing request. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +Optional fields: + +```json +{"deviceId": "a1b2c3d4e5f6_...", "accept": true, "reject": false} +``` + +- 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. + +**Response data:** none (`{"ok": true}`) + +#### `pair_listen` + +Wait for an incoming pairing request and return the verification key. + +**Request payload:** none + +**Response data:** `PairListenResult` + +```json +{ + "ok": true, + "data": { + "deviceId": "a1b2c3d4e5f6_...", + "deviceName": "Pixel 9", + "verificationKey": "ABCD1234EFGH5678" + } +} +``` + +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. + +#### `unpair` + +Remove a paired device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none + +#### `ping` + +Send a ping packet to a device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none + +### 3.2 Plugin Commands (registered via `handler.Register`) + +#### `connect` + +Connect to a device by IP address. Used when LAN broadcast is unavailable or +the device is on a different subnet. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_...", "ip": "192.168.1.42"} +``` + +**Response data:** none + +#### `broadcast_start` / `broadcast_stop` + +Disable/enable LAN UDP broadcasting at runtime. + +**Request payload:** none + +**Response data:** none + +#### `status` + +Return daemon status info. + +**Request payload:** none + +**Response data:** `StatusResponse` + +```json +{ + "ok": true, + "data": { + "version": "1.13.0", + "startedAt": "2026-05-27T10:00:00Z", + "uptimeHuman": "2h34m", + "socketPath": "/run/user/1000/kcd/kcd.sock", + "configPath": "/home/user/.config/kcd/kcd.toml", + "plugins": ["pair", "ping", "battery", "share", "sftp", "clipboard", "mpris", "notification", "sms", "telephony", "connectivity", "systemvolume", "mousepad", "lockdevice", "findmyphone", "runcommand"], + "deviceCount": 3, + "connectedCount": 1 + } +} +``` + +#### `battery` + +Request battery state from a device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** `{"charge": 85, "charging": true}` (the daemon waits for the +device to respond and returns the value). + +| Field | Type | Description | +|---|---|---| +| `charge` | number | Battery percentage (0–100) | +| `charging` | bool | Whether the device is currently charging | + +#### `clipboard_push` + +Push the local clipboard content to a device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +The daemon reads the local clipboard via `wl-paste` or `xclip` and sends it. + +**Response data:** none + +#### `share` + +Send a file to a device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_...", "file": "/path/to/file.pdf"} +``` + +**Response data:** none + +The daemon opens a side-channel TLS port for the actual file transfer. + +#### `send_sms` + +Send an SMS via a paired phone. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_...", "phoneNumber": "+1234567890", "message": "Hello!"} +``` + +**Response data:** none + +#### `sms_request_conversations` + +Request a list of SMS conversations from a device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none (results arrive as `sms.incoming` events if the phone +uses the deprecated event-based protocol) or via the conversation response +packet (handled internally). For client authors: subscribe to `notification` +or watch the event stream for the reply. + +#### `sms_request_conversation` + +Request messages from a specific conversation thread. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_...", "threadID": 42} +``` + +Optional fields: `rangeStartTimestamp` (int64), `numberToRequest` (int64). + +**Response data:** none + +#### `sms_request_attachment` + +Request an MMS attachment file from a device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_...", "threadID": 42, "partID": 1, "uniqueIdentifier": "..."} +``` + +**Response data:** none (attachment arrives via side-channel transfer, emitted +as `sms.attachment` event). + +#### `call_mute` + +Mute an incoming phone call. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none + +#### `notify_reply` + +Reply to a notification that supports inline replies. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_...", "replyId": "notification-id-here", "message": "OK, I'll be there"} +``` + +The `replyId` comes from the `requestReplyId` field of a `notification` event. + +**Response data:** none + +#### `findmyphone` (also aliased as `ring`) + +Make a paired phone ring loudly. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none + +#### `lock` + +Lock a paired device's screen. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none + +#### `unlock` + +Unlock a paired device's screen (if the device supports it). + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none + +#### `run_list` + +Request a device's list of configured run commands. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none (results arrive via `kdeconnect.runcommand` response +packet). + +#### `run_exec` + +Execute a command on a device by its command key. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_...", "key": "my_command_key"} +``` + +**Response data:** none + +#### `sftp_info` + +Get SFTP connection details for a device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** `SftpInfo` + +```json +{ + "ok": true, + "data": { + "ip": "192.168.1.42", + "port": "31588", + "user": "u0_a123", + "password": "sftp-password-here", + "path": "/storage/emulated/0", + "volumes": [ + {"name": "Internal shared storage", "path": "/storage/emulated/0"} + ] + } +} +``` + +#### `sftp_volumes` + +List storage volumes available on a device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** `[]StorageVolume` (array of `{"name": "...", "path": "..."}`) + +#### `sftp_mount` + +Mount a device's storage via SFTP (requires `sshfs`). + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none + +#### `sftp_mount_local` + +Mount a device's storage at a local temporary path. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** `{"path": "/tmp/kcd-sftp-abcdef123456"}` + +#### `sftp_unmount` + +Unmount a previously mounted SFTP filesystem. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_..."} +``` + +**Response data:** none + +#### `sftp_browse` + +Request fresh SFTP credentials and either list available storage volumes or +mount a specific one. + +**Request payload:** + +- Without volume (list mode): `{"deviceId": "a1b2c3d4e5f6_..."}` +- With volume (mount mode): `{"deviceId": "a1b2c3d4e5f6_...", "volume": "/storage/emulated/0"}` + +The `volume` field accepts an index (0-based), volume name, or path. The +daemon resolves it against the device's reported volumes after receiving +fresh credentials. + +**Response data:** `SftpBrowseResponse` + +```json +{ + "ok": true, + "data": { + "path": "/home/user/Downloads/kcd/mnt/kcd-sftp-a1b2c3d4", + "volumes": [ + {"name": "Internal shared storage", "path": "/storage/emulated/0"}, + {"name": "SD card", "path": "/storage/ABCD-1234"} + ] + } +} +``` + +In list mode (`volume` omitted or empty), `path` is empty and `volumes` is +populated. In mount mode, `path` contains the local sshfs mount point. + +**Error cases:** + +- `"device not found"` — the device ID is unknown or was removed +- `"sftp plugin not enabled"` — SFTP is disabled in config +- `"timed out after 20s waiting for SFTP response"` — phone did not respond + +#### `mpris_status` + +Get MPRIS watcher status (which local players are tracked). + +**Request payload:** none + +**Response data:** `MprisDebugStatus` + +```json +{ + "ok": true, + "data": { + "watcherRunning": true, + "deviceCount": 2, + "players": ["spotify", "firefox"], + "playerMappings": { + "a1b2c3d4e5f6_...": "spotify" + } + } +} +``` + +#### `mpris_action` + +Send an MPRIS control action to a device. + +**Request payload:** + +```json +{"deviceId": "a1b2c3d4e5f6_...", "action": "play"} +``` + +Supported actions: `"play"`, `"pause"`, `"playpause"`, `"next"`, `"previous"`, +`"stop"`, `"raise"`, `"quit"`. Volume can be set with `"setVolume"` (requires +an integer value field). Seek with `"seek"` (int64, offset in ms) or +`"setPosition"` (int64, absolute position in ms). + +**Response data:** none + +#### `mpris_remote` + +List remote MPRIS players (players on paired devices). + +**Request payload:** none + +**Response data:** `MprisRemoteResponse` + +```json +{ + "ok": true, + "data": { + "players": [ + {"deviceId": "a1b2c3d4e5f6_...", "player": "spotify"} + ] + } +} +``` + +--- + +## 4. Watch Protocol + +The `watch` command establishes a persistent connection that streams live +events as NDJSON. Unlike all other commands, the connection stays open. + +### Request + +```json +{"cmd": "watch", "payload": {"events": ["battery.update", "notification"]}} +``` + +The `payload.events` array is optional. If omitted, **all** event types are +streamed. If present, only events whose type string matches one of the entries +are delivered. + +### Initial Sequence + +1. **Ack line:** The daemon sends one `{"ok": true}` immediately upon + 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: + + **2a. `device.connected`:** + ```json + {"type":"device.connected","deviceId":"...","timestamp":"2026-05-27T10:00:00Z","payload":{"id":"...","name":"Pixel 9","type":"phone"}} + ``` + + **2b. `battery.update`:** + ```json + {"type":"battery.update","deviceId":"...","timestamp":"...","payload":{"charge":85,"charging":true}} + ``` + + **2c. `mpris.update`** (only if MPRIS plugin is registered AND the cached + state is less than 10 seconds old): + ```json + {"type":"mpris.update","deviceId":"...","timestamp":"...","payload":{...NowPlaying...}} + ``` + +3. **Live stream:** All matching events are streamed as they occur, one per + line, until the client disconnects or the daemon shuts down. + +### Event Wire Format + +```json +{ + "type": "battery.update", + "timestamp": "2026-05-27T10:00:00Z", + "deviceId": "a1b2c3d4e5f6_...", + "payload": { ... } +} +``` + +| Field | Type | Description | +|---|---|---| +| `type` | string | Event type identifier | +| `timestamp` | string (RFC3339) | UTC time when the event was published | +| `deviceId` | string | The device that triggered the event (empty for daemon-level events) | +| `payload` | JSON | Per-type payload, see section 4.2 | + +### Event Filters + +The `events` filter is applied on the server side. Only events whose type +string exactly matches one of the filters are delivered. Invalid/unknown +filter strings are silently ignored — they simply match nothing. + +### Reconnection + +The official `kcd watch` CLI client implements automatic reconnection with +exponential backoff (1 second initial, 30 second maximum, randomized jitter). +Client authors are encouraged to adopt a similar strategy. + +--- + +## 5. Event Types + +### 5.1 Device Events + +#### `device.added` + +A new device was discovered on the network. + +**Payload:** `string` (the device name) + +#### `device.removed` + +A device disappeared from the network (or was manually removed). + +**Payload:** none (`null`) + +#### `device.connected` + +A TCP connection was established with a device. + +**Payload:** + +```json +{"id": "...", "name": "Pixel 9", "type": "phone"} +``` + +#### `device.disconnected` + +A TCP connection was lost. + +**Payload:** none (`null`) + +### 5.2 Pairing Events + +#### `pair.requested` + +A remote device is requesting pairing. + +**Payload:** + +```json +{"name": "Pixel 9", "type": "phone", "verificationKey": "ABCD1234EFGH5678"} +``` + +The 16-character verification key should be displayed to the user to confirm +the same key is shown on the remote device. + +#### `pair.accepted` + +A pairing was accepted. + +**Payload:** + +```json +{"name": "Pixel 9", "type": "phone"} +``` + +#### `pair.rejected` + +A pairing request was rejected, or a device was unpaired. + +**Payload:** + +```json +{"name": "Pixel 9", "type": "phone"} +``` + +### 5.3 Battery Events + +#### `battery.update` + +Battery state changed or was requested. + +**Payload:** + +```json +{"charge": 85, "charging": true} +``` + +| Field | Type | Description | +|---|---|---| +| `charge` | number | Battery percentage (0–100) | +| `charging` | bool | Whether the device is currently charging | + +#### `battery.threshold` + +A battery threshold event was received from the device (e.g. low battery +warning). + +**Payload:** + +```json +{"charge": 15, "charging": false, "event": 1} +``` + +### 5.4 Notification Events + +#### `notification` + +A notification was received from a device. + +**Payload:** + +```json +{ + "appName": "WhatsApp", + "title": "John Doe", + "text": "See you at 5", + "requestReplyId": "reply-123", + "id": "notif-456" +} +``` + +| Field | Type | Description | +|---|---|---| +| `appName` | string | Application name (Android `appName` field) | +| `title` | string | Notification title (Android `title` field) | +| `text` | string | Notification body text (Android `ticker` field) | +| `requestReplyId` | string | Present if the notification supports inline replies | +| `id` | string | Notification identifier | + +#### `notification.canceled` + +A notification was dismissed by the device. + +**Payload:** + +```json +{"id": "notif-456"} +``` + +### 5.5 Share Events + +#### `share.progress` + +A file transfer is in progress. + +**Payload:** + +```json +{"file": "photo.jpg", "current": 524288, "total": 2097152} +``` + +| Field | Type | Description | +|---|---|---| +| `file` | string | Filename being transferred | +| `current` | number | Bytes received so far | +| `total` | number | Total file size in bytes | + +#### `share.complete` + +A file transfer completed (or failed). + +**Payload (success):** + +```json +{"file": "photo.jpg", "success": true} +``` + +**Payload (failure):** + +```json +{"file": "photo.jpg", "success": false, "error": "permission denied"} +``` + +#### `share.text` + +Text was shared from the device. + +**Payload:** + +```json +{"text": "Hello, check this out!"} +``` + +#### `share.url` + +A URL was shared from the device. + +**Payload:** + +```json +{"url": "https://example.com"} +``` + +### 5.6 Ping Events + +#### `ping.received` + +A ping was received from a device. + +**Payload:** + +```json +{"message": "Ping!"} +``` + +### 5.7 Telephony Events + +#### `telephony.ringing` + +An incoming call is ringing. + +**Payload:** + +```json +{"event": "ringing", "contactName": "John Doe", "phoneNumber": "+1234567890", "isCancel": false} +``` + +#### `telephony.talking` + +A call is in progress. + +**Payload:** same shape as `telephony.ringing` with `"event": "talking"`. + +#### `telephony.missed` + +A call was missed. + +**Payload:** same shape with `"event": "missed"`. + +#### `telephony.canceled` + +A call was cancelled. + +**Payload:** full `TelephonyBody` struct (includes `event`, `contactName`, +`phoneNumber`). + +### 5.8 Connectivity Events + +#### `connectivity.update` + +Cellular/Wi-Fi signal strength report from the device. + +**Payload:** + +```json +{ + "signalStrengths": { + "wlan0": { + "networkType": "Wi-Fi", + "networkDetailedType": "Wi-Fi", + "signalStrength": 4 + }, + "rmnet0": { + "networkType": "mobile", + "networkDetailedType": "LTE", + "signalStrength": 3 + } + } +} +``` + +### 5.9 SFTP Events + +#### `sftp.mount` + +SFTP credentials received (success) or error. + +**Payload (success):** + +```json +{ + "uri": "sftp://192.168.1.42:31588", + "ip": "192.168.1.42", + "port": "31588", + "user": "u0_a123", + "password": "sftp-password-here", + "path": "/storage/emulated/0", + "volumes": [{"name": "Internal shared storage", "path": "/storage/emulated/0"}] +} +``` + +**Payload (error):** + +```json +{"error": "SFTP server rejected credentials"} +``` + +### 5.10 Volume Events + +#### `volume.update` + +Device volume level changed. + +**Payload:** + +```json +{"name": "media", "volume": 70, "muted": false} +``` + +| Field | Type | Description | +|---|---|---| +| `name` | string | Audio stream name | +| `volume` | number | Volume level (0–100) | +| `muted` | bool | Whether the stream is muted | + +### 5.11 SMS Events + +#### `sms.incoming` + +An SMS or MMS message was received. + +**Payload:** + +```json +{ + "body": "Hello!", + "sender": "+1234567890", + "date": 1716800000000, + "type": 1, + "thread_id": 42, + "read": false, + "event": 0, + "u_id": 98765, + "sub_id": 0, + "addresses": [{"address": "+1234567890"}], + "attachments": [{"part_id": 1, "mime_type": "image/jpeg", "unique_identifier": "..."}] +} +``` + +#### `sms.attachment` + +An MMS attachment has been downloaded. + +**Payload:** + +```json +{"filename": "image.jpg", "path": "/tmp/kcd-sms-attachment-...", "thread_id": 42} +``` + +### 5.12 Ring Events + +#### `ring.received` + +A ring/find-my-phone request was received from a device (the phone is asking +this daemon to ring). + +**Payload:** none (`null`) + +### 5.13 MPRIS Events + +#### `mpris.update` + +Now-playing state from a device's media player. + +**Payload:** + +```json +{ + "player": "spotify", + "title": "Song Title", + "artist": "Artist Name", + "album": "Album Name", + "albumArtUrl": "https://i.scdn.co/image/...", + "url": "spotify:track:...", + "length": 240000, + "pos": 45000, + "isPlaying": true, + "volume": 80, + "canControl": true, + "canGoNext": true, + "canGoPrevious": true, + "canPause": true, + "canPlay": true, + "canSeek": true, + "playbackStatus": "Playing", + "shuffle": false, + "loopStatus": "None" +} +``` + +--- + +## 6. Outbound Packet Reference + +Packets that the daemon sends to remote devices. These are relevant for +understanding protocol-level interactions but are generally abstracted by the +IPC interface. Included here for completeness and for advanced client authors +who may want to implement a full network-level implementation. + +| Packet Type | Direction (Daemon → Device) | Trigger | +|---|---|---| +| `kdeconnect.identity` | Handshake | TLS identity exchange (sent twice: plaintext pre-TLS + full after TLS) | +| `kdeconnect.pair` | Pairing | Pair/unpair/reject actions | +| `kdeconnect.battery` | Battery | Reply to battery request + push on connect | +| `kdeconnect.battery.request` | Battery | Request phone battery state on connect | +| `kdeconnect.clipboard` | Clipboard | Push clipboard content to phone | +| `kdeconnect.clipboard.connect` | Clipboard | Push clipboard with timestamp on connect | +| `kdeconnect.mousepad.keyboardstate` | Mousepad | Advertise keyboard capability on connect | +| `kdeconnect.mpris` | MPRIS | Player list, NowPlaying state, seek positions, album art (broadcast + request-reply) | +| `kdeconnect.mpris.request` | MPRIS | Request player list, now-playing, volume; send control actions | +| `kdeconnect.notification.reply` | Notification | Reply to a notification with inline reply support | +| `kdeconnect.notification` | RunCommand | Command output notification pushed to phone | +| `kdeconnect.runcommand` | RunCommand | Send command list to phone | +| `kdeconnect.runcommand.request` | RunCommand | Request phone's command list / execute command | +| `kdeconnect.share.request` | Share | File transfer invitation (side-channel) | +| `kdeconnect.sftp.request` | SFTP | Request the phone to start its SFTP server | +| `kdeconnect.sms.request` | SMS | Send an SMS | +| `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.telephony.request_mute` | Telephony | Mute incoming call ringer | +| `kdeconnect.systemvolume` | SystemVolume | Push local sink list to phone | +| `kdeconnect.findmyphone.request` | FindMyPhone | Trigger phone ringer | +| `kdeconnect.connectivity_report.request` | Connectivity | Request signal strength report | +| `kdeconnect.ping` | Ping | Send a ping | + +--- + +## 7. Inbound Packet Reference + +Packets that the daemon handles from remote devices. Each entry shows which +plugin processes it and a link to the body struct definition. + +| Packet Type | Plugin | Body Struct | +|---|---|---| +| `kdeconnect.pair` | Pair | `PairBody{Pair bool, Timestamp int64}` | +| `kdeconnect.battery` | Battery | `BatteryBody{CurrentCharge int, IsCharging bool, ThresholdEvent int}` | +| `kdeconnect.battery.request` | Battery | (empty, triggers a battery reply) | +| `kdeconnect.notification` | Notification | `NotificationBody{ID, AppName, Title, Text, IsCancel, IsClearable, Silent, RequestReplyId string}` | +| `kdeconnect.share.request` | Share | `ShareBody{Filename, NumberOfFiles, TotalPayloadSize, LastModified, CreationTime, Text, Url}` | +| `kdeconnect.sftp` | SFTP | `SftpBody{IP, Port, User, Password, Path, MultiPaths, PathNames, ErrorMessage}` | +| `kdeconnect.ping` | Ping | `PingBody{Message string}` | +| `kdeconnect.mousepad.request` | Mousepad | `MousepadBody{Dx, Dy, X, Y, SingleClick, DoubleClick, MiddleClick, RightClick, SingleHold, SingleRel, Scroll, Key, SpecialKey, Shift, Ctrl, Alt, Super}` | +| `kdeconnect.systemvolume.request` | SystemVolume | `VolumeBody{RequestSinks, Name, Volume, Muted, MaxVolume}` | +| `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.findmyphone.request` | FindMyPhone | (empty, triggers ring event) | +| `kdeconnect.connectivity_report` | Connectivity | `ConnectivityBody{SignalStrengths map[string]SignalStrength}` | +| `kdeconnect.clipboard` | Clipboard | `ClipboardBody{Content string, Timestamp int64}` | +| `kdeconnect.clipboard.connect` | Clipboard | `ClipboardBody{Content string, Timestamp int64}` | +| `kdeconnect.clipboard.file` | Clipboard | `ClipboardFileBody{Filename string}` | +| `kdeconnect.lock` | LockDevice | `LockBody{RequestLocked, SetLocked, IsLocked}` | +| `kdeconnect.lock.request` | LockDevice | `LockBody{}` (triggers lock/unlock) | +| `kdeconnect.mpris` | MPRIS | `MPRISRequest{RequestPlayerList, RequestNowPlaying, RequestVolume, Player, Action, ...}` | +| `kdeconnect.mpris.request` | MPRIS | `MPRISRequest{}` (same struct, different semantics) | +| `kdeconnect.runcommand.request` | RunCommand | `RequestBody{RequestCommandList bool, Key string}` | +| `kdeconnect.presenter` | Presenter | `PresenterBody{Dx, Dy *float64, Stop *bool}` | + +--- + +## 8. Complete Walkthroughs + +### 8.1 List Devices (nc) + +```bash +$ echo '{"cmd":"devices"}' | nc -U /run/user/1000/kcd/kcd.sock +{"ok":true,"data":[{"id":"a1b2c3d4e5f6_...","name":"Pixel 9","type":"phone","state":"PAIRED","cert_fp":"","last_seen":"0001-01-01T00:00:00Z","connected":true}]} +``` + +### 8.2 Watch Events (socat) + +```bash +$ socat - UNIX-CONNECT:/run/user/1000/kcd/kcd.sock +{"cmd":"watch","payload":{"events":["battery.update","notification"]}} +``` + +After sending the request, read the ack line, then process events: + +```bash +# With jq for formatting: +$ echo '{"cmd":"watch","payload":{"events":["battery.update"]}}' | socat - UNIX-CONNECT:/run/user/1000/kcd/kcd.sock | while read -r line; do + [ "$line" = '{"ok":true}' ] && continue + echo "$line" | jq . +done +``` + +### 8.3 Pairing Flow + +1. **Start listening on the daemon:** + ```bash + $ echo '{"cmd":"pair_listen"}' | nc -U /run/user/1000/kcd/kcd.sock + ``` + + This blocks until a pair request arrives. The output: + ```json + {"ok":true,"data":{"deviceId":"a1b2...","deviceName":"Pixel 9","verificationKey":"ABCD1234EFGH5678"}} + ``` + +2. **In another terminal, accept the pairing:** + ```bash + $ echo '{"cmd":"pair","payload":{"deviceId":"a1b2...","accept":true}}' | nc -U /run/user/1000/kcd/kcd.sock + {"ok":true} + ``` + +3. **The phone shows the same verification key.** User confirms on both ends. + +### 8.4 Ping a Device + +```bash +$ echo '{"cmd":"ping","payload":{"deviceId":"a1b2..."}}' | nc -U /run/user/1000/kcd/kcd.sock +{"ok":true} +``` + +--- + +## 9. Error Handling + +### Error Response + +All commands return errors in the same format: + +```json +{"ok": false, "error": "device not found"} +``` + +Common error strings: + +| Error string | Meaning | +|---|---| +| `"device not found"` | The device ID is unknown or not paired | +| `"device not connected"` | The device is paired but not currently connected | +| `"plugin not enabled"` | The required plugin is disabled in config | +| `"invalid payload"` | The request payload could not be deserialized | +| `"command not found"` | The requested IPC command does not exist | + +### Connection Loss + +- If the daemon is not running, the socket connect will fail with `ENOENT` or + `ECONNREFUSED`. +- The daemon creates the socket directory and socket file on startup, and + removes the socket file on shutdown. The state directory (`$XDG_STATE_HOME`) + is preserved. +- For the `watch` protocol, the daemon does not send explicit keepalive + pings. A client can detect disconnection by a read returning 0 bytes or an + error. Implement reconnection with exponential backoff. + +### Concurrency + +The IPC server is single-threaded per connection; each connection is handled +in its own goroutine. Long-running commands (`pair_listen`, `watch`) block +that connection but do not block other connections. Send one request per +connection and use a separate connection for concurrent commands. diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..d430f10 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# kcd entrypoint — fixes volume ownership and creates default config. + +for d in /config /state /data /run; do + [ "$(stat -c %u "$d")" = "0" ] && chown -R 65534:65534 "$d" +done + +if [ ! -f /config/kcd/kcd.toml ]; then + mkdir -p /config/kcd + cat > /config/kcd/kcd.toml << 'EOF' +log_level = "info" + +[plugins] +battery = true +notification = false +mpris = false +systemvolume = false +mousepad = false +EOF +fi + +exec /usr/bin/kcd "$@" diff --git a/internal/daemon/ipc_routes_sftp.go b/internal/daemon/ipc_routes_sftp.go index 5d14719..8f7011a 100644 --- a/internal/daemon/ipc_routes_sftp.go +++ b/internal/daemon/ipc_routes_sftp.go @@ -3,6 +3,8 @@ package daemon import ( "context" "encoding/json" + "strconv" + "strings" "github.com/bethropolis/kcd/internal/device" "github.com/bethropolis/kcd/internal/ipc" @@ -10,6 +12,42 @@ import ( "github.com/bethropolis/kcd/internal/plugins/sftp" ) +// resolveVolume resolves a user-supplied volume argument (index, name, or path) +// against a list of StorageVolume. Returns the matching path or empty string. +func resolveVolume(arg string, volumes []ipc.StorageVolumeResponse) string { + if len(volumes) == 0 { + return "" + } + + // Try index first. + if idx, err := strconv.Atoi(arg); err == nil && idx >= 0 && idx < len(volumes) { + return volumes[idx].Path + } + + // Try name match (case-insensitive). + for _, v := range volumes { + if strings.EqualFold(v.Name, arg) { + return v.Path + } + } + + // Try path match (exact). + for _, v := range volumes { + if v.Path == arg { + return v.Path + } + } + + // Try path match (case-insensitive). + for _, v := range volumes { + if strings.EqualFold(v.Path, arg) { + return v.Path + } + } + + return "" +} + func registerSftpRoutes(handler *ipc.Handler, devices *device.Registry, plugins *plugin.Registry) { handler.Register(ipc.CmdSftpInfo, func(req ipc.Request) ipc.Response { var p ipc.DevicePayload @@ -95,4 +133,52 @@ func registerSftpRoutes(handler *ipc.Handler, devices *device.Registry, plugins } return ipc.Response{OK: true} }) + handler.Register(ipc.CmdSftpBrowse, func(req ipc.Request) ipc.Response { + var p ipc.SftpBrowsePayload + if err := json.Unmarshal(req.Payload, &p); err != nil { + return ipc.Response{OK: false, Error: "invalid payload"} + } + pl, ok := plugins.GetByName("SFTP") + if !ok { + return ipc.Response{OK: false, Error: "sftp plugin not enabled"} + } + dev, ok := devices.Get(p.DeviceID) + if !ok { + return ipc.Response{OK: false, Error: "device not found"} + } + sftpPl := pl.(*sftp.SftpPlugin) + + volumePath := p.Volume + + // If a volume was specified, try to resolve it to a path. + if volumePath != "" { + vols := sftpPl.Volumes(p.DeviceID) + if len(vols) > 0 { + sv := make([]ipc.StorageVolumeResponse, len(vols)) + for i, v := range vols { + sv[i] = ipc.StorageVolumeResponse{Name: v.Name, Path: v.Path} + } + if resolved := resolveVolume(volumePath, sv); resolved != "" { + volumePath = resolved + } + } + } + + mountPath, volumes, err := sftpPl.RequestAndMountVolume(context.Background(), dev, volumePath) + if err != nil { + return ipc.Response{OK: false, Error: err.Error()} + } + + vols := make([]ipc.StorageVolumeResponse, len(volumes)) + for i, v := range volumes { + vols[i] = ipc.StorageVolumeResponse{Name: v.Name, Path: v.Path} + } + + resp := ipc.SftpBrowseResponse{ + Path: mountPath, + Volumes: vols, + } + data, _ := json.Marshal(resp) + return ipc.Response{OK: true, Data: data} + }) } diff --git a/internal/ipc/proto.go b/internal/ipc/proto.go index a9b1c9b..cc7f784 100644 --- a/internal/ipc/proto.go +++ b/internal/ipc/proto.go @@ -38,6 +38,7 @@ const ( CmdMprisStatus = "mpris_status" CmdMprisAction = "mpris_action" CmdMprisRemote = "mpris_remote" + CmdSftpBrowse = "sftp_browse" ) // ConnectPayload carries the target IP for the CmdConnect command. @@ -139,6 +140,18 @@ type StorageVolumeResponse struct { Path string `json:"path"` } +// SftpBrowsePayload is used for CmdSftpBrowse. +type SftpBrowsePayload struct { + DeviceID string `json:"deviceId"` + Volume string `json:"volume,omitempty"` +} + +// SftpBrowseResponse is returned by CmdSftpBrowse. +type SftpBrowseResponse struct { + Path string `json:"path,omitempty"` + Volumes []StorageVolumeResponse `json:"volumes,omitempty"` +} + type MprisPlayerInfo struct { DisplayName string `json:"displayName"` BusName string `json:"busName"` diff --git a/internal/plugins/sftp/sftp.go b/internal/plugins/sftp/sftp.go index 5f0aa68..3080ce7 100644 --- a/internal/plugins/sftp/sftp.go +++ b/internal/plugins/sftp/sftp.go @@ -190,7 +190,7 @@ func (p *SftpPlugin) RequestAndMount(ctx context.Context, dev device.Sender) (st if !exists { return "", fmt.Errorf("credentials missing after event (internal error)") } - return p.mountWithBody(ctx, dev.ID(), body) + return p.mountWithBody(ctx, dev.ID(), body, "") case <-deadline.Done(): return "", fmt.Errorf("timed out after %s waiting for SFTP response — is the KDE Connect app open on the phone?", timeout) @@ -198,6 +198,65 @@ func (p *SftpPlugin) RequestAndMount(ctx context.Context, dev device.Sender) (st } } +// RequestAndMountVolume sends the SFTP request, waits for credentials, then +// mounts the specified volume. If volumePath is empty, the available volumes +// are returned without mounting (list mode). The caller is responsible for +// closing the returned closer when done with the mounted path. +func (p *SftpPlugin) RequestAndMountVolume(ctx context.Context, dev device.Sender, volumePath string) (mountPath string, volumes []StorageVolume, err error) { + if p.bus == nil { + return "", nil, fmt.Errorf("event bus not available") + } + + sub := p.bus.Subscribe(0, events.TypeSftpMount) + defer sub.Close() + + if err := p.RequestMount(dev); err != nil { + return "", nil, fmt.Errorf("send SFTP request: %w", err) + } + + p.logger.Info("SFTP request sent, waiting for phone response", zap.String("device", dev.ID())) + + timeout := time.Duration(p.cfg.CredentialsTimeoutSecs) * time.Second + if timeout == 0 { + timeout = 20 * time.Second + } + deadline, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for { + select { + case evt, ok := <-sub.C: + if !ok { + return "", nil, fmt.Errorf("event bus closed") + } + if evt.DeviceID != dev.ID() { + continue + } + p.mu.RLock() + body, exists := p.lastBody[dev.ID()] + p.mu.RUnlock() + if !exists { + return "", nil, fmt.Errorf("credentials missing after event (internal error)") + } + + vols := p.buildVolumes(body) + + if volumePath == "" { + return "", vols, nil + } + + path, err := p.mountWithBody(ctx, dev.ID(), body, volumePath) + if err != nil { + return "", nil, err + } + return path, vols, nil + + case <-deadline.Done(): + return "", nil, fmt.Errorf("timed out after %s waiting for SFTP response — is the KDE Connect app open on the phone?", timeout) + } + } +} + // MountLocally mounts using previously cached credentials. // Prefer RequestAndMount for a one-step experience. func (p *SftpPlugin) MountLocally(ctx context.Context, deviceID string) (string, error) { @@ -207,11 +266,13 @@ func (p *SftpPlugin) MountLocally(ctx context.Context, deviceID string) (string, if !ok { return "", fmt.Errorf("no SFTP credentials cached for device %s — use 'kcd sftp mount' which requests them automatically", deviceID) } - return p.mountWithBody(ctx, deviceID, body) + return p.mountWithBody(ctx, deviceID, body, "") } // mountWithBody performs the sshfs mount and returns the local browse path. -func (p *SftpPlugin) mountWithBody(ctx context.Context, deviceID string, body SftpBody) (string, error) { +// volumePath specifies which storage volume to mount. If empty, the first +// available volume is selected automatically. +func (p *SftpPlugin) mountWithBody(ctx context.Context, deviceID string, body SftpBody, volumePath string) (string, error) { baseDir := p.cfg.MountDir if baseDir == "" { baseDir = os.TempDir() @@ -224,13 +285,17 @@ func (p *SftpPlugin) mountWithBody(ctx context.Context, deviceID string, body Sf // Determine the remote path on the Android device. // The Android SFTP server exposes the real filesystem at "/". // Listing "/" via sshfs fails because it contains permission-denied - // entries (/proc, /sys). Instead, mount directly to the first storage + // entries (/proc, /sys). Instead, mount directly to a storage // volume (e.g. /storage/emulated/0) which is guaranteed browsable. - remotePath := "" - if len(body.MultiPaths) > 0 { - remotePath = body.MultiPaths[0] - } else if body.Path != "" && body.Path != "/" { - remotePath = body.Path + // If a specific volumePath is provided, use it; otherwise auto-select + // the first available volume. + remotePath := volumePath + if remotePath == "" { + if len(body.MultiPaths) > 0 { + remotePath = body.MultiPaths[0] + } else if body.Path != "" && body.Path != "/" { + remotePath = body.Path + } } remoteRoot := fmt.Sprintf("%s@%s:%s", body.User, body.IP, remotePath) @@ -339,13 +404,10 @@ func (p *SftpPlugin) Info(deviceID string) *SftpInfo { return info } -// Volumes returns the list of available storage volumes from cached credentials. -// Returns nil if no credentials or no multiPaths data. -func (p *SftpPlugin) Volumes(deviceID string) []StorageVolume { - p.mu.RLock() - defer p.mu.RUnlock() - body, ok := p.lastBody[deviceID] - if !ok || len(body.MultiPaths) == 0 { +// buildVolumes constructs a StorageVolume slice from a SftpBody. +// Caller must hold at least a read lock on p.mu if body comes from p.lastBody. +func (p *SftpPlugin) buildVolumes(body SftpBody) []StorageVolume { + if len(body.MultiPaths) == 0 { return nil } volumes := make([]StorageVolume, 0, len(body.MultiPaths)) @@ -359,6 +421,18 @@ func (p *SftpPlugin) Volumes(deviceID string) []StorageVolume { return volumes } +// Volumes returns the list of available storage volumes from cached credentials. +// Returns nil if no credentials or no multiPaths data. +func (p *SftpPlugin) Volumes(deviceID string) []StorageVolume { + p.mu.RLock() + defer p.mu.RUnlock() + body, ok := p.lastBody[deviceID] + if !ok { + return nil + } + return p.buildVolumes(body) +} + func (p *SftpPlugin) OnConnect(_ device.Sender) {} func (p *SftpPlugin) OnDisconnect(dev device.Sender) { diff --git a/packaging/kcd.fish-completion b/packaging/kcd.fish-completion index 22c3f1c..ff37d5b 100644 --- a/packaging/kcd.fish-completion +++ b/packaging/kcd.fish-completion @@ -157,11 +157,16 @@ share.text\t'Text shared from phone' share.url\t'URL shared from phone' ping.received\t'Ping received' telephony.ringing\t'Incoming call' +telephony.talking\t'Call in progress' telephony.missed\t'Missed call' telephony.canceled\t'Call ended' connectivity.update\t'Cell signal changed' +volume.update\t'System volume changed' sftp.mount\t'SFTP filesystem mounted' -volume.update\t'System volume changed'" +mpris.update\t'Now playing changed' +sms.incoming\t'SMS/MMS received' +sms.attachment\t'MMS attachment downloaded' +ring.received\t'Phone wants PC to ring'" # --------------------------------------------------------------------------- # sftp request|info|volumes|mount|unmount diff --git a/pkg/client/client.go b/pkg/client/client.go index c97daf7..7f240b2 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -228,6 +228,25 @@ func (c *Client) SftpUnmount(deviceID string) error { return err } +// SftpBrowse requests fresh SFTP credentials from the phone and either lists +// available volumes (volume arg empty) or mounts the specified volume. +// volume can be an index (0-based), volume name, or path. +// Returns the mount path (empty if listing) and available volumes. +func (c *Client) SftpBrowse(deviceID string, volume string) (string, []ipc.StorageVolumeResponse, error) { + resp, err := c.Call(ipc.CmdSftpBrowse, ipc.SftpBrowsePayload{ + DeviceID: deviceID, + Volume: volume, + }) + if err != nil { + return "", nil, err + } + var result ipc.SftpBrowseResponse + if len(resp.Data) > 0 { + _ = json.Unmarshal(resp.Data, &result) + } + return result.Path, result.Volumes, nil +} + // BroadcastStart asks the daemon to begin UDP/mDNS broadcasting. func (c *Client) BroadcastStart() error { _, err := c.Call(ipc.CmdBroadcastStart, nil)