feat(activity): Steam game detection on macOS (#1144) #8

Merged
dozken merged 5 commits from feat/1144-activity-steam-game-detection-on-macos into main 2026-09-25 19:12:50 +02:00
Owner

Summary

rkd-activity now detects Steam games on macOS, installs itself as a per-user launchd agent there, and is released and installed for Apple silicon. This is the plugin side of the Developer ID build's activity status. Linux behaviour is unchanged.

Refs nanoya-biz/rkd#1144

  • Detection (macproc). It reads the same signal as on Linux: SteamAppId in the game process's environment. On macOS that comes from the KERN_PROCARGS2 sysctl, which is what ps -E reads and needs no root for your own processes. Owner and start time come from proc_pidinfo(PROC_PIDTBSDINFO): pbi_uid, and pbi_start_tvsec, which is the kernel's p_starttime. libc has no kinfo_proc on Apple, so I used this instead of KERN_PROC_PID. The buffer is parsed as argc, then the exec path and its NUL padding, then argv, then env up to the empty string before the kernel's apple[] strings. Reads are capped at 4 MiB, and a variable cut off at the cap is dropped. A process is looked at again after its arguments are read, so one that exits, or whose pid is reused, mid-scan is skipped. Other users' processes are never read.
  • Fallback (detect::RegistryFallback). When no process carries SteamAppId, it uses Steam's own registry.vdf RunningAppID. That value is trusted only while the SteamPID beside it is still a live process of this user. The registry has no start time, so started_at is when the plugin first saw that game; it is kept across scans and reset when the game changes or stops.
  • Library. The Steam folder is ~/Library/Application Support/Steam, read with the existing libraryfolders.vdf / appmanifest_<id>.acf code.
  • Install. rkd-activity install on macOS writes ~/Library/LaunchAgents/biz.nanoya.rkd-activity.plist and loads it with the same launchd sequence as rkd-terminal: print, bootout on reinstall, enable, bootstrap gui/<uid> retried after a bootout. Logs go to ~/Library/Logs/rkd-activity/. The exe checks are the same too: a binary another user could replace is refused even with --force, and a temp or build folder is refused without it. Unlike rkd-terminal, the plist sets no PATH, since this plugin runs no programs.
  • Release and install.sh. The macOS job in release.yml builds rkd-activity and attaches rkd-activity-<tag>-aarch64-apple-darwin. install.sh no longer refuses it on Apple silicon; asked for v0.6.0 or older on a Mac, it says those releases have no macOS build. No tag or release was made.

Type of change

  • New feature
  • CI / release
  • Docs

Screenshots / recording

No UI. Evidence is rkd-activity serve plus a small Python client doing pair_begin → hello → pair → watch, then killing the game once it is watching. Both runs are on this Mac (arm64, macOS 26.5). BEFORE is a release build of origin/main (c585b66) from a git archive scratch copy; AFTER is this branch's release build.

Steam is installed here, but no game is: the library has no appmanifest_*.acf and registry.vdf has no RunningAppID. So the game is a stand-in: a tiny native binary that sleeps, Hades.app/Contents/MacOS/Hades, started as env SteamAppId=1145360 SECRET_TOKEN=hunter2 …, with Hades' manifest in a scratch HOME. This proves the macOS read path end to end on the real kernel. It does not prove that a real macOS Steam game carries SteamAppId; see "Not verified" below.

BEFORE — origin/main: nothing is detected on macOS.

$ rkd-activity serve
rkd-activity 0.6.0 listening on <tmp>/p/activity.sock (no game detection on this platform: it reports nothing)
$ rkd-activity status
Detects: nothing on this platform (Linux only)
$ python3 client.py
hello: {'id': 'activity', 'version': '0.6.0', 'provides': ['activity.game.watch@1'], 'known': False}
paired: paired -> ready
watch answer: {"type": "activity", "activity": null}
next: nothing within 20 s

AFTER — this PR: the stand-in started at 20:55:10 (1790355310).

$ rkd-activity serve
rkd-activity 0.6.0 listening on <tmp>/p/activity.sock
$ rkd-activity status
Detects: Steam games, while the RKD app watches
$ python3 client.py
hello: {'id': 'activity', 'version': '0.6.0', 'provides': ['activity.game.watch@1'], 'known': False}
paired: paired -> ready
watch answer: {"type": "activity", "activity": {"kind": "game", "name": "Hades", "steam_app_id": 1145360, "started_at": 1790355310}}
next: {"type": "activity", "activity": null}

A real launchd install on this Mac with the AFTER binary, uninstalled afterwards. BEFORE, install refused on macOS ("detects games on Linux only").

$ /private/tmp/…/rkd-activity install
rkd-activity: /private/tmp is writable by every user, so another user could replace the program the service runs as you. … --force does not override this.
$ ~/.local/share/rkd-1144-launchd-check/rkd-activity install
Wrote /Users/dozken/Library/LaunchAgents/biz.nanoya.rkd-activity.plist
Started the launchd agent biz.nanoya.rkd-activity; logs in /Users/dozken/Library/Logs/rkd-activity
$ rkd-activity status
Plugin:  running
Detects: Steam games, while the RKD app watches
Service: installed, running (/Users/dozken/Library/LaunchAgents/biz.nanoya.rkd-activity.plist)
$ launchctl print gui/501/biz.nanoya.rkd-activity
	state = running
$ rkd-activity uninstall
Stopped the launchd agent biz.nanoya.rkd-activity
Removed /Users/dozken/Library/LaunchAgents/biz.nanoya.rkd-activity.plist

Test plan

Automated:

  • cargo fmt --all --check

  • cargo clippy --workspace --all-targets --locked -- -D warnings on macOS, and with --target x86_64-unknown-linux-gnu and --target x86_64-pc-windows-msvc

  • cargo test --workspace --locked on macOS (66 in rkd-activity). The Linux-only code runs in CI

  • scripts/test-install.sh under sh and dash: new act-mac, act-mac-old and act-intel-mac cases replace the old "refuses macOS" one. Also scripts/test-release.sh (includes the workflow lint) and shellcheck install.sh scripts/*.sh

  • New tests:

    • macproc: argv/env/apple[] parsing, no padding, a cut-off buffer, other uid never read, exit/pid reuse mid-scan, and the real System reading this test process
    • detect: env scan, and the registry fallback (first seen kept, reset on change and stop, env wins, dead or foreign SteamPID, no manifest)
    • steam: registry.vdf reading, including this Mac's real no-game shape, and the macOS root
    • service: launchd install/reinstall/retry/no-start/uninstall/status, and the plist
    • no_process_argument_or_environment_value_reaches_the_link_on_macos: the leak test over the macOS scan and the fallback
  • Mutations applied by hand and confirmed red. 12 in the Rust code, all killed:

    • dropping the apple[]-boundary stop, keeping a cut variable, the pid-reuse re-check, the uid filter, the padding skip
    • the SteamPID liveness check, first-seen keeping, the fallback call, SteamPID > 0
    • bootout on reinstall, retrying only after a bootout, the state = running probe

    Two in install.sh, both killed: the Darwin target for rkd-activity, and the no-macOS-build message.

Manual:

  • Before/after serve + client run above (real sysctl/libproc on macOS, stand-in game)
  • Real install → running → uninstall via launchd on this Mac

Not verified:

  • That a real macOS Steam game (native, or started through Steam's launcher) carries SteamAppId in its environment. No game is installed here, so the env signal is unconfirmed on a real game. Whether the macOS client writes RunningAppID to registry.vdf is also unconfirmed; this Mac's registry, with no game, has SteamPID under HKLM and no RunningAppID. Both need a Mac with a game before a release is tagged.
  • A game with a hardened runtime or other protection may refuse KERN_PROCARGS2 even to its own user. It is then skipped, and only the registry fallback can report it.
  • The release workflow's macOS job was not run; no tag was pushed.

Review checklist

  • No dbg! or commented-out code left behind; the evidence harness lives outside the repo
  • No secrets, tokens, or personal URLs added
  • No new dependencies: libc was already there for unix
  • READMEs updated (root and local/)

Notes for the reviewer

  • cfg layout. macproc is cfg(any(target_os = "macos", all(test, unix))), so a Linux release binary contains no macOS code and the other way round. appid holds Running, app_id_in and earliest, which both scanners share; procfs re-exports them, so the Linux path reads the same as before.
  • Linux is unchanged: same unit text, same systemctl commands, same status line. service.rs gained a Platform and Layout.uid from rkd-terminal. I left out rkd-terminal's "another plugin answers on the socket" warning, so Linux install output stays exactly the same.
  • App side (nanoya-biz/rkd), deferred. The Settings activity card's "Linux only" text and ADR-0005's platform matrix are not changed here: nanoya-biz/rkd#1146 is open and touches the same card and ADR-0003/0005, so they follow once it merges. The Tauri one-click part is nanoya-biz/rkd#1147.

https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt

## Summary `rkd-activity` now detects Steam games on macOS, installs itself as a per-user launchd agent there, and is released and installed for Apple silicon. This is the plugin side of the Developer ID build's activity status. Linux behaviour is unchanged. Refs nanoya-biz/rkd#1144 - **Detection (`macproc`).** It reads the same signal as on Linux: `SteamAppId` in the game process's environment. On macOS that comes from the `KERN_PROCARGS2` sysctl, which is what `ps -E` reads and needs no root for your own processes. Owner and start time come from `proc_pidinfo(PROC_PIDTBSDINFO)`: `pbi_uid`, and `pbi_start_tvsec`, which is the kernel's `p_starttime`. libc has no `kinfo_proc` on Apple, so I used this instead of `KERN_PROC_PID`. The buffer is parsed as argc, then the exec path and its NUL padding, then argv, then env up to the empty string before the kernel's `apple[]` strings. Reads are capped at 4 MiB, and a variable cut off at the cap is dropped. A process is looked at again after its arguments are read, so one that exits, or whose pid is reused, mid-scan is skipped. Other users' processes are never read. - **Fallback (`detect::RegistryFallback`).** When no process carries `SteamAppId`, it uses Steam's own `registry.vdf` `RunningAppID`. That value is trusted only while the `SteamPID` beside it is still a live process of this user. The registry has no start time, so `started_at` is when the plugin first saw that game; it is kept across scans and reset when the game changes or stops. - **Library.** The Steam folder is `~/Library/Application Support/Steam`, read with the existing `libraryfolders.vdf` / `appmanifest_<id>.acf` code. - **Install.** `rkd-activity install` on macOS writes `~/Library/LaunchAgents/biz.nanoya.rkd-activity.plist` and loads it with the same launchd sequence as `rkd-terminal`: `print`, `bootout` on reinstall, `enable`, `bootstrap gui/<uid>` retried after a bootout. Logs go to `~/Library/Logs/rkd-activity/`. The exe checks are the same too: a binary another user could replace is refused even with `--force`, and a temp or build folder is refused without it. Unlike rkd-terminal, the plist sets no `PATH`, since this plugin runs no programs. - **Release and install.sh.** The macOS job in `release.yml` builds `rkd-activity` and attaches `rkd-activity-<tag>-aarch64-apple-darwin`. `install.sh` no longer refuses it on Apple silicon; asked for v0.6.0 or older on a Mac, it says those releases have no macOS build. No tag or release was made. ## Type of change - [x] New feature - [x] CI / release - [x] Docs ## Screenshots / recording No UI. Evidence is `rkd-activity serve` plus a small Python client doing `pair_begin` → `hello` → `pair` → `watch`, then killing the game once it is watching. Both runs are on this Mac (arm64, macOS 26.5). BEFORE is a release build of `origin/main` (c585b66) from a `git archive` scratch copy; AFTER is this branch's release build. **Steam is installed here, but no game is:** the library has no `appmanifest_*.acf` and `registry.vdf` has no `RunningAppID`. So the game is a stand-in: a tiny native binary that sleeps, `Hades.app/Contents/MacOS/Hades`, started as `env SteamAppId=1145360 SECRET_TOKEN=hunter2 …`, with Hades' manifest in a scratch `HOME`. This proves the macOS read path end to end on the real kernel. It does not prove that a real macOS Steam game carries `SteamAppId`; see "Not verified" below. BEFORE — origin/main: nothing is detected on macOS. ``` $ rkd-activity serve rkd-activity 0.6.0 listening on <tmp>/p/activity.sock (no game detection on this platform: it reports nothing) $ rkd-activity status Detects: nothing on this platform (Linux only) $ python3 client.py hello: {'id': 'activity', 'version': '0.6.0', 'provides': ['activity.game.watch@1'], 'known': False} paired: paired -> ready watch answer: {"type": "activity", "activity": null} next: nothing within 20 s ``` AFTER — this PR: the stand-in started at 20:55:10 (1790355310). ``` $ rkd-activity serve rkd-activity 0.6.0 listening on <tmp>/p/activity.sock $ rkd-activity status Detects: Steam games, while the RKD app watches $ python3 client.py hello: {'id': 'activity', 'version': '0.6.0', 'provides': ['activity.game.watch@1'], 'known': False} paired: paired -> ready watch answer: {"type": "activity", "activity": {"kind": "game", "name": "Hades", "steam_app_id": 1145360, "started_at": 1790355310}} next: {"type": "activity", "activity": null} ``` A real launchd install on this Mac with the AFTER binary, uninstalled afterwards. BEFORE, `install` refused on macOS ("detects games on Linux only"). ``` $ /private/tmp/…/rkd-activity install rkd-activity: /private/tmp is writable by every user, so another user could replace the program the service runs as you. … --force does not override this. $ ~/.local/share/rkd-1144-launchd-check/rkd-activity install Wrote /Users/dozken/Library/LaunchAgents/biz.nanoya.rkd-activity.plist Started the launchd agent biz.nanoya.rkd-activity; logs in /Users/dozken/Library/Logs/rkd-activity $ rkd-activity status Plugin: running Detects: Steam games, while the RKD app watches Service: installed, running (/Users/dozken/Library/LaunchAgents/biz.nanoya.rkd-activity.plist) $ launchctl print gui/501/biz.nanoya.rkd-activity state = running $ rkd-activity uninstall Stopped the launchd agent biz.nanoya.rkd-activity Removed /Users/dozken/Library/LaunchAgents/biz.nanoya.rkd-activity.plist ``` ## Test plan Automated: - [x] `cargo fmt --all --check` - [x] `cargo clippy --workspace --all-targets --locked -- -D warnings` on macOS, and with `--target x86_64-unknown-linux-gnu` and `--target x86_64-pc-windows-msvc` - [x] `cargo test --workspace --locked` on macOS (66 in `rkd-activity`). The Linux-only code runs in CI - [x] `scripts/test-install.sh` under `sh` and `dash`: new `act-mac`, `act-mac-old` and `act-intel-mac` cases replace the old "refuses macOS" one. Also `scripts/test-release.sh` (includes the workflow lint) and `shellcheck install.sh scripts/*.sh` - [x] New tests: - `macproc`: argv/env/apple[] parsing, no padding, a cut-off buffer, other uid never read, exit/pid reuse mid-scan, and the real `System` reading this test process - `detect`: env scan, and the registry fallback (first seen kept, reset on change and stop, env wins, dead or foreign `SteamPID`, no manifest) - `steam`: `registry.vdf` reading, including this Mac's real no-game shape, and the macOS root - `service`: launchd install/reinstall/retry/no-start/uninstall/status, and the plist - `no_process_argument_or_environment_value_reaches_the_link_on_macos`: the leak test over the macOS scan and the fallback - [x] Mutations applied by hand and confirmed red. 12 in the Rust code, all killed: - dropping the apple[]-boundary stop, keeping a cut variable, the pid-reuse re-check, the uid filter, the padding skip - the `SteamPID` liveness check, first-seen keeping, the fallback call, `SteamPID > 0` - bootout on reinstall, retrying only after a bootout, the `state = running` probe Two in `install.sh`, both killed: the Darwin target for rkd-activity, and the no-macOS-build message. Manual: - [x] Before/after serve + client run above (real sysctl/libproc on macOS, stand-in game) - [x] Real `install` → running → `uninstall` via launchd on this Mac Not verified: - [ ] That a real macOS Steam game (native, or started through Steam's launcher) carries `SteamAppId` in its environment. No game is installed here, so the env signal is unconfirmed on a real game. Whether the macOS client writes `RunningAppID` to `registry.vdf` is also unconfirmed; this Mac's registry, with no game, has `SteamPID` under HKLM and no `RunningAppID`. Both need a Mac with a game before a release is tagged. - [ ] A game with a hardened runtime or other protection may refuse `KERN_PROCARGS2` even to its own user. It is then skipped, and only the registry fallback can report it. - [ ] The release workflow's macOS job was not run; no tag was pushed. ## Review checklist - [x] No `dbg!` or commented-out code left behind; the evidence harness lives outside the repo - [x] No secrets, tokens, or personal URLs added - [x] No new dependencies: `libc` was already there for unix - [x] READMEs updated (root and `local/`) ## Notes for the reviewer - **cfg layout.** `macproc` is `cfg(any(target_os = "macos", all(test, unix)))`, so a Linux release binary contains no macOS code and the other way round. `appid` holds `Running`, `app_id_in` and `earliest`, which both scanners share; `procfs` re-exports them, so the Linux path reads the same as before. - **Linux is unchanged:** same unit text, same `systemctl` commands, same status line. `service.rs` gained a `Platform` and `Layout.uid` from rkd-terminal. I left out rkd-terminal's "another plugin answers on the socket" warning, so Linux install output stays exactly the same. - **App side (nanoya-biz/rkd), deferred.** The Settings activity card's "Linux only" text and ADR-0005's platform matrix are not changed here: nanoya-biz/rkd#1146 is open and touches the same card and ADR-0003/0005, so they follow once it merges. The Tauri one-click part is nanoya-biz/rkd#1147. https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
A game Steam starts carries SteamAppId in its environment on macOS as
on Linux; there is no /proc, so the environment comes from the
KERN_PROCARGS2 sysctl (what `ps -E` reads, no root for your own
processes) and the owner and start time from libproc's
proc_pidinfo(PROC_PIDTBSDINFO). libc has no kinfo_proc on Apple, and
pbi_start_tvsec is the kernel's p_starttime. The buffer is parsed past
argc, the executable path, its padding and argv; the environment ends
at the empty string before the kernel's apple[] strings, and a variable
cut off by the 4 MiB cap is dropped rather than read as another id. A
process is re-checked after its arguments are read, so one that exits
or whose pid is reused mid-scan is skipped.

No game is installed on the Mac this was built on, so SteamAppId could
not be confirmed on a real macOS game. Steam's own registry.vdf
RunningAppID is the fallback, believed only while the SteamPID beside it
still runs as this user; its started_at is when the plugin first saw
the game. Everything the system is asked goes through a Processes
trait, faked in the tests; the leak test now also runs over the macOS
scan and the registry fallback.

Refs nanoya-biz/rkd#1144

Claude-Session: https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
`rkd-activity install` on macOS writes and loads
~/Library/LaunchAgents/biz.nanoya.rkd-activity.plist the way
rkd-terminal does: launchctl print, bootout on a reinstall, enable,
bootstrap gui/<uid> (retried only after a bootout), logs in
~/Library/Logs/rkd-activity, and the same refusal of a binary another
user could replace or one in a temp or build folder. Unlike
rkd-terminal's, the agent passes no PATH: this plugin runs no programs.
The Linux unit and its systemctl commands are unchanged.

Refs nanoya-biz/rkd#1144

Claude-Session: https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
The release's macOS job builds rkd-activity next to rkd-terminal and
attaches rkd-activity-<tag>-aarch64-apple-darwin, and install.sh stops
refusing it on Apple silicon. v0.6.0 has no such build, so a download
that 404s on macOS says the releases up to v0.6.0 have none.

Refs nanoya-biz/rkd#1144

Claude-Session: https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
docs(activity): macOS detection, launchd install, builds (#1144)
All checks were successful
CI / install-script (pull_request) Successful in 14s
CI / windows-check (pull_request) Successful in 24s
CI / rust (pull_request) Successful in 36s
4ccfab4439
Refs nanoya-biz/rkd#1144

Claude-Session: https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
fix(activity): refuse short procargs reads, stale SteamPID (#1144)
All checks were successful
CI / install-script (pull_request) Successful in 15s
CI / windows-check (pull_request) Successful in 24s
CI / rust (pull_request) Successful in 36s
81d89a9253
KERN_PROCARGS2 with a buffer smaller than the arguments drops bytes
from the front (after argc), not the end, so a short read starts
mid-string and argv could be parsed as the environment. Ask for one
byte more than the probed size and refuse any read that fills the
buffer.

registry.vdf's SteamPID is only trusted while the process under it
started no later than the file was written, so a pid reused after the
client died does not keep a stale RunningAppID alive.

Adds a panic/bounds fuzz test for env_block (every prefix of real
layouts plus 20k seeded random buffers).

Claude-Session: https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
dozken merged commit b3ee34410b into main 2026-09-25 19:12:50 +02:00
dozken deleted branch feat/1144-activity-steam-game-detection-on-macos 2026-09-25 19:12:50 +02:00
dozken referenced this pull request from a commit 2026-09-26 05:28:04 +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!8
No description provided.