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, output_device: Option, 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 }, /// 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 }, /// 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), /// 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 }, /// 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 }, /// 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), } #[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 }, /// 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 }, /// 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, 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 }, /// 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, 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), /// 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); } } }