CoreController::send put every app->core command on a single bounded depth-100 channel via try_send and discarded the result. iced slider drags emit ~60-120 commands/sec, so a drag burst could transiently saturate the queue exactly when the user hit mute / released PTT / left a room, silently dropping that critical command and leaving the mic hot -- a privacy/state mismatch. Split the queue by drop-tolerance: - A pure delivery_class(&CoreCommand) classifier in messages.rs maps the 7 continuous audio sliders to BestEffort and every other (discrete, human-paced) command to Reliable. The match has no wildcard arm, so a new CoreCommand variant fails to compile until it is classified. - CoreController now holds two senders: an unbounded reliable channel and the existing bounded(100) best-effort channel. send() routes by class; Reliable uses unbounded send (fails only if the core loop is dead), BestEffort keeps today's bounded try_send. - run_core_loop takes both receivers and drains them with a biased select: reliable first, best-effort second, game-change third. Unbounded is safe because the only machine-rate producer (slider drags) stays on the bounded channel; Reliable commands are all human-paced. command_sender() and the awaiting Shutdown path are unchanged. Implemented by Codex (gpt-5.5), senior-reviewed and verified here: 454 lib tests pass, clippy --all-targets clean, release builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
333 lines
16 KiB
Rust
333 lines
16 KiB
Rust
use crate::config::{NetworkMode, RecordingMode};
|
|
use crate::friends::Friend;
|
|
use crate::network::PeerState;
|
|
use crate::presence::{FriendPresence, PresenceMode};
|
|
use iroh::{EndpointAddr, EndpointId};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum CoreCommand {
|
|
/// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a
|
|
/// share ticket to join. `room_name` is the creator's chosen cosmetic label
|
|
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
|
|
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
|
|
Leave,
|
|
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
|
|
/// audio/screen-share work, close the persistent network stack, then ack with
|
|
/// [`UiEvent::ShutdownComplete`].
|
|
Shutdown,
|
|
ToggleMute,
|
|
/// Change our avatar (W4) and re-announce it to the room over presence.
|
|
SetAvatar(crate::avatar::Avatar),
|
|
ToggleDeafen,
|
|
SetPttMode(bool),
|
|
SetPttActive(bool),
|
|
SetPeerVolume(EndpointId, f32),
|
|
/// Listener-side per-peer EQ. Local only; never leaves this app instance.
|
|
SetPeerEq(EndpointId, crate::audio::eq::EqSettings),
|
|
/// Listener-side per-peer pan. Local only; never leaves this app instance.
|
|
SetPeerPan(EndpointId, f32),
|
|
/// Listener-side per-peer noise gate threshold (normalized RMS, `0.0` = off).
|
|
/// Applies the same smooth gate as the mic path to a peer's incoming audio,
|
|
/// to suppress their background noise on our end. Local only.
|
|
SetPeerGate(EndpointId, f32),
|
|
/// Locally mute/unmute a peer: when muted, their audio is decoded (so levels
|
|
/// still show) but not mixed into our output.
|
|
SetPeerMuted(EndpointId, bool),
|
|
SetNoiseGateThreshold(f32),
|
|
/// App-internal capture gain (mic), applied before the gate/encode. 1.0 = unity.
|
|
SetInputVolume(f32),
|
|
/// App-internal playback gain on the mixed output. 1.0 = unity.
|
|
SetOutputVolume(f32),
|
|
/// Start/stop a standalone capture-only stream that reports the raw mic
|
|
/// level via [`UiEvent::MicLevel`], for gate calibration outside a call.
|
|
/// Ignored while a room session is active (the in-call meter covers that).
|
|
SetMicMonitor { enabled: bool, input_device: Option<String> },
|
|
/// Set the relay/discovery posture. Takes effect on the next room join,
|
|
/// since the endpoint is (re)built then.
|
|
SetNetworkMode(NetworkMode),
|
|
/// Start/stop recording the call to a local WAV (your mic + the incoming
|
|
/// mix). No-op start if already recording / not in a call.
|
|
SetRecording(bool),
|
|
/// Set what a recording captures (mixed / per-peer stems / both). Takes
|
|
/// effect on the next recording start. Sent at startup from config.
|
|
SetRecordingMode(RecordingMode),
|
|
/// Broadcast a room text-chat message. No-op when not in a call.
|
|
SendChat(String),
|
|
/// Send a chat message carrying a file attachment. The app has already read +
|
|
/// capped the file and built the descriptor; core makes the bytes available
|
|
/// on the file plane and broadcasts the descriptor.
|
|
SendChatFile { text: String, attachment: crate::files::ChatAttachment, data: Vec<u8> },
|
|
/// Fetch a received attachment's bytes from its sender over the file plane
|
|
/// (used for on-demand file/chip downloads; images are auto-fetched on
|
|
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
|
|
FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment },
|
|
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
|
/// Sent at startup so screen-share can resolve the binary.
|
|
SetPixelpassPath(Option<String>),
|
|
/// Enumerate apps currently producing audio (for the screen-share audio
|
|
/// picker, A23). Replies with [`UiEvent::AudioAppsListed`]. Cheap shell-out;
|
|
/// safe to call each time the picker opens.
|
|
ListAudioApps,
|
|
/// Start sharing our screen: spawn a pixelpass host and announce its ticket
|
|
/// on our presence so the room can watch. No-op when not in a call.
|
|
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
|
|
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
|
|
/// whole desktop audio (the legacy behavior).
|
|
StartScreenShare { audio_app: Option<String> },
|
|
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
|
/// ticket. No-op when not sharing.
|
|
StopScreenShare,
|
|
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
|
|
/// open it in a local player.
|
|
ViewShare(String),
|
|
/// Mint a fresh persistent identity (W7), discarding the old one. Takes effect
|
|
/// on the next room join (the endpoint is rebuilt then). The core replies with
|
|
/// an updated [`UiEvent::IdentityStatus`].
|
|
RegenerateIdentity,
|
|
/// Add a friend (W7). Core owns the friends store: it mutates + persists it and
|
|
/// replies with [`UiEvent::FriendsUpdated`]. `addr` seeds `last_addr` if known
|
|
/// (e.g. added from a room). Idempotent — re-adding an existing id is a no-op.
|
|
AddFriend { id: EndpointId, name: String, addr: Option<EndpointAddr> },
|
|
/// Remove a friend by id (W7).
|
|
RemoveFriend(EndpointId),
|
|
/// Locally rename a friend (W7).
|
|
RenameFriend(EndpointId, String),
|
|
/// Set our presence posture (W7). Gates the idle listener (answer friends-only /
|
|
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
|
/// startup from config and whenever the user changes it.
|
|
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, Copy, PartialEq, Eq)]
|
|
pub enum DeliveryClass {
|
|
Reliable,
|
|
BestEffort,
|
|
}
|
|
|
|
/// Route a command by how bad it is to drop it. Discrete, human-paced user
|
|
/// actions are Reliable (must land). The only high-frequency commands are the
|
|
/// continuous audio sliders, where dropping intermediate values is harmless;
|
|
/// those are BestEffort.
|
|
pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
|
match cmd {
|
|
CoreCommand::SetPeerVolume(_, _)
|
|
| CoreCommand::SetPeerPan(_, _)
|
|
| CoreCommand::SetPeerGate(_, _)
|
|
| CoreCommand::SetPeerEq(_, _)
|
|
| CoreCommand::SetInputVolume(_)
|
|
| CoreCommand::SetOutputVolume(_)
|
|
| CoreCommand::SetNoiseGateThreshold(_) => DeliveryClass::BestEffort,
|
|
|
|
CoreCommand::Join {
|
|
name: _,
|
|
ticket: _,
|
|
room_name: _,
|
|
input_device: _,
|
|
output_device: _,
|
|
echo_cancellation: _,
|
|
avatar: _,
|
|
}
|
|
| CoreCommand::Leave
|
|
| CoreCommand::Shutdown
|
|
| CoreCommand::ToggleMute
|
|
| CoreCommand::SetAvatar(_)
|
|
| CoreCommand::ToggleDeafen
|
|
| CoreCommand::SetPttMode(_)
|
|
| CoreCommand::SetPttActive(_)
|
|
| CoreCommand::SetPeerMuted(_, _)
|
|
| CoreCommand::SetMicMonitor {
|
|
enabled: _,
|
|
input_device: _,
|
|
}
|
|
| CoreCommand::SetNetworkMode(_)
|
|
| CoreCommand::SetRecording(_)
|
|
| CoreCommand::SetRecordingMode(_)
|
|
| CoreCommand::SendChat(_)
|
|
| CoreCommand::SendChatFile {
|
|
text: _,
|
|
attachment: _,
|
|
data: _,
|
|
}
|
|
| CoreCommand::FetchAttachment {
|
|
from: _,
|
|
attachment: _,
|
|
}
|
|
| CoreCommand::SetPixelpassPath(_)
|
|
| CoreCommand::ListAudioApps
|
|
| CoreCommand::StartScreenShare { audio_app: _ }
|
|
| CoreCommand::StopScreenShare
|
|
| CoreCommand::ViewShare(_)
|
|
| CoreCommand::RegenerateIdentity
|
|
| CoreCommand::AddFriend {
|
|
id: _,
|
|
name: _,
|
|
addr: _,
|
|
}
|
|
| CoreCommand::RemoveFriend(_)
|
|
| CoreCommand::RenameFriend(_, _)
|
|
| CoreCommand::SetPresenceMode(_)
|
|
| CoreCommand::SetGamePresenceEnabled(_)
|
|
| CoreCommand::SetGameOverride(_)
|
|
| CoreCommand::SetGameProcessMap(_) => DeliveryClass::Reliable,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum UiEvent {
|
|
RoomJoined { ticket: String, self_id: String },
|
|
RoomLeft,
|
|
/// Clear room-scoped UI state after a failed in-call room switch, without a
|
|
/// leave chime. The persistent identity remains unchanged.
|
|
RoomReset,
|
|
PeerJoined { id: EndpointId, state: PeerState },
|
|
PeerLeft { id: EndpointId },
|
|
/// The fixed reconnect grace expired and bounded background gossip recovery
|
|
/// has started. This is non-terminal and must not play the failure chime.
|
|
PeerRecoveryStarted { id: EndpointId },
|
|
PeerConnectionFailed { id: EndpointId },
|
|
PeerUpdated { id: EndpointId, state: PeerState },
|
|
/// Audio link to a peer is being (re)established — show a connecting state.
|
|
PeerConnecting { id: EndpointId },
|
|
/// Audio link to a peer is up and carrying audio.
|
|
PeerConnected { id: EndpointId },
|
|
AudioLevels(Vec<(EndpointId, f32)>),
|
|
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
|
|
/// for the settings level meter. Throttled to ~10/sec.
|
|
MicLevel(f32),
|
|
/// Call recording started; carries the absolute WAV path being written.
|
|
RecordingStarted { path: String },
|
|
/// Call recording stopped; carries the finished WAV path.
|
|
RecordingStopped { path: String },
|
|
/// A room text-chat message arrived from a peer (never our own — local
|
|
/// messages are echoed by the UI on send). `from` is the sender's node id
|
|
/// string, used to key their avatar (W4).
|
|
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> },
|
|
/// An attachment's bytes are now available (auto-fetched for images, or
|
|
/// fetched on demand for files). Keyed by `(from, id)`: the id is
|
|
/// attacker-chosen, so a malicious peer can reuse a victim's id — the author
|
|
/// disambiguates whose bytes these are and stops content aliasing (Tier C
|
|
/// F-12).
|
|
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
|
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
|
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
|
/// The apps currently producing audio, for the screen-share audio picker
|
|
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
|
|
/// playing or enumeration isn't available. `app_audio_supported` reports
|
|
/// whether the resolved pixelpass understands `--strict-audio`: when `false`
|
|
/// (an older pixelpass) the picker must offer whole-desktop audio only, since
|
|
/// a per-app share would pass a flag that older binary rejects (audit P2).
|
|
AudioAppsListed { apps: Vec<String>, app_audio_supported: bool },
|
|
/// Our own screen share started; the UI flips the Share button to "Stop".
|
|
ScreenShareStarted,
|
|
/// Our own screen share stopped (or failed to start).
|
|
ScreenShareStopped,
|
|
/// Per-app screen-share audio routing state (A23). `true` = the app we chose
|
|
/// is now reaching viewers; `false` = its audio stopped, so under our strict
|
|
/// run viewers currently hear silence. The UI shows a transient warning while
|
|
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
|
|
ShareAudioActive(bool),
|
|
/// A validly signed peer cannot be admitted because its gossip timestamp is
|
|
/// outside the replay freshness window. `peer_ahead` describes the peer's
|
|
/// sender-stamped timestamp relative to this machine's clock.
|
|
ClockSkewWarning { skew_secs: u64, peer_ahead: bool },
|
|
/// Our node identity (W7): the current node id string, and whether it is
|
|
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
|
|
/// `persisted = false` means the key file couldn't be read/written and we're
|
|
/// running on an ephemeral fallback — a degraded state the UI must surface,
|
|
/// since the id (and thus friend recognition) won't survive the next launch.
|
|
/// `error` carries the reason when degraded, for the UI explainer.
|
|
IdentityStatus { node_id: String, persisted: bool, error: Option<String> },
|
|
/// The friends list (W7), now owned by core. Sent at startup (after load) and
|
|
/// after every add/remove/rename so the GUI renders from this snapshot instead
|
|
/// of owning the store. `read_only` is true when `friends.json` failed to load
|
|
/// (malformed) — the GUI shows a degraded warning and disables edits so we never
|
|
/// overwrite the damaged file (backlog A16).
|
|
FriendsUpdated { friends: Vec<Friend>, read_only: bool },
|
|
/// A friend's live presence from a successful ping reply (W7): online, or in a
|
|
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
|
/// scheduler; absence of a recent event = treat as offline.
|
|
FriendPresence { id: EndpointId, presence: FriendPresence },
|
|
/// Core corrected the committed presence posture. Usually the Discoverable
|
|
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
|
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
|
/// persist this so its presence picker matches the endpoint's discovery state.
|
|
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.
|
|
ShutdownComplete,
|
|
Error(String),
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{delivery_class, CoreCommand, DeliveryClass};
|
|
use crate::audio::eq::EqSettings;
|
|
use crate::presence::PresenceMode;
|
|
use iroh::{EndpointId, SecretKey};
|
|
|
|
fn endpoint_id() -> EndpointId {
|
|
SecretKey::generate().public()
|
|
}
|
|
|
|
#[test]
|
|
fn continuous_audio_controls_are_best_effort() {
|
|
let peer = endpoint_id();
|
|
let commands = [
|
|
CoreCommand::SetPeerVolume(peer, 0.7),
|
|
CoreCommand::SetPeerPan(peer, -0.2),
|
|
CoreCommand::SetPeerGate(peer, 0.1),
|
|
CoreCommand::SetPeerEq(peer, EqSettings::default()),
|
|
CoreCommand::SetInputVolume(0.8),
|
|
CoreCommand::SetOutputVolume(0.9),
|
|
CoreCommand::SetNoiseGateThreshold(0.02),
|
|
];
|
|
|
|
for cmd in commands {
|
|
assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn discrete_user_actions_are_reliable() {
|
|
let peer = endpoint_id();
|
|
let commands = [
|
|
CoreCommand::ToggleMute,
|
|
CoreCommand::SetPttActive(false),
|
|
CoreCommand::Leave,
|
|
CoreCommand::RegenerateIdentity,
|
|
CoreCommand::Join {
|
|
name: "Peer".to_string(),
|
|
ticket: "create".to_string(),
|
|
room_name: "Room".to_string(),
|
|
input_device: None,
|
|
output_device: None,
|
|
echo_cancellation: true,
|
|
avatar: crate::avatar::Avatar::default(),
|
|
},
|
|
CoreCommand::SetPeerMuted(peer, true),
|
|
CoreCommand::SetPresenceMode(PresenceMode::Normal),
|
|
CoreCommand::SendChat("hello".to_string()),
|
|
];
|
|
|
|
for cmd in commands {
|
|
assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable);
|
|
}
|
|
}
|
|
}
|