feat(activity): rkd-activity, Steam game detection on Linux (#1121) #6

Merged
dozken merged 4 commits from feat/1121-plugins-rkd-activity-v1-steam-game-detec into main 2026-09-25 14:16:11 +02:00
Owner

Summary

Adds rkd-activity (local/activity), the local plugin that tells the RKD desktop app which Steam game is running on Linux, for the activity status in ADR-0005 (decision 3). It speaks msg::activity from #5: watch/unwatch of activity.game.watch@1, answered with {kind: "game", name, steam_app_id, started_at} or null.

Closes nanoya-biz/rkd#1121

Stacked on #5. Base is feat/1119-plugins-split-rkd-local-link-handshake-fr, not main. Retarget this PR to main after #5 merges. CI here only runs pull_request for branches: [main], so this PR gets no CI runs until it is retargeted; the local gates are listed below.

Type of change

  • New feature
  • Docs

Screenshots / recording

No UI. The pair is the plugin itself, before and after.

BEFORE (base origin/feat/1119-…): there is no activity plugin.

$ git ls-tree --name-only origin/feat/1119-plugins-split-rkd-local-link-handshake-fr local/
local/README.md
local/WINDOWS-CHECKLIST.md
local/local-link
local/terminal

AFTER (this branch, in a Linux container, rust:1.98 arm64 under OrbStack, real /proc): a fake Steam install with Portal 2 in a second library listed in libraryfolders.vdf, a process started as env SteamAppId=620 SECRET_TOKEN=hunter2 sleep 300, and a Python client doing pair_begin → hello → pair → watch. Killing the process sends null.

rkd-activity 0.1.0 listening on /tmp/rt/rkd-plugins/activity.sock
Socket:  /tmp/rt/rkd-plugins/activity.sock
Plugin:  running
Detects: Steam games, while the RKD app watches
Paired:  nothing yet (run `rkd-activity pair`)
Service: not installed (run `rkd-activity install`)
hello: {'type': 'hello', 'protocol': 1, 'id': 'activity', 'version': '0.1.0', 'provides': ['activity.game.watch@1'], 'known': False, 'nonce': '57fb…'}
{'type': 'ready'}
watch answer: {"type": "activity", "activity": {"kind": "game", "name": "Portal 2", "steam_app_id": 620, "started_at": 1790296236}}
next: {"type": "activity", "activity": null}

Test plan

Automated:

  • cargo test --workspace --locked passes locally on macOS (45 in rkd-activity), and cargo test -p rkd-activity --locked passes on Linux in the container (44; the off-Linux test is cfg'd out there)
  • cargo fmt --all --check, cargo clippy --workspace --all-targets --locked -- -D warnings (macOS)
  • cargo clippy --target x86_64-pc-windows-msvc --workspace --all-targets --locked -- -D warnings (the windows-check job)
  • cargo clippy --target x86_64-unknown-linux-gnu -p rkd-activity --all-targets --locked -- -D warnings, so the Linux-only code is linted from the Mac as well
  • Added tests covering the change. They include no_process_or_environment_value_reaches_the_link, which runs the real scan over a fake /proc full of other processes and secret-looking variables and checks every byte the plugin sends for them, and nothing_is_scanned_without_an_open_watch
  • Mutations applied by hand to confirm the tests go red: not aborting the scanner on the last close fails the no-scan test, and no min_interval hold-back fails the two interval tests

Manual:

  • Linux smoke run above: real /proc, status, pair, watch, game quits → null
  • Not run with a real Steam client or a real game (no Linux desktop with Steam here). The env var, libraryfolders.vdf and .acf shapes come from Steam's current formats and the old LibraryFolders one. A Proton game is expected to carry SteamAppId in its wine processes too, but that has not been checked
  • Not paired with the desktop app, whose activity client is separate work in the RKD app

Review checklist

  • No dbg! or commented-out code left behind
  • No secrets, tokens, or personal URLs added
  • New dependencies justified: none new to the lockfile; the crate uses rkd-local-link, anyhow, serde, serde_json, tokio (unix socket, no TCP use) and libc. No HTTP or network crate
  • README updated (local/README.md has a rkd-activity section on what it reads and sends; the root README lists it)

Notes for the reviewer

  • Scan only while watched. One scanner task is shared by all connections. It starts with the first open watch, scans every 2 s and stops when the last watch closes (on unwatch or when the connection drops). Each watch has its own sender task. It answers a watch at once, even inside the interval, then sends at most one change per MIN_INTERVAL (the latest value when the interval ends), so a game started and quit within the interval is never sent. A generation counter on the published snapshot stops a scanner that is being aborted from publishing after a new one has started.
  • Detection rules. Only processes whose /proc/<pid> is owned by our uid are read, and from each only SteamAppId and the stat start time are kept. procfs returns nothing but Running { app_id, started_at }. SteamAppId=0 is ignored. With several games running, the most recently started one that has a manifest is reported. An id with no manifest → null. Names lose control characters and are cut to MAX_NAME_CHARS (128) chars. started_at is the earliest start of that app id's processes (btime + stat field 22 / CLK_TCK).
  • cfg layout. procfs is cfg(any(target_os = "linux", all(test, unix))), and steam/vdf are cfg(any(target_os = "linux", test)), so a macOS or Windows release binary contains no scanning code, while the fixture tests still run on a Mac. detect::system_source returns None off Linux. Windows builds a stub main that says it is unsupported. The server, CLI and service modules are unix-only.
  • Duplication, deliberately. The socket/handshake/pairing server and the CLI plumbing are a copy of rkd-terminal's, trimmed (no pipe transport). I left local/terminal untouched so this PR does not conflict with #5's review. Extracting a shared plugin-host crate is a sensible follow-up.
  • install is Linux-only (systemd user unit, same exe checks as rkd-terminal). On macOS it says detection is Linux-only; a launchd agent that always reports nothing would be pointless.
  • Deferred: install.sh and release packaging for rkd-activity. install.sh, package-release.sh and release.yml are written around rkd-terminal (names, RKD_TERMINAL_* settings, the tarball layout and its test harness). A sha256-checked installer needs release assets to check, so it belongs with the release-pipeline change. Until then the README says to build from source (cargo install --git … rkd-activity). No systemd hardening either (RestrictAddressFamilies, PrivateNetwork): user units need user namespaces for those, and a private user namespace would stop the plugin reading other processes' environ. Both need checking on a real Linux desktop first.

https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt

## Summary Adds `rkd-activity` (`local/activity`), the local plugin that tells the RKD desktop app which Steam game is running on Linux, for the activity status in ADR-0005 (decision 3). It speaks `msg::activity` from #5: `watch`/`unwatch` of `activity.game.watch@1`, answered with `{kind: "game", name, steam_app_id, started_at}` or `null`. Closes nanoya-biz/rkd#1121 **Stacked on #5.** Base is `feat/1119-plugins-split-rkd-local-link-handshake-fr`, not `main`. Retarget this PR to `main` after #5 merges. CI here only runs `pull_request` for `branches: [main]`, so this PR gets **no CI runs** until it is retargeted; the local gates are listed below. ## Type of change - [x] New feature - [x] Docs ## Screenshots / recording No UI. The pair is the plugin itself, before and after. BEFORE (base `origin/feat/1119-…`): there is no activity plugin. ``` $ git ls-tree --name-only origin/feat/1119-plugins-split-rkd-local-link-handshake-fr local/ local/README.md local/WINDOWS-CHECKLIST.md local/local-link local/terminal ``` AFTER (this branch, in a Linux container, `rust:1.98` arm64 under OrbStack, real `/proc`): a fake Steam install with `Portal 2` in a second library listed in `libraryfolders.vdf`, a process started as `env SteamAppId=620 SECRET_TOKEN=hunter2 sleep 300`, and a Python client doing pair_begin → hello → pair → watch. Killing the process sends `null`. ``` rkd-activity 0.1.0 listening on /tmp/rt/rkd-plugins/activity.sock Socket: /tmp/rt/rkd-plugins/activity.sock Plugin: running Detects: Steam games, while the RKD app watches Paired: nothing yet (run `rkd-activity pair`) Service: not installed (run `rkd-activity install`) hello: {'type': 'hello', 'protocol': 1, 'id': 'activity', 'version': '0.1.0', 'provides': ['activity.game.watch@1'], 'known': False, 'nonce': '57fb…'} {'type': 'ready'} watch answer: {"type": "activity", "activity": {"kind": "game", "name": "Portal 2", "steam_app_id": 620, "started_at": 1790296236}} next: {"type": "activity", "activity": null} ``` ## Test plan Automated: - [x] `cargo test --workspace --locked` passes locally on macOS (45 in `rkd-activity`), and `cargo test -p rkd-activity --locked` passes on Linux in the container (44; the off-Linux test is cfg'd out there) - [x] `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --locked -- -D warnings` (macOS) - [x] `cargo clippy --target x86_64-pc-windows-msvc --workspace --all-targets --locked -- -D warnings` (the `windows-check` job) - [x] `cargo clippy --target x86_64-unknown-linux-gnu -p rkd-activity --all-targets --locked -- -D warnings`, so the Linux-only code is linted from the Mac as well - [x] Added tests covering the change. They include `no_process_or_environment_value_reaches_the_link`, which runs the real scan over a fake `/proc` full of other processes and secret-looking variables and checks every byte the plugin sends for them, and `nothing_is_scanned_without_an_open_watch` - [x] Mutations applied by hand to confirm the tests go red: not aborting the scanner on the last close fails the no-scan test, and no `min_interval` hold-back fails the two interval tests Manual: - [x] Linux smoke run above: real `/proc`, `status`, pair, watch, game quits → `null` - [ ] Not run with a real Steam client or a real game (no Linux desktop with Steam here). The env var, `libraryfolders.vdf` and `.acf` shapes come from Steam's current formats and the old `LibraryFolders` one. A Proton game is expected to carry `SteamAppId` in its wine processes too, but that has not been checked - [ ] Not paired with the desktop app, whose activity client is separate work in the RKD app ## Review checklist - [x] No `dbg!` or commented-out code left behind - [x] No secrets, tokens, or personal URLs added - [x] New dependencies justified: none new to the lockfile; the crate uses `rkd-local-link`, `anyhow`, `serde`, `serde_json`, `tokio` (unix socket, no TCP use) and `libc`. No HTTP or network crate - [x] README updated (`local/README.md` has a `rkd-activity` section on what it reads and sends; the root README lists it) ## Notes for the reviewer - **Scan only while watched.** One scanner task is shared by all connections. It starts with the first open watch, scans every 2 s and stops when the last watch closes (on `unwatch` or when the connection drops). Each watch has its own sender task. It answers a `watch` at once, even inside the interval, then sends at most one change per `MIN_INTERVAL` (the latest value when the interval ends), so a game started and quit within the interval is never sent. A generation counter on the published snapshot stops a scanner that is being aborted from publishing after a new one has started. - **Detection rules.** Only processes whose `/proc/<pid>` is owned by our uid are read, and from each only `SteamAppId` and the stat start time are kept. `procfs` returns nothing but `Running { app_id, started_at }`. `SteamAppId=0` is ignored. With several games running, the most recently started one that has a manifest is reported. An id with no manifest → `null`. Names lose control characters and are cut to `MAX_NAME_CHARS` (128) chars. `started_at` is the earliest start of that app id's processes (`btime` + stat field 22 / `CLK_TCK`). - **cfg layout.** `procfs` is `cfg(any(target_os = "linux", all(test, unix)))`, and `steam`/`vdf` are `cfg(any(target_os = "linux", test))`, so a macOS or Windows release binary contains no scanning code, while the fixture tests still run on a Mac. `detect::system_source` returns `None` off Linux. Windows builds a stub `main` that says it is unsupported. The server, CLI and service modules are unix-only. - **Duplication, deliberately.** The socket/handshake/pairing server and the CLI plumbing are a copy of `rkd-terminal`'s, trimmed (no pipe transport). I left `local/terminal` untouched so this PR does not conflict with #5's review. Extracting a shared plugin-host crate is a sensible follow-up. - **`install` is Linux-only** (systemd user unit, same exe checks as `rkd-terminal`). On macOS it says detection is Linux-only; a launchd agent that always reports nothing would be pointless. - **Deferred:** `install.sh` and release packaging for `rkd-activity`. `install.sh`, `package-release.sh` and `release.yml` are written around `rkd-terminal` (names, `RKD_TERMINAL_*` settings, the tarball layout and its test harness). A sha256-checked installer needs release assets to check, so it belongs with the release-pipeline change. Until then the README says to build from source (`cargo install --git … rkd-activity`). No systemd hardening either (`RestrictAddressFamilies`, `PrivateNetwork`): user units need user namespaces for those, and a private user namespace would stop the plugin reading other processes' `environ`. Both need checking on a real Linux desktop first. https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
A second local plugin (ADR-0005 decision 3) that speaks
msg::activity: watch/unwatch of activity.game.watch@1, answered with
{kind: "game", name, steam_app_id, started_at} or null.

Detection scans same-uid /proc/<pid>/environ for SteamAppId and names
the game from appmanifest_<id>.acf, found via libraryfolders.vdf; an
id with no manifest is not reported. One scanner runs only while a
watch is open on some connection, and each watch sends at most one
change per MIN_INTERVAL (the latest value), while a watch itself is
answered at once.

The detection code is compiled for Linux only (and for the tests on
any unix, against fake /proc and Steam trees); elsewhere the source
always reports null. A test drives the real scan over a fake /proc
full of other processes and variables and checks every byte on the
link for them.

The socket, handshake and pairing are a copy of rkd-terminal's server,
and serve/pair/status/unpair/install/uninstall mirror its CLI;
install is a systemd user unit, Linux only.

Claude-Session: https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
Claude-Session: https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
/proc/<pid>/stat was read with read_to_string, so a process whose
command name is not UTF-8 was skipped. The kernel cuts comm at 15
bytes, which can split a multi-byte character (a Japanese .exe under
Wine), so such a game was never reported. Read bytes and parse only
what follows the last ')'.

A .vdf/.acf that is a FIFO blocked the scan thread forever (and leaked
one blocking thread per watch cycle); a device never ended. Read only
regular files, following symlinks so a moved library still works.

Claude-Session: https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
dozken changed target branch from feat/1119-plugins-split-rkd-local-link-handshake-fr to main 2026-09-25 12:00:38 +02:00
Merge branch 'main' into feat/1121-plugins-rkd-activity-v1-steam-game-detec
All checks were successful
CI / windows-check (pull_request) Successful in 35s
CI / rust (pull_request) Successful in 55s
CI / install-script (pull_request) Successful in 11s
8a1ee6603b
dozken merged commit ec34f84510 into main 2026-09-25 14:16:11 +02:00
dozken deleted branch feat/1121-plugins-rkd-activity-v1-steam-game-detec 2026-09-25 14:16:11 +02:00
dozken referenced this pull request from a commit 2026-09-25 15:29:47 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rkd/plugins!6
No description provided.