The chat-attachment result cache (`attachment_data` + `image_handle_cache`) was keyed by attachment id alone and only cleared on room-leave, so an authenticated insider could (F-02) stream distinct attachments to grow it without bound, and (F-12) reuse a victim's attachment id to alias displayed/ saved bytes — the id is attacker-chosen, so a signature only proves keypair ownership, not a distinct human. F-12: thread the author (`from: EndpointId`) back through the `AttachmentReady`/`AttachmentFailed` core→UI events (the fetch task already holds it) and key all attachment result state on `(author, id)`: - new `AttachmentKey = (EndpointId, AttachmentId)`; - `attachment_data` + `image_handle_cache` fold into one `AttachmentCache`; - `pending_saves` and the `SaveAttachment`/`PlayAudio` messages re-keyed, so the save/fetch dispatch can't be redirected to the wrong sender's line; - `find_attachment_source` now matches author AND id; - the render path resolves each line's key from `ChatEntry.from`. F-02: `AttachmentCache` is bounded (`ATTACHMENT_CACHE_CAP = 64`) with insertion-order eviction. True LRU is impossible because iced's `view` borrows `&self` and so can't reorder on a render read; the generous cap means a normal session never evicts and the newest (on-screen) entries are always retained — only an abusive stream hits the bound. Deliberately id-keyed (cosmetic only, documented): the clip player's `playing_id`, `pending_plays`, `invalid_audio` — they're coupled to the id-keyed clip player, and the bytes actually played come from the author-keyed cache, so content is always correct. No gossip/wire/protocol change (UiEvent is in-process), no new deps. +6 unit tests (cache eviction, replace-keeps-position, same-id/distinct-author non-aliasing, is_ready/handle/clear, cap-zero clamp). 423 lib tests, clippy --all-targets clean, release build green. TESTS-GREEN-ONLY. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
179 lines
10 KiB
Rust
179 lines
10 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>),
|
|
/// 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.
|
|
StartScreenShare,
|
|
/// 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)]
|
|
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 },
|
|
/// Our own screen share started; the UI flips the Share button to "Stop".
|
|
ScreenShareStarted,
|
|
/// Our own screen share stopped (or failed to start).
|
|
ScreenShareStopped,
|
|
/// 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),
|
|
}
|