vapour-protocol 0.4.0

Steam client protocol implementation for native Rust applications
Documentation

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, 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 runtime — the API is async.
  • A real Steam account to authenticate against.

Install

cargo add vapour-protocol

vapour-protocol pulls in tokio, so add it too if you don't already depend on it:

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.

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 AuthEvents; wait for AuthEvent::Success(LoggedOn).
  3. run(commands, events) — take over the connection. You drive the session by sending RunCommands on the commands channel and reacting to FriendsEvents 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)

Command Effect Result event
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)

Event Meaning
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 AuthMethods and reports progress as AuthEvents (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):

    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:

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:

vapour-protocol = { path = "../vapour-protocol" }

Release steps are documented in how_to_release.md.

License

This repository is licensed under MIT.