feat(local-link): generic handshake, activity messages (#1119) #5

Merged
dozken merged 3 commits from feat/1119-plugins-split-rkd-local-link-handshake-fr into main 2026-09-25 11:58:14 +02:00
Owner

Summary

This is part of nanoya-biz/rkd#1119 (split from #578; the design is ADR-0005, decision 3). rkd-local-link's handshake is now generic over each plugin's own messages, and it adds the messages for activity.game.watch@1, which the future rkd-activity plugin will speak. Terminal messages are unchanged on the wire, and no release is tagged here.

  • The handshake is generic. msg::ClientMsg<M> and msg::PluginMsg<M> carry hello/auth/pair/pair_begin/unpair and hello/ready/paired/pair_code/error. A plugin's own messages sit in a Plugin(M) variant, and From<M> wraps them.
    • The terminal's variants moved unchanged to msg::terminal::{ClientMsg, PluginMsg, Dir, DirKind}. None of the activity variants were added to the terminal's enum.
  • Protocol versions are per plugin, as ADR-0005 says ("one protocol version per link target"). The terminal keeps msg::terminal::{PROTOCOL = 2, MIN_PROTOCOL = 1}. Activity starts at msg::activity::{PROTOCOL = 1, MIN_PROTOCOL = 1}. The crate-root PROTOCOL/MIN_PROTOCOL are gone.
  • Terminal messages are byte-identical. A Plugin(m) serializes exactly as m does: one flat {"type":…} object. On read, the type decides whether a message is handshake or plugin, and that half gives the error. With serde's untagged-variant fallback, every malformed message would have been reported as "data did not match any variant". See the wire transcript below.
  • Activity (msg::activity), id activity, capability GAME_WATCH = "activity.game.watch@1":
    • The client sends watch { capability } and unwatch { capability }.
    • The plugin sends activity { activity: {kind:"game", name, steam_app_id, started_at} | null }. null means nothing is running and is never omitted. steam_app_id is required (a record without one is refused); name is capped at MAX_NAME_CHARS = 128. started_at is Unix seconds.
    • The plugin sends only on change, at most once per MIN_INTERVAL (5 s). It sends the current value right after watch, and it does not scan while no watch is open.
  • test-vectors.json:
    • Every message vector gets a plugin field (handshake/terminal/activity). The existing terminal vectors are unchanged byte for byte.
    • New: 11 handshake vectors (including a plugin hello whose proof is the proof vector's) and 5 activity vectors (non-ASCII name, missing steam_app_id refused, null).
    • tests/vectors.rs checks that each vector round-trips under the enums it belongs to, and handshake vectors under both plugins'.
  • local/README.md "Changing the contract" now explains per-plugin protocols and has a table per plugin.

Breaking for Rust users of the crate, which means the RKD Tauri shell when it moves its tag = "v0.4.0" pin: ClientMsg/PluginMsg take a type parameter, Dir/DirKind move to msg::terminal, and PROTOCOL/MIN_PROTOCOL move to msg::terminal. The wire does not change, so an app on v0.4.0 keeps working with a plugin built from this.

Type of change

  • New feature
  • Refactor / cleanup (no user-visible change)
  • Docs

Screenshots / recording

This is a contract change with no UI, so the evidence is a wire transcript. The same scripted Python client drove two rkd-terminal serve binaries over a real socket: BEFORE is built from tag v0.4.0 (a detached scratch worktree), and AFTER is built from this branch. The client pairs, then sends open with argv, an open with a missing cwd, open/resize/close, dirs, a second hello, an activity watch sent to the terminal, a dirs before ready, and hello with protocol 3. It prints every control frame in both directions as raw JSON. Only random values are masked (nonces, code, client id, secret, version, home path), and stream output is skipped because its timing varies.

diff BEFORE AFTER shows one line. Every frame is identical except the free-text message of the unexpected error for an unreadable message: it used to list the handshake types as well as the terminal's, and now lists the terminal's alone. type and code are unchanged. message is for people and not part of the contract.

27c27
< app <- {"type":"error","code":"unexpected","message":"unreadable message: unknown variant `watch`, expected one of `hello`, `auth`, `pair`, `pair_begin`, `unpair`, `open`, `resize`, `close`, `dirs` at line 1 column 15"}
---
> app <- {"type":"error","code":"unexpected","message":"unreadable message: unknown variant `watch`, expected one of `open`, `resize`, `close`, `dirs`"}
BEFORE: rkd-terminal v0.4.0
cli -> {"type":"pair_begin"}
cli <- {"type":"pair_code","code":"<CODE>","expires_in":<N>}
app -> {"type":"hello","protocol":2,"nonce":"abababababababababababababababababababababababababababababababab"}
app <- {"type":"hello","protocol":2,"id":"terminal","version":"<V>","provides":["surface.terminal@1","terminal.session.open@1"],"known":false,"nonce":"<NONCE_P>"}
app -> {"type":"pair","code":"<CODE>","label":"wire.py"}
app <- {"type":"paired","client_id":"<CLIENT_ID>","secret":"<SECRET>"}
app <- {"type":"ready"}
app -> {"type":"open","req":1,"cols":80,"rows":24,"argv":["/bin/echo","hi"]}
app <- {"type":"opened","req":1,"stream":1}
app <- {"type":"exit","stream":1,"code":0}
app -> {"type":"open","req":2,"cols":80,"rows":24,"cwd":"/nonexistent-1119"}
app <- {"type":"open_failed","req":2,"message":"/nonexistent-1119 is not a folder"}
app -> {"type":"open","req":3,"cols":80,"rows":24,"argv":["/bin/sleep","30"]}
app <- {"type":"opened","req":3,"stream":2}
app -> {"type":"resize","stream":2,"cols":100,"rows":30}
app -> {"type":"close","stream":2}
app <- {"type":"exit","stream":2,"code":1}
app -> {"type":"open","req":4,"cols":80,"rows":24,"cwd":"/"}
app <- {"type":"opened","req":4,"stream":3}
app -> {"type":"close","stream":3}
app <- {"type":"exit","stream":3,"code":1}
app -> {"type":"dirs","req":5}
app <- {"type":"dir_list","req":5,"dirs":[{"path":"<HOME>","kind":"home"},{"path":"/","kind":"recent"}]}
app -> {"type":"hello","protocol":2,"nonce":"abababababababababababababababababababababababababababababababab"}
app <- {"type":"error","code":"unexpected","message":"the handshake is already under way"}
app -> {"type":"watch","capability":"activity.game.watch@1"}
app <- {"type":"error","code":"unexpected","message":"unreadable message: unknown variant `watch`, expected one of `hello`, `auth`, `pair`, `pair_begin`, `unpair`, `open`, `resize`, `close`, `dirs` at line 1 column 15"}
early -> {"type":"dirs","req":1}
early <- {"type":"error","code":"not_ready","message":"not authenticated"}
new -> {"type":"hello","protocol":3,"nonce":"abababababababababababababababababababababababababababababababab"}
new <- {"type":"error","code":"protocol","message":"this rkd-terminal speaks protocol 1 to 2, not 3"}
AFTER: this branch
cli -> {"type":"pair_begin"}
cli <- {"type":"pair_code","code":"<CODE>","expires_in":<N>}
app -> {"type":"hello","protocol":2,"nonce":"abababababababababababababababababababababababababababababababab"}
app <- {"type":"hello","protocol":2,"id":"terminal","version":"<V>","provides":["surface.terminal@1","terminal.session.open@1"],"known":false,"nonce":"<NONCE_P>"}
app -> {"type":"pair","code":"<CODE>","label":"wire.py"}
app <- {"type":"paired","client_id":"<CLIENT_ID>","secret":"<SECRET>"}
app <- {"type":"ready"}
app -> {"type":"open","req":1,"cols":80,"rows":24,"argv":["/bin/echo","hi"]}
app <- {"type":"opened","req":1,"stream":1}
app <- {"type":"exit","stream":1,"code":0}
app -> {"type":"open","req":2,"cols":80,"rows":24,"cwd":"/nonexistent-1119"}
app <- {"type":"open_failed","req":2,"message":"/nonexistent-1119 is not a folder"}
app -> {"type":"open","req":3,"cols":80,"rows":24,"argv":["/bin/sleep","30"]}
app <- {"type":"opened","req":3,"stream":2}
app -> {"type":"resize","stream":2,"cols":100,"rows":30}
app -> {"type":"close","stream":2}
app <- {"type":"exit","stream":2,"code":1}
app -> {"type":"open","req":4,"cols":80,"rows":24,"cwd":"/"}
app <- {"type":"opened","req":4,"stream":3}
app -> {"type":"close","stream":3}
app <- {"type":"exit","stream":3,"code":1}
app -> {"type":"dirs","req":5}
app <- {"type":"dir_list","req":5,"dirs":[{"path":"<HOME>","kind":"home"},{"path":"/","kind":"recent"}]}
app -> {"type":"hello","protocol":2,"nonce":"abababababababababababababababababababababababababababababababab"}
app <- {"type":"error","code":"unexpected","message":"the handshake is already under way"}
app -> {"type":"watch","capability":"activity.game.watch@1"}
app <- {"type":"error","code":"unexpected","message":"unreadable message: unknown variant `watch`, expected one of `open`, `resize`, `close`, `dirs`"}
early -> {"type":"dirs","req":1}
early <- {"type":"error","code":"not_ready","message":"not authenticated"}
new -> {"type":"hello","protocol":3,"nonce":"abababababababababababababababababababababababababababababababab"}
new <- {"type":"error","code":"protocol","message":"this rkd-terminal speaks protocol 1 to 2, not 3"}

Test plan

Automated:

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets --locked -- -D warnings (macOS host)
  • cargo clippy --target x86_64-pc-windows-msvc --workspace --all-targets --locked -- -D warnings (the CI windows-check job)
  • cargo test --workspace --locked: local-link 35 unit + 3 vector tests, terminal 86 unit + 1 + 31 integration tests
  • Added tests: handshake routing for every handshake variant, parse errors that name the field or variant, another plugin's message refused, activity wire names, null vs omitted, unknown kind refused
  • Mutations applied by hand and confirmed red: dropping skip_serializing_if on steam_app_id, and dropping unpair from the handshake routing list

Manual:

  • Wire transcript v0.4.0 vs this branch (above)

Review checklist

  • No dbg! or commented-out code left behind
  • No secrets, tokens, or personal URLs added
  • No new dependencies
  • README updated

Notes for the reviewer

  • The RKD side is deferred until this is tagged. That covers app/src-electron/localLink.ts, fakePlugin.test.helper.ts, the vendored test-vectors.json copy, and the Tauri rkd-local-link pin bump with its ClientMsg<terminal::ClientMsg> port. The Tauri shell pins rkd-local-link by tag, and the Electron copy of the vectors is "a copy of vX.Y.Z", so it needs a tag to point at. It also builds on nanoya-biz/rkd#1125 (#1118, LINK_TARGETS), which is still open.
  • No tag, no version bump. Cutting the release (0.5.0?) is the next step once this merges.
  • Contract details I chose, since ADR-0005 does not spell them out:
    • started_at is Unix seconds.
    • steam_app_id is required (review 819d52b): the server's Activity::Game carries source: steam, so a non-Steam launcher needs a new source field and a protocol bump anyway.
    • watch answers with the current value at once, and a repeated watch resends it.
    • A change inside the 5 s window is sent when the window ends, as the latest value, so a game started and quit within the window never shows up.
    • A watch of a capability the plugin does not provide gets error { unexpected }, and the connection stays up.
    • Any of these is cheap to change before the tag.
  • rkd-activity itself (the detector) is not part of this. This PR defines only its messages.

https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt

## Summary This is part of nanoya-biz/rkd#1119 (split from #578; the design is ADR-0005, decision 3). `rkd-local-link`'s handshake is now generic over each plugin's own messages, and it adds the messages for `activity.game.watch@1`, which the future `rkd-activity` plugin will speak. Terminal messages are unchanged on the wire, and no release is tagged here. - **The handshake is generic.** `msg::ClientMsg<M>` and `msg::PluginMsg<M>` carry hello/auth/pair/pair_begin/unpair and hello/ready/paired/pair_code/error. A plugin's own messages sit in a `Plugin(M)` variant, and `From<M>` wraps them. - The terminal's variants moved unchanged to `msg::terminal::{ClientMsg, PluginMsg, Dir, DirKind}`. None of the activity variants were added to the terminal's enum. - **Protocol versions are per plugin, as ADR-0005 says ("one protocol version per link target").** The terminal keeps `msg::terminal::{PROTOCOL = 2, MIN_PROTOCOL = 1}`. Activity starts at `msg::activity::{PROTOCOL = 1, MIN_PROTOCOL = 1}`. The crate-root `PROTOCOL`/`MIN_PROTOCOL` are gone. - **Terminal messages are byte-identical.** A `Plugin(m)` serializes exactly as `m` does: one flat `{"type":…}` object. On read, the `type` decides whether a message is handshake or plugin, and that half gives the error. With serde's untagged-variant fallback, every malformed message would have been reported as "data did not match any variant". See the wire transcript below. - **Activity (`msg::activity`)**, id `activity`, capability `GAME_WATCH = "activity.game.watch@1"`: - The client sends `watch { capability }` and `unwatch { capability }`. - The plugin sends `activity { activity: {kind:"game", name, steam_app_id, started_at} | null }`. `null` means nothing is running and is never omitted. `steam_app_id` is required (a record without one is refused); `name` is capped at `MAX_NAME_CHARS` = 128. `started_at` is Unix seconds. - The plugin sends only on change, at most once per `MIN_INTERVAL` (5 s). It sends the current value right after `watch`, and it does not scan while no watch is open. - **test-vectors.json**: - Every message vector gets a `plugin` field (`handshake`/`terminal`/`activity`). The existing terminal vectors are unchanged byte for byte. - New: 11 handshake vectors (including a plugin hello whose `proof` is the proof vector's) and 5 activity vectors (non-ASCII name, missing `steam_app_id` refused, `null`). - `tests/vectors.rs` checks that each vector round-trips under the enums it belongs to, and handshake vectors under both plugins'. - `local/README.md` "Changing the contract" now explains per-plugin protocols and has a table per plugin. **Breaking for Rust users of the crate**, which means the RKD Tauri shell when it moves its `tag = "v0.4.0"` pin: `ClientMsg`/`PluginMsg` take a type parameter, `Dir`/`DirKind` move to `msg::terminal`, and `PROTOCOL`/`MIN_PROTOCOL` move to `msg::terminal`. The wire does not change, so an app on v0.4.0 keeps working with a plugin built from this. ## Type of change - [x] New feature - [x] Refactor / cleanup (no user-visible change) - [x] Docs ## Screenshots / recording This is a contract change with no UI, so the evidence is a wire transcript. The same scripted Python client drove two `rkd-terminal serve` binaries over a real socket: **BEFORE** is built from tag `v0.4.0` (a detached scratch worktree), and **AFTER** is built from this branch. The client pairs, then sends `open` with `argv`, an `open` with a missing `cwd`, `open`/`resize`/`close`, `dirs`, a second `hello`, an activity `watch` sent to the terminal, a `dirs` before `ready`, and `hello` with protocol 3. It prints every control frame in both directions as raw JSON. Only random values are masked (nonces, code, client id, secret, version, home path), and stream output is skipped because its timing varies. `diff BEFORE AFTER` shows one line. Every frame is identical except the free-text `message` of the `unexpected` error for an unreadable message: it used to list the handshake types as well as the terminal's, and now lists the terminal's alone. `type` and `code` are unchanged. `message` is for people and not part of the contract. ```diff 27c27 < app <- {"type":"error","code":"unexpected","message":"unreadable message: unknown variant `watch`, expected one of `hello`, `auth`, `pair`, `pair_begin`, `unpair`, `open`, `resize`, `close`, `dirs` at line 1 column 15"} --- > app <- {"type":"error","code":"unexpected","message":"unreadable message: unknown variant `watch`, expected one of `open`, `resize`, `close`, `dirs`"} ``` <details><summary>BEFORE: rkd-terminal v0.4.0</summary> ``` cli -> {"type":"pair_begin"} cli <- {"type":"pair_code","code":"<CODE>","expires_in":<N>} app -> {"type":"hello","protocol":2,"nonce":"abababababababababababababababababababababababababababababababab"} app <- {"type":"hello","protocol":2,"id":"terminal","version":"<V>","provides":["surface.terminal@1","terminal.session.open@1"],"known":false,"nonce":"<NONCE_P>"} app -> {"type":"pair","code":"<CODE>","label":"wire.py"} app <- {"type":"paired","client_id":"<CLIENT_ID>","secret":"<SECRET>"} app <- {"type":"ready"} app -> {"type":"open","req":1,"cols":80,"rows":24,"argv":["/bin/echo","hi"]} app <- {"type":"opened","req":1,"stream":1} app <- {"type":"exit","stream":1,"code":0} app -> {"type":"open","req":2,"cols":80,"rows":24,"cwd":"/nonexistent-1119"} app <- {"type":"open_failed","req":2,"message":"/nonexistent-1119 is not a folder"} app -> {"type":"open","req":3,"cols":80,"rows":24,"argv":["/bin/sleep","30"]} app <- {"type":"opened","req":3,"stream":2} app -> {"type":"resize","stream":2,"cols":100,"rows":30} app -> {"type":"close","stream":2} app <- {"type":"exit","stream":2,"code":1} app -> {"type":"open","req":4,"cols":80,"rows":24,"cwd":"/"} app <- {"type":"opened","req":4,"stream":3} app -> {"type":"close","stream":3} app <- {"type":"exit","stream":3,"code":1} app -> {"type":"dirs","req":5} app <- {"type":"dir_list","req":5,"dirs":[{"path":"<HOME>","kind":"home"},{"path":"/","kind":"recent"}]} app -> {"type":"hello","protocol":2,"nonce":"abababababababababababababababababababababababababababababababab"} app <- {"type":"error","code":"unexpected","message":"the handshake is already under way"} app -> {"type":"watch","capability":"activity.game.watch@1"} app <- {"type":"error","code":"unexpected","message":"unreadable message: unknown variant `watch`, expected one of `hello`, `auth`, `pair`, `pair_begin`, `unpair`, `open`, `resize`, `close`, `dirs` at line 1 column 15"} early -> {"type":"dirs","req":1} early <- {"type":"error","code":"not_ready","message":"not authenticated"} new -> {"type":"hello","protocol":3,"nonce":"abababababababababababababababababababababababababababababababab"} new <- {"type":"error","code":"protocol","message":"this rkd-terminal speaks protocol 1 to 2, not 3"} ``` </details> <details><summary>AFTER: this branch</summary> ``` cli -> {"type":"pair_begin"} cli <- {"type":"pair_code","code":"<CODE>","expires_in":<N>} app -> {"type":"hello","protocol":2,"nonce":"abababababababababababababababababababababababababababababababab"} app <- {"type":"hello","protocol":2,"id":"terminal","version":"<V>","provides":["surface.terminal@1","terminal.session.open@1"],"known":false,"nonce":"<NONCE_P>"} app -> {"type":"pair","code":"<CODE>","label":"wire.py"} app <- {"type":"paired","client_id":"<CLIENT_ID>","secret":"<SECRET>"} app <- {"type":"ready"} app -> {"type":"open","req":1,"cols":80,"rows":24,"argv":["/bin/echo","hi"]} app <- {"type":"opened","req":1,"stream":1} app <- {"type":"exit","stream":1,"code":0} app -> {"type":"open","req":2,"cols":80,"rows":24,"cwd":"/nonexistent-1119"} app <- {"type":"open_failed","req":2,"message":"/nonexistent-1119 is not a folder"} app -> {"type":"open","req":3,"cols":80,"rows":24,"argv":["/bin/sleep","30"]} app <- {"type":"opened","req":3,"stream":2} app -> {"type":"resize","stream":2,"cols":100,"rows":30} app -> {"type":"close","stream":2} app <- {"type":"exit","stream":2,"code":1} app -> {"type":"open","req":4,"cols":80,"rows":24,"cwd":"/"} app <- {"type":"opened","req":4,"stream":3} app -> {"type":"close","stream":3} app <- {"type":"exit","stream":3,"code":1} app -> {"type":"dirs","req":5} app <- {"type":"dir_list","req":5,"dirs":[{"path":"<HOME>","kind":"home"},{"path":"/","kind":"recent"}]} app -> {"type":"hello","protocol":2,"nonce":"abababababababababababababababababababababababababababababababab"} app <- {"type":"error","code":"unexpected","message":"the handshake is already under way"} app -> {"type":"watch","capability":"activity.game.watch@1"} app <- {"type":"error","code":"unexpected","message":"unreadable message: unknown variant `watch`, expected one of `open`, `resize`, `close`, `dirs`"} early -> {"type":"dirs","req":1} early <- {"type":"error","code":"not_ready","message":"not authenticated"} new -> {"type":"hello","protocol":3,"nonce":"abababababababababababababababababababababababababababababababab"} new <- {"type":"error","code":"protocol","message":"this rkd-terminal speaks protocol 1 to 2, not 3"} ``` </details> ## Test plan Automated: - [x] `cargo fmt --all --check` - [x] `cargo clippy --workspace --all-targets --locked -- -D warnings` (macOS host) - [x] `cargo clippy --target x86_64-pc-windows-msvc --workspace --all-targets --locked -- -D warnings` (the CI `windows-check` job) - [x] `cargo test --workspace --locked`: local-link 35 unit + 3 vector tests, terminal 86 unit + 1 + 31 integration tests - [x] Added tests: handshake routing for every handshake variant, parse errors that name the field or variant, another plugin's message refused, activity wire names, `null` vs omitted, unknown `kind` refused - [x] Mutations applied by hand and confirmed red: dropping `skip_serializing_if` on `steam_app_id`, and dropping `unpair` from the handshake routing list Manual: - [x] Wire transcript v0.4.0 vs this branch (above) ## Review checklist - [x] No `dbg!` or commented-out code left behind - [x] No secrets, tokens, or personal URLs added - [x] No new dependencies - [x] README updated ## Notes for the reviewer - **The RKD side is deferred until this is tagged.** That covers `app/src-electron/localLink.ts`, `fakePlugin.test.helper.ts`, the vendored `test-vectors.json` copy, and the Tauri `rkd-local-link` pin bump with its `ClientMsg<terminal::ClientMsg>` port. The Tauri shell pins `rkd-local-link` by tag, and the Electron copy of the vectors is "a copy of vX.Y.Z", so it needs a tag to point at. It also builds on nanoya-biz/rkd#1125 (#1118, `LINK_TARGETS`), which is still open. - **No tag, no version bump.** Cutting the release (0.5.0?) is the next step once this merges. - **Contract details I chose, since ADR-0005 does not spell them out:** - `started_at` is Unix seconds. - `steam_app_id` is required (review 819d52b): the server's `Activity::Game` carries `source: steam`, so a non-Steam launcher needs a new source field and a protocol bump anyway. - `watch` answers with the current value at once, and a repeated `watch` resends it. - A change inside the 5 s window is sent when the window ends, as the latest value, so a game started and quit within the window never shows up. - A `watch` of a capability the plugin does not provide gets `error { unexpected }`, and the connection stays up. - Any of these is cheap to change before the tag. - `rkd-activity` itself (the detector) is not part of this. This PR defines only its messages. https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
dozken self-assigned this 2026-09-25 02:13:28 +02:00
The handshake enums (hello/auth/pair/paired/ready/error, pair_begin,
unpair) are now generic over a plugin's own messages, which move to
msg::terminal and the new msg::activity (ADR-0005 decision 3), instead
of the activity variants joining the terminal's enum. Protocol
versions move with them: terminal keeps 1..=2, activity starts at 1,
so one plugin's bump never makes the app call the other outdated.

A plugin message is written as its own enum writes it, so terminal
messages are byte-identical on the wire. Reading dispatches on `type`
(handshake or the plugin's) rather than serde's untagged fallback,
which would have reduced every parse error to "did not match any
variant".

activity.game.watch@1: watch/unwatch { capability } from the client,
activity { activity: {kind: game, name, steam_app_id?, started_at} |
null } from the plugin. test-vectors.json gains a `plugin` field on
every message vector, handshake vectors and activity vectors.

Breaking for Rust users of the crate: msg::{ClientMsg, PluginMsg} take
a type parameter, Dir/DirKind move to msg::terminal, and the crate-root
PROTOCOL/MIN_PROTOCOL become msg::terminal::{PROTOCOL, MIN_PROTOCOL}.

Claude-Session: https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
docs(local): per-plugin protocols, activity table (#1119)
All checks were successful
CI / rust (pull_request) Successful in 40s
CI / install-script (pull_request) Successful in 10s
CI / windows-check (pull_request) Successful in 23s
790da5182d
Claude-Session: https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
fix(local-link): steam_app_id required, name cap (#1119)
All checks were successful
CI / rust (pull_request) Successful in 48s
CI / windows-check (pull_request) Successful in 28s
CI / install-script (pull_request) Successful in 15s
819d52be50
ADR-0005 and #1119 give the record as {kind, name, steam_app_id,
started_at}, and the server's Activity requires a source. An optional
steam_app_id let a record arrive that a client could not attribute, so
a non-Steam launcher needs a protocol bump anyway. Make it required.

Add activity::MAX_NAME_CHARS = 128, matching the server's
ACTIVITY_NAME_MAX_CHARS, so the plugin cuts names before the server
does. Clarify that a watch answer is not held by MIN_INTERVAL, and
make handshake docs plugin-neutral.

Claude-Session: https://claude.ai/code/session_01S6WwLMTpMtw1RuCgBY8JGt
dozken merged commit 86ab89b870 into main 2026-09-25 11:58:14 +02:00
dozken referenced this pull request from a commit 2026-09-25 13:10:31 +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!5
No description provided.