Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -115,6 +114,8 @@ dockers_v2:
- "v{{ .Version }}"
- latest
dockerfile: Dockerfile.goreleaser
extra_files:
- entrypoint.sh
ids:
- kcd
labels:
Expand Down
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 5 additions & 29 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 \
Expand All @@ -23,67 +20,46 @@ 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" \
org.opencontainers.image.url="https://github.com/bethropolis/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"]
8 changes: 4 additions & 4 deletions Dockerfile.goreleaser
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand All @@ -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 \
Expand All @@ -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"]
55 changes: 55 additions & 0 deletions cmd/kcd/cli_sftp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<device-id> [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 <device-id> <index|name|path>")
return nil
},
},
},
}
106 changes: 101 additions & 5 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <device-id> [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 <device-id> <index|name|path>
```

**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
Expand Down Expand Up @@ -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 <device-id> <phone-number> <message>
kcd sms send <device-id> <phone-number> <message>
```

**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 <device-id>
```

### sms conversation

Request messages from a specific conversation thread.

```
kcd sms conversation <device-id> <thread-id>
```

### 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 <device-id> <part-id> <unique-identifier>
```

---

Expand Down Expand Up @@ -666,16 +756,22 @@ kcd watch [--events <type,...>] [--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 |
| `telephony.ringing` | Incoming call: `{contactName, phoneNumber}` |
| `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

Expand Down
Loading
Loading