feat(game): broadcast game presence (GOSSIP_PROTO 3) + per-game background
Steps 5-6 of game detection. BREAKING wire change — bump everyone. Wire (step 5): - PeerState.game: Option<String> (display label only — never appid/source). - SelfPresence.game + to_state carry it (single self-state builder). - GOSSIP_PROTO 2->3, GOSSIP_SIG_DOMAIN v3, version comment bumped together; Cargo MINOR 0.3.0 -> 0.4.0 per VERSIONING.md. v2/v3 isolate into different topics + signature domains, so a coordinated redeploy is required (same as the W4 avatar bump). - Gossip ingest sanitizes incoming game via sanitize_game_label (bidi/ control strip, 64-char/256-byte cap); empty -> None. - Bonus security fix (Codex find): reject inbound gossip frames over a 128KB cap BEFORE serde_json::from_slice — a legit Announce with a full 48KB avatar is ~49KB, so this bounds allocation abuse with headroom. Core wiring: - Spawns the detector at startup; consumes its watch channel in the main select. Detection runs continuously (for the local background); the broadcast is gated by game_presence_enabled (opt-in, default OFF). New commands: SetGamePresenceEnabled (immediate publish/clear, D8), SetGameOverride, SetGameProcessMap. New event: GameChanged. - game_presence_label sanitizes the outgoing label too. Background switch (step 6): - GUI handles GameChanged: stores current_game, swaps background to the per-game override (config.game_backgrounds[id]) or falls back to the W16 default; reuses the existing cached-handle path (no redraw flicker). 397 lib tests (all green), clippy --all-targets clean, full binary builds. Remaining: step 7 UI (opt-in toggle, roster 'Playing' text, manual override control, Settings game-backgrounds + process-map editors). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
# cargo-deny's [licenses.private] skip the missing-license check.
|
||||||
|
|||||||
@@ -381,6 +381,11 @@ pub struct AppState {
|
|||||||
/// doesn't read the file from disk on every redraw. Loaded on startup and
|
/// doesn't read the file from disk on every redraw. Loaded on startup and
|
||||||
/// refreshed when the background is changed/removed. `None` = no custom bg.
|
/// refreshed when the background is changed/removed. `None` = no custom bg.
|
||||||
background_image: Option<bytes::Bytes>,
|
background_image: Option<bytes::Bytes>,
|
||||||
|
/// The locally-detected running game (game detection), as reported by core.
|
||||||
|
/// Drives the per-game background (W18) and a local "Playing …" indicator.
|
||||||
|
/// `None` = nothing detected. Independent of whether we broadcast it to peers
|
||||||
|
/// (that's `config.game_presence_enabled`).
|
||||||
|
current_game: Option<crate::game::DetectedGame>,
|
||||||
peers: HashMap<EndpointId, PeerState>,
|
peers: HashMap<EndpointId, PeerState>,
|
||||||
audio_levels: HashMap<EndpointId, f32>,
|
audio_levels: HashMap<EndpointId, f32>,
|
||||||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||||||
@@ -565,6 +570,7 @@ impl Default for AppState {
|
|||||||
selected_output,
|
selected_output,
|
||||||
config,
|
config,
|
||||||
background_image,
|
background_image,
|
||||||
|
current_game: None,
|
||||||
peers: HashMap::new(),
|
peers: HashMap::new(),
|
||||||
audio_levels: HashMap::new(),
|
audio_levels: HashMap::new(),
|
||||||
locally_muted: HashSet::new(),
|
locally_muted: HashSet::new(),
|
||||||
@@ -624,6 +630,23 @@ fn load_background_bytes(config: &AppConfig) -> Option<bytes::Bytes> {
|
|||||||
std::fs::read(path).ok().map(bytes::Bytes::from)
|
std::fs::read(path).ok().map(bytes::Bytes::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The effective background for the current state: the per-game override (W18) when
|
||||||
|
/// the running `game` has a mapping in `config.game_backgrounds`, otherwise the
|
||||||
|
/// single custom background (W16). A mapped-but-missing/unreadable per-game file
|
||||||
|
/// falls back to the default WITHOUT forgetting the mapping (the file may return).
|
||||||
|
fn effective_background_bytes(
|
||||||
|
config: &AppConfig,
|
||||||
|
game: Option<&crate::game::DetectedGame>,
|
||||||
|
) -> Option<bytes::Bytes> {
|
||||||
|
if let Some(g) = game
|
||||||
|
&& let Some(path) = config.game_backgrounds.get(&g.id)
|
||||||
|
&& let Ok(bytes) = std::fs::read(path)
|
||||||
|
{
|
||||||
|
return Some(bytes::Bytes::from(bytes));
|
||||||
|
}
|
||||||
|
load_background_bytes(config)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run_gui() -> iced::Result {
|
pub fn run_gui() -> iced::Result {
|
||||||
// Restore the last window size (saved on close). Position is restored too,
|
// Restore the last window size (saved on close). Position is restored too,
|
||||||
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
|
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
|
||||||
@@ -1144,6 +1167,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
format!("Presence mode stayed {mode}")
|
format!("Presence mode stayed {mode}")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
UiEvent::GameChanged(detected) => {
|
||||||
|
// The locally-detected game changed: switch the per-game
|
||||||
|
// background (W18) if one is mapped, else fall back to the
|
||||||
|
// default. Presence broadcasting is handled in core, gated by
|
||||||
|
// the opt-in toggle; this is purely local presentation.
|
||||||
|
state.current_game = detected;
|
||||||
|
state.background_image =
|
||||||
|
effective_background_bytes(&state.config, state.current_game.as_ref());
|
||||||
|
}
|
||||||
UiEvent::ShutdownComplete => {
|
UiEvent::ShutdownComplete => {
|
||||||
if state.closing {
|
if state.closing {
|
||||||
return iced::exit();
|
return iced::exit();
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
addr: endpoint_a.addr(),
|
addr: endpoint_a.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
|
game: None,
|
||||||
};
|
};
|
||||||
room_a.join(&ticket_str, state_a, vec![]).await?;
|
room_a.join(&ticket_str, state_a, vec![]).await?;
|
||||||
println!("Node A joined topic.");
|
println!("Node A joined topic.");
|
||||||
@@ -83,6 +84,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
addr: endpoint_b.addr(),
|
addr: endpoint_b.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
|
game: None,
|
||||||
};
|
};
|
||||||
room_b.join(&ticket_str, state_b, vec![]).await?;
|
room_b.join(&ticket_str, state_b, vec![]).await?;
|
||||||
println!("Node B joined topic.");
|
println!("Node B joined topic.");
|
||||||
|
|||||||
@@ -89,6 +89,17 @@ pub enum CoreCommand {
|
|||||||
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
||||||
/// startup from config and whenever the user changes it.
|
/// startup from config and whenever the user changes it.
|
||||||
SetPresenceMode(PresenceMode),
|
SetPresenceMode(PresenceMode),
|
||||||
|
/// Toggle broadcasting the detected game as presence (game detection). Opt-in,
|
||||||
|
/// default OFF. Enabling immediately publishes the current game; disabling
|
||||||
|
/// immediately publishes `game: None`. Detection for the local background runs
|
||||||
|
/// regardless. Sent at startup from config and on user toggle.
|
||||||
|
SetGamePresenceEnabled(bool),
|
||||||
|
/// Set the manual game-detection override (`Auto` / `None` / a forced game).
|
||||||
|
/// Forwarded to the detector and applied immediately (bypasses debounce).
|
||||||
|
SetGameOverride(crate::game::ManualOverride),
|
||||||
|
/// Replace the user process→display-name mappings used by the non-Steam
|
||||||
|
/// detection fallback. Sent at startup from config and after Settings edits.
|
||||||
|
SetGameProcessMap(std::collections::BTreeMap<String, String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -150,6 +161,12 @@ pub enum UiEvent {
|
|||||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||||
/// persist this so its presence picker matches the endpoint's discovery state.
|
/// persist this so its presence picker matches the endpoint's discovery state.
|
||||||
PresenceModeReverted { mode: PresenceMode },
|
PresenceModeReverted { mode: PresenceMode },
|
||||||
|
/// The locally-detected running game changed (game detection). Carries the
|
||||||
|
/// debounced `DetectedGame` (id + display name + source) or `None` when nothing
|
||||||
|
/// is detected. The GUI uses the stable `id` to switch the per-game background
|
||||||
|
/// (W18) and may show a local "Playing …" indicator. Emitted regardless of
|
||||||
|
/// whether game presence is being broadcast — the broadcast is core's own job.
|
||||||
|
GameChanged(Option<crate::game::DetectedGame>),
|
||||||
/// Core finished orderly app shutdown and the GUI can exit.
|
/// Core finished orderly app shutdown and the GUI can exit.
|
||||||
ShutdownComplete,
|
ShutdownComplete,
|
||||||
Error(String),
|
Error(String),
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ fn audio_datagram_len_ok(len: usize) -> bool {
|
|||||||
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
|
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The presence label to broadcast for a detected game: its display name,
|
||||||
|
/// sanitized + length-capped, or `None` when there's no game or no broadcastable
|
||||||
|
/// name (a Steam appid without a manifest name, or a label that sanitizes empty).
|
||||||
|
/// Sanitizing here as well as at the gossip ingest boundary keeps the outgoing
|
||||||
|
/// value clean even though every peer re-sanitizes on receipt.
|
||||||
|
fn game_presence_label(game: Option<&crate::game::DetectedGame>) -> Option<String> {
|
||||||
|
game.and_then(|g| g.name.as_deref())
|
||||||
|
.map(crate::sanitize::sanitize_game_label)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
fn arm_discovery_retry(
|
fn arm_discovery_retry(
|
||||||
discovery_deadline: &mut Option<tokio::time::Instant>,
|
discovery_deadline: &mut Option<tokio::time::Instant>,
|
||||||
now: tokio::time::Instant,
|
now: tokio::time::Instant,
|
||||||
@@ -924,7 +935,23 @@ async fn run_core_loop(
|
|||||||
let mut presence = SelfPresence {
|
let mut presence = SelfPresence {
|
||||||
name: "Anonymous".to_string(),
|
name: "Anonymous".to_string(),
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: None,
|
||||||
};
|
};
|
||||||
|
// Game detection (W17/W18): a background worker polls Steam state + the process
|
||||||
|
// list and publishes the debounced running game on a watch channel. Detection
|
||||||
|
// runs continuously (the GUI uses it for the local per-game background); whether
|
||||||
|
// the game is *broadcast* as presence is gated by `game_presence_enabled` (opt-in,
|
||||||
|
// seeded false; the GUI sends `SetGamePresenceEnabled` from config at startup).
|
||||||
|
// The override + process map start at their defaults and are set via commands.
|
||||||
|
let game_detector = crate::game::detector::GameDetector::spawn(
|
||||||
|
crate::game::ManualOverride::Auto,
|
||||||
|
std::collections::BTreeMap::new(),
|
||||||
|
);
|
||||||
|
let mut game_rx = game_detector.subscribe();
|
||||||
|
let mut game_presence_enabled = false;
|
||||||
|
// The latest debounced detection, kept regardless of the broadcast toggle so a
|
||||||
|
// later opt-in can immediately publish whatever is currently running.
|
||||||
|
let mut current_game: Option<crate::game::DetectedGame> = None;
|
||||||
let mut network_mode = NetworkMode::default();
|
let mut network_mode = NetworkMode::default();
|
||||||
// Pixelpass binary override (config), and the ticket of our own active screen
|
// Pixelpass binary override (config), and the ticket of our own active screen
|
||||||
// share (rides our presence so the room — incl. late joiners — can watch).
|
// share (rides our presence so the room — incl. late joiners — can watch).
|
||||||
@@ -1031,6 +1058,30 @@ async fn run_core_loop(
|
|||||||
Some(cmd) => cmd,
|
Some(cmd) => cmd,
|
||||||
None => break,
|
None => break,
|
||||||
},
|
},
|
||||||
|
changed = game_rx.changed() => {
|
||||||
|
// The detector worker published a new debounced game (or `None`).
|
||||||
|
if changed.is_err() {
|
||||||
|
// Worker gone (shouldn't happen before shutdown); stop watching.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let detected = game_rx.borrow_and_update().clone();
|
||||||
|
current_game = detected.clone();
|
||||||
|
// Always tell the GUI for the local per-game background + indicator.
|
||||||
|
let _ = ui_tx.send(UiEvent::GameChanged(detected.clone())).await;
|
||||||
|
// Broadcast as presence only when opted in; re-announce if in a room.
|
||||||
|
if game_presence_enabled {
|
||||||
|
presence.game = game_presence_label(detected.as_ref());
|
||||||
|
if let Some(session) = &active_session {
|
||||||
|
let self_state = presence.to_state(
|
||||||
|
is_muted.load(Ordering::Relaxed),
|
||||||
|
net.endpoint.addr(),
|
||||||
|
current_sharing.clone(),
|
||||||
|
);
|
||||||
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
_ = ping_interval.tick() => {
|
_ = ping_interval.tick() => {
|
||||||
// Fully dark while Invisible (the user's choice): don't even probe,
|
// Fully dark while Invisible (the user's choice): don't even probe,
|
||||||
// so nothing we do touches a friend's machine. Otherwise refresh in a
|
// so nothing we do touches a friend's machine. Otherwise refresh in a
|
||||||
@@ -2182,6 +2233,35 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CoreCommand::SetGamePresenceEnabled(enabled) => {
|
||||||
|
game_presence_enabled = enabled;
|
||||||
|
// Recompute our broadcast label: the current game when enabling,
|
||||||
|
// cleared when disabling. Publish immediately (D8) so peers see the
|
||||||
|
// game appear/disappear at once, not on the next detector tick.
|
||||||
|
presence.game = if enabled {
|
||||||
|
game_presence_label(current_game.as_ref())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(session) = &active_session {
|
||||||
|
let self_state = presence.to_state(
|
||||||
|
is_muted.load(Ordering::Relaxed),
|
||||||
|
net.endpoint.addr(),
|
||||||
|
current_sharing.clone(),
|
||||||
|
);
|
||||||
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CoreCommand::SetGameOverride(override_) => {
|
||||||
|
// Applied on the detector's next poll, immediately (bypasses debounce).
|
||||||
|
game_detector.set_override(override_);
|
||||||
|
}
|
||||||
|
|
||||||
|
CoreCommand::SetGameProcessMap(map) => {
|
||||||
|
game_detector.set_process_map(map);
|
||||||
|
}
|
||||||
|
|
||||||
CoreCommand::SetRecordingMode(mode) => {
|
CoreCommand::SetRecordingMode(mode) => {
|
||||||
recording_mode = mode;
|
recording_mode = mode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ use crate::protocol::GOSSIP_SIG_DOMAIN;
|
|||||||
/// reasonable cross-peer clock skew without leaving a wide replay window.
|
/// reasonable cross-peer clock skew without leaving a wide replay window.
|
||||||
const GOSSIP_FRESHNESS_MS: u64 = 120_000;
|
const GOSSIP_FRESHNESS_MS: u64 = 120_000;
|
||||||
|
|
||||||
|
/// Hard cap on an inbound gossip frame before it is deserialized. The largest
|
||||||
|
/// legitimate payload is an `Announce` carrying a full custom avatar (≤48 KB
|
||||||
|
/// base64, [`crate::avatar::CUSTOM_MAX_B64`]) plus the small presence/signature
|
||||||
|
/// fields — about 49 KB on the wire. This cap sits comfortably above that while
|
||||||
|
/// bounding the work/allocation a hostile peer can force: `serde_json::from_slice`
|
||||||
|
/// allocates while parsing, so post-deserialize string caps do NOT prevent abuse —
|
||||||
|
/// the size must be checked *before* parsing (security hardening, Codex find).
|
||||||
|
const MAX_GOSSIP_FRAME_BYTES: usize = 128 * 1024;
|
||||||
|
|
||||||
/// A gossip message plus the authentication envelope that proves who sent it.
|
/// A gossip message plus the authentication envelope that proves who sent it.
|
||||||
/// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public
|
/// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public
|
||||||
/// key); `sig` is that key's signature over [`signable_bytes`], so a forged
|
/// key); `sig` is that key's signature over [`signable_bytes`], so a forged
|
||||||
@@ -343,6 +352,17 @@ impl RoomState for IrohGossipState {
|
|||||||
match res {
|
match res {
|
||||||
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
||||||
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from));
|
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from));
|
||||||
|
// Reject oversized frames BEFORE deserializing: parsing
|
||||||
|
// allocates, so a size check has to precede `from_slice` to
|
||||||
|
// bound the memory a hostile peer can make us hold.
|
||||||
|
if msg.content.len() > MAX_GOSSIP_FRAME_BYTES {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"Gossip dropped oversized frame: {} bytes > {} cap",
|
||||||
|
msg.content.len(),
|
||||||
|
MAX_GOSSIP_FRAME_BYTES
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match serde_json::from_slice::<GossipPayload>(&msg.content) {
|
match serde_json::from_slice::<GossipPayload>(&msg.content) {
|
||||||
Ok(payload) => {
|
Ok(payload) => {
|
||||||
// Authenticate before trusting `author` for ANY
|
// Authenticate before trusting `author` for ANY
|
||||||
@@ -406,6 +426,15 @@ impl RoomState for IrohGossipState {
|
|||||||
// peer-supplied: cap/validate once at ingest
|
// peer-supplied: cap/validate once at ingest
|
||||||
// so invalid offers never render a Watch button.
|
// so invalid offers never render a Watch button.
|
||||||
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
|
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
|
||||||
|
// The game-presence label is untrusted
|
||||||
|
// peer text like the name: sanitize +
|
||||||
|
// length-cap at ingest (strip bidi/control,
|
||||||
|
// 64-char/256-byte cap). An empty result
|
||||||
|
// means "no game" rather than a blank label.
|
||||||
|
state.game = state.game.and_then(|g| {
|
||||||
|
let cleaned = crate::sanitize::sanitize_game_label(&g);
|
||||||
|
(!cleaned.is_empty()).then_some(cleaned)
|
||||||
|
});
|
||||||
disconnected_peers.lock().unwrap().remove(&payload.author);
|
disconnected_peers.lock().unwrap().remove(&payload.author);
|
||||||
let (is_new, state_changed) = {
|
let (is_new, state_changed) = {
|
||||||
let mut peer_map = peers.lock().unwrap();
|
let mut peer_map = peers.lock().unwrap();
|
||||||
@@ -676,6 +705,7 @@ mod tests {
|
|||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ pub struct PeerState {
|
|||||||
/// peers/configs that predate the field still deserialize (→ monogram).
|
/// peers/configs that predate the field still deserialize (→ monogram).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub avatar: crate::avatar::Avatar,
|
pub avatar: crate::avatar::Avatar,
|
||||||
|
/// The game this peer is currently playing, as a display string only (shown as
|
||||||
|
/// `Playing <name>` next to their avatar). Opt-in and **untrusted** like
|
||||||
|
/// `name`: sanitized + length-capped at the gossip ingest boundary. `None` when
|
||||||
|
/// the peer isn't sharing a game (feature off / nothing detected). Only the
|
||||||
|
/// display string rides the wire — never the appid or detection source, to
|
||||||
|
/// avoid fingerprinting and coupling the protocol to detector internals.
|
||||||
|
/// Defaulted so peers/configs predating the field still deserialize.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
|
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
|
||||||
@@ -56,6 +65,11 @@ pub struct PeerState {
|
|||||||
pub struct SelfPresence {
|
pub struct SelfPresence {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub avatar: crate::avatar::Avatar,
|
pub avatar: crate::avatar::Avatar,
|
||||||
|
/// The display label of the game we're currently broadcasting, or `None` when
|
||||||
|
/// game presence is off / nothing is detected. Already sanitized + capped
|
||||||
|
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
|
||||||
|
/// the outgoing announce carries a safe value.
|
||||||
|
pub game: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SelfPresence {
|
impl SelfPresence {
|
||||||
@@ -74,6 +88,7 @@ impl SelfPresence {
|
|||||||
addr,
|
addr,
|
||||||
sharing,
|
sharing,
|
||||||
avatar: self.avatar.clone(),
|
avatar: self.avatar.clone(),
|
||||||
|
game: self.game.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,6 +302,7 @@ mod tests {
|
|||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +417,7 @@ mod tests {
|
|||||||
let presence = SelfPresence {
|
let presence = SelfPresence {
|
||||||
name: "Alice".to_string(),
|
name: "Alice".to_string(),
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: Some("Half-Life 2".to_string()),
|
||||||
};
|
};
|
||||||
// Volatile fields come from the call; sticky fields from the struct.
|
// Volatile fields come from the call; sticky fields from the struct.
|
||||||
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
|
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
|
||||||
@@ -409,6 +426,7 @@ mod tests {
|
|||||||
assert_eq!(muted.addr.id, addr.id);
|
assert_eq!(muted.addr.id, addr.id);
|
||||||
assert_eq!(muted.sharing.as_deref(), Some("ticket"));
|
assert_eq!(muted.sharing.as_deref(), Some("ticket"));
|
||||||
assert_eq!(muted.avatar, crate::avatar::Avatar::default());
|
assert_eq!(muted.avatar, crate::avatar::Avatar::default());
|
||||||
|
assert_eq!(muted.game.as_deref(), Some("Half-Life 2"));
|
||||||
// The same sticky presence yields different volatile fields per announce.
|
// The same sticky presence yields different volatile fields per announce.
|
||||||
let unmuted = presence.to_state(false, addr.clone(), None);
|
let unmuted = presence.to_state(false, addr.clone(), None);
|
||||||
assert!(!unmuted.is_muted);
|
assert!(!unmuted.is_muted);
|
||||||
|
|||||||
+8
-2
@@ -26,7 +26,13 @@ pub const FRIENDS_PROTO: u32 = 1;
|
|||||||
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
|
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
|
||||||
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
|
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
|
||||||
/// to fail fast rather than half-work.
|
/// to fail fast rather than half-work.
|
||||||
pub const GOSSIP_PROTO: u32 = 2;
|
///
|
||||||
|
/// v3 (0.4.0): `PeerState` gained an optional `game` presence field (the
|
||||||
|
/// `Playing <name>` status). The field is `#[serde(default)]`, so the bump isn't
|
||||||
|
/// strictly required for decoding — but per the versioning discipline a wire-shape
|
||||||
|
/// change is isolated into its own topic + signature domain so v2 and v3 peers
|
||||||
|
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
|
||||||
|
pub const GOSSIP_PROTO: u32 = 3;
|
||||||
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
||||||
/// any change. Mirrored in [`FILES_ALPN`].
|
/// any change. Mirrored in [`FILES_ALPN`].
|
||||||
pub const FILES_PROTO: u32 = 1;
|
pub const FILES_PROTO: u32 = 1;
|
||||||
@@ -41,7 +47,7 @@ pub const FILES_ALPN: &[u8] = b"peerspeak/files/1";
|
|||||||
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
||||||
/// the gossip protocol version into every signed payload — a version mismatch
|
/// the gossip protocol version into every signed payload — a version mismatch
|
||||||
/// fails verification (cryptographic separation between gossip versions).
|
/// fails verification (cryptographic separation between gossip versions).
|
||||||
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v2";
|
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v3";
|
||||||
|
|
||||||
/// Version-namespace a room topic so peers on different gossip protocol versions
|
/// Version-namespace a room topic so peers on different gossip protocol versions
|
||||||
/// derive **different subscription topics from the same ticket** and therefore
|
/// derive **different subscription topics from the same ticket** and therefore
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
|
|||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
|
game: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user