# vapour-protocol
Rust implementation of the Steam client protocol for building native Steam applications.
`vapour-protocol` speaks directly to Steam CM servers over WebSocket and exposes higher-level
Rust APIs for client-style Steam features — authentication, friends, chat, and your game library —
without depending on the Steam client or Web API key. It was built for, and is used by,
[Vapour](https://github.com/LargeModGames/vapour), but it is a standalone crate you can use on its own.
## Features
- Steam authentication: QR, username/password (with Steam Guard), and refresh-token reuse.
- CM server discovery and WebSocket transport.
- Friends/persona state and real-time friend presence events.
- 1-on-1 friend chat: send, receive, typing indicators, and history.
- Owned/recently-played library loading through protocol messages.
- Per-user achievements and PICS app metadata (type, install dir, launch options).
- Service method calls for authenticated unified Steam services.
## Status
This crate is usable and evolving toward a stable public API. It is `0.x`: expect breaking changes
between minor releases while protocol coverage grows. Talking to Steam requires a real Steam account,
and you are responsible for using it in line with the Steam Subscriber Agreement.
## Requirements
- Rust 1.85+ (the crate uses edition 2024).
- A [Tokio](https://tokio.rs) runtime — the API is async.
- A real Steam account to authenticate against.
## Install
```bash
cargo add vapour-protocol
```
`vapour-protocol` pulls in `tokio`, so add it too if you don't already depend on it:
```bash
cargo add tokio --features full
```
## Quickstart
Log in with a QR code, then load your owned games and echo any incoming chat message. Every type
used here is re-exported from the crate root.
```rust,no_run
use tokio::sync::mpsc;
use vapour_protocol::{AuthEvent, AuthMethod, Error, FriendsEvent, RunCommand, SteamClient};
#[tokio::main]
async fn main() -> vapour_protocol::Result<()> {
let mut client = SteamClient::new();
// 1. Authenticate. QR is shown here; see "Authentication" for credentials / refresh tokens.
let mut auth = client.begin_auth(AuthMethod::Qr).await?;
let session = loop {
match auth.recv().await {
Some(AuthEvent::QrChallenge(url)) => {
// Render `url` as a QR code and scan it in the Steam Mobile app.
println!("Scan to sign in: {url}");
}
Some(AuthEvent::GuardRequired(kind)) => println!("Steam Guard required: {kind:?}"),
Some(AuthEvent::Success(session)) => break session,
Some(AuthEvent::Failure(error)) => return Err(error),
None => return Err(Error::Closed),
}
};
println!("Logged in as {} ({})", session.account_name, session.steamid);
// Persist `session.refresh_token` to log in again later without re-scanning
// (see AuthMethod::RefreshToken below).
// 2. Drive the session over two channels: you send `RunCommand`s in and
// receive `FriendsEvent`s back.
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
// Handle events — and react with new commands — on a background task.
let commands = cmd_tx.clone();
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
FriendsEvent::OwnedGames(games) => {
for game in games {
println!("{} — {} min played", game.name, game.playtime_forever);
}
}
FriendsEvent::IncomingMessage(msg) => {
println!("{}: {}", msg.steamid, msg.message);
let _ = commands.send(RunCommand::SendMessage {
steamid: msg.steamid,
message: "got it".to_owned(),
});
}
_ => {}
}
}
});
// Ask for the owned library; results arrive as the events handled above.
let _ = cmd_tx.send(RunCommand::GetLibrary);
// `run` owns the connection loop and returns when Steam disconnects.
client.run(cmd_rx, event_tx).await
}
```
## Session model
A `SteamClient` has three stages:
1. **`SteamClient::new()`** — create the client.
2. **`begin_auth(method)`** — connect and start an auth flow. Returns a receiver of `AuthEvent`s;
wait for `AuthEvent::Success(LoggedOn)`.
3. **`run(commands, events)`** — take over the connection. You drive the session by sending
`RunCommand`s on the `commands` channel and reacting to `FriendsEvent`s on the `events` channel.
`run` sets your status online, requests friend data as it arrives, and resolves until the
connection closes.
### Commands you send (`RunCommand`)
| `SetPersonaState(PersonaState)` | Set your online status | — |
| `RequestFriendData(Vec<u64>)` | Request persona data for SteamIDs | `PersonaStates` |
| `GetLibrary` | Load owned + recently-played games | `RecentlyPlayedGames`, then `OwnedGames` |
| `GetPlayerAchievements(u32)` | Achievements for an appid | `PlayerAchievements` |
| `SendMessage { steamid, message }` | Send a 1-on-1 chat message | `MessageSent` |
| `SendTyping { steamid }` | Send a typing indicator (best-effort) | — |
| `GetRecentMessages { steamid }` | Fetch recent chat history | `RecentMessages` |
### Events you receive (`FriendsEvent`)
| `FriendsList(Vec<Friend>)` | Your friends list (pushed after login) |
| `PersonaStates(Vec<Persona>)` | Presence/profile updates |
| `RecentlyPlayedGames(Vec<ProtocolGame>)` | Recently-played subset of the library |
| `OwnedGames(Vec<ProtocolGame>)` | Full owned-game catalog |
| `PlayerAchievements { appid, achievements }` | Achievements for one app |
| `IncomingMessage(ChatMessage)` | A 1-on-1 message arrived |
| `MessageSent(ChatMessage)` | Your send confirmed (with server timestamp) |
| `TypingNotification { steamid }` | A friend is typing |
| `RecentMessages { steamid, messages }` | History for one conversation |
## Authentication
`begin_auth` takes one of three `AuthMethod`s and reports progress as `AuthEvent`s
(`QrChallenge`, `GuardRequired`, `Success`, `Failure`).
- **`AuthMethod::Qr`** — emits `QrChallenge(url)`; render it as a QR code and scan it in the Steam
Mobile app.
- **`AuthMethod::Credentials { account, password }`** — may emit `GuardRequired(kind)`. For
`EmailCode` / `DeviceCode`, collect the code and call `submit_guard_code`; for
`DeviceConfirmation`, approve the prompt in the Steam Mobile app (no code needed):
```rust,ignore
let mut auth = client
.begin_auth(AuthMethod::Credentials {
account: "username".to_owned(),
password: "password".to_owned(),
})
.await?;
while let Some(event) = auth.recv().await {
match event {
AuthEvent::GuardRequired(GuardKind::EmailCode | GuardKind::DeviceCode) => {
client.submit_guard_code("ABCDE")?; // a code you collected from the user
}
AuthEvent::Success(session) => { /* logged in; break and call run() */ }
AuthEvent::Failure(error) => return Err(error),
_ => {}
}
}
```
- **`AuthMethod::RefreshToken(token)`** — reuse the `refresh_token` from a previous
`LoggedOn` to sign in non-interactively. Pair it with `set_account_name_hint` if you have the
account name. Store refresh tokens securely; they grant access to the account.
## Development
Run the same checks as CI before opening a PR:
```bash
cargo fmt --all -- --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
cargo package --locked
```
If you are developing `vapour-protocol` alongside Vapour, point Vapour at your local checkout with a
path dependency so changes to both land together:
```toml
vapour-protocol = { path = "../vapour-protocol" }
```
Release steps are documented in
[how_to_release.md](https://github.com/LargeModGames/vapour-protocol/blob/main/how_to_release.md).
## License
This repository is licensed under `MIT`.