Skip to main content

app_ui/
system_audio_picker.rs

1//! `<SystemAudioPicker />` — live-data picker for the speaker /
2//! per-app audio capture in the Recorder surface (M-AUDIO-SYS.2 /
3//! AUT-279 / AUT-282).
4//!
5//! Mirrors [`crate::mic_picker`] for the speaker path with two key
6//! differences:
7//!
8//! - **Master on/off toggle** (`enabled` signal) gates everything.
9//!   The mic picker has no master toggle because a mic device is
10//!   always selectable; system audio is opt-in and the toggle is
11//!   the cleanest UX.
12//! - **Multi-select with bundle-id persistence**. Each app row has a
13//!   checkbox; the selected bundle ids round-trip through
14//!   `LocalStorage` so a Spotify selection survives across launches.
15//!
16//! Ships the full picker: filter chips (All / None / Suggested / Custom) with
17//! the suggested-app heuristic, a 250 ms debounced filter-apply, real app
18//! icons (AUT-288 — inline PNG `data:` URLs from the backend, glyph fallback),
19//! and the SCK-denial settings deep-link.
20
21use leptos::prelude::*;
22use leptos::task::spawn_local;
23use ui_storybook::components::primitives::ChevronDown;
24
25use crate::system_audio_ipc::{self, AudioAppFilterView, AudioAppView, ListAudioAppsResult};
26
27/// `LocalStorage` key for the persisted multi-select bundle-id set.
28#[cfg(target_arch = "wasm32")]
29const SELECTED_KEY: &str = "screen.system_audio.selected_bundle_ids";
30
31/// `LocalStorage` key for the master enabled flag.
32#[cfg(target_arch = "wasm32")]
33const ENABLED_KEY: &str = "screen.system_audio.enabled";
34
35/// Debounce window for `set_system_audio_filter` invocations
36/// (M-AUDIO-SYS.3 / AUT-288). SCK's `updateContentFilter` takes
37/// ~100 ms; 250 ms lets the user click 3-4 checkboxes in rapid
38/// succession and only rebuild the stream once on the trailing
39/// edge. Only referenced from the wasm32 branch of
40/// `schedule_filter_apply` — native builds skip the gloo-timers
41/// path entirely.
42#[cfg(target_arch = "wasm32")]
43const FILTER_DEBOUNCE_MS: u32 = 250;
44
45/// Suggested-app heuristic (M-AUDIO-SYS.3 / AUT-288). Best-effort
46/// baseline list of bundle-id prefixes the recorder thinks the
47/// user probably wants when they click the "Suggested" chip:
48/// browsers, media / streaming apps, and comm apps. Excludes
49/// system services + the recorder itself by omission.
50///
51/// Match is **prefix-based** so versioned bundles like
52/// `com.google.Chrome.beta` still hit. PRs welcome — this is a
53/// pragmatic baseline, not a curated taxonomy.
54const SUGGESTED_BUNDLE_PREFIXES: &[&str] = &[
55    // Browsers
56    "com.google.Chrome",
57    "com.apple.Safari",
58    "org.mozilla.firefox",
59    "com.brave.Browser",
60    "company.thebrowser.Browser", // Arc
61    "com.microsoft.edgemac",
62    // Media / streaming
63    "com.spotify.client",
64    "com.apple.Music",
65    "com.apple.TV",
66    "tv.plex.player",
67    "com.netflix.Netflix",
68    // Communication
69    "com.tinyspeck.slackmacgap",
70    "us.zoom.xos",
71    "com.microsoft.teams2",
72    "com.hnc.Discord",
73    "com.google.meet",
74];
75
76/// Which filter-chip is visually active given the current picker
77/// state. `Custom` is the implicit "user hand-edited the
78/// checkboxes" mode — no chip is mapped to it as a click target;
79/// it just lights up when none of the other three describe the
80/// current selection.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82enum ActiveChip {
83    /// All apps captured (no filter).
84    All,
85    /// Master toggle is off — no system audio captured.
86    None,
87    /// Selection exactly matches the suggested-heuristic result.
88    Suggested,
89    /// User hand-edited the checkboxes; no chip describes it.
90    Custom,
91}
92
93/// Compute which chip should appear active. Order matters — when
94/// multiple chips could describe the state (e.g. empty selection
95/// could be "All" OR "Suggested produced no matches"), the most
96/// specific wins.
97fn compute_active_chip(
98    enabled: bool,
99    selected: &[String],
100    all_apps: &[AudioAppView],
101) -> ActiveChip {
102    if !enabled {
103        return ActiveChip::None;
104    }
105    if selected.is_empty() {
106        return ActiveChip::All;
107    }
108    let suggested = suggested_bundle_ids(all_apps);
109    if same_set(selected, &suggested) {
110        ActiveChip::Suggested
111    } else {
112        ActiveChip::Custom
113    }
114}
115
116/// Order-insensitive equality of two bundle-id lists.
117fn same_set(a: &[String], b: &[String]) -> bool {
118    if a.len() != b.len() {
119        return false;
120    }
121    let set: std::collections::HashSet<&str> = a.iter().map(String::as_str).collect();
122    b.iter().all(|s| set.contains(s.as_str()))
123}
124
125/// Walk the running-app list and pull out bundle ids whose prefix
126/// is in [`SUGGESTED_BUNDLE_PREFIXES`].
127fn suggested_bundle_ids(apps: &[AudioAppView]) -> Vec<String> {
128    apps.iter()
129        .filter(|a| {
130            SUGGESTED_BUNDLE_PREFIXES
131                .iter()
132                .any(|p| a.bundle_id.starts_with(p))
133        })
134        .map(|a| a.bundle_id.clone())
135        .collect()
136}
137
138/// `<SystemAudioPicker />` — master toggle + expandable per-app
139/// checklist.
140#[allow(
141    clippy::too_many_lines,
142    reason = "Leptos #[component] body is signals + closures + view!; splitting at the natural seam (each signal's click handler) would only hurt readability without reducing complexity."
143)]
144#[component]
145pub fn SystemAudioPicker() -> impl IntoView {
146    let enabled = RwSignal::new(read_enabled());
147    let expanded = RwSignal::new(false);
148    let apps = RwSignal::new(Vec::<AudioAppView>::new());
149    let error_message = RwSignal::new(Option::<String>::None);
150    let selected_ids = RwSignal::new(read_selected_ids());
151    // M-AUDIO.METER / AUT-287 — master-stream RMS from the SCK
152    // delegate. Per-app meters are deferred to M-AUDIO.METER.1.
153    let level = RwSignal::new(0.0_f32);
154    // M-RECORD.3 — lock the master toggle while a coordinated
155    // session is active so the user can't drop sys-audio mid-record.
156    let recording_lock = RwSignal::new(false);
157    crate::recording_ipc::install_recording_lock_listener(recording_lock);
158
159    system_audio_ipc::subscribe_system_audio_level(move |l| level.set(l));
160
161    // Master toggle: clicking starts/stops the SCK session.
162    let on_toggle_enabled = move |_| {
163        let next = !enabled.get();
164        enabled.set(next);
165        write_enabled(next);
166        let selected_now = selected_ids.get();
167        spawn_local(async move {
168            if next {
169                match system_audio_ipc::start_system_audio_capture().await {
170                    Ok(()) => {
171                        error_message.set(None);
172                        apply_filter_from_selection(&selected_now).await;
173                    }
174                    Err(err) => {
175                        // Revert master toggle since start failed.
176                        enabled.set(false);
177                        write_enabled(false);
178                        error_message.set(Some(err));
179                    }
180                }
181            } else {
182                system_audio_ipc::stop_system_audio_capture().await;
183            }
184        });
185    };
186
187    // Open the expander → re-fetch apps so newly-launched ones appear.
188    let on_toggle_expand = move |_| {
189        let next = !expanded.get();
190        expanded.set(next);
191        if next {
192            spawn_local(async move {
193                match system_audio_ipc::list_audio_apps().await {
194                    ListAudioAppsResult::Ok(list) => {
195                        error_message.set(None);
196                        apps.set(list);
197                    }
198                    ListAudioAppsResult::Err(msg) => {
199                        error_message.set(Some(msg));
200                        apps.set(Vec::new());
201                    }
202                }
203            });
204        }
205    };
206
207    let on_toggle_app = move |bundle_id: String| {
208        selected_ids.update(|ids| {
209            if let Some(pos) = ids.iter().position(|id| id == &bundle_id) {
210                ids.remove(pos);
211            } else {
212                ids.push(bundle_id);
213            }
214        });
215        let selected_now = selected_ids.get();
216        write_selected_ids(&selected_now);
217        if enabled.get() {
218            schedule_filter_apply(selected_now);
219        }
220    };
221
222    // Chip-click handlers (M-AUDIO-SYS.3 / AUT-288). Each chip
223    // mutates `(enabled, selected_ids)` to the canonical state for
224    // that chip, persists, and (when enabled) schedules a
225    // debounced filter apply.
226    let on_chip_all = move |_| {
227        selected_ids.set(Vec::new());
228        write_selected_ids(&[]);
229        if enabled.get() {
230            schedule_filter_apply(Vec::new());
231        } else {
232            enabled.set(true);
233            write_enabled(true);
234            spawn_local(async move {
235                match system_audio_ipc::start_system_audio_capture().await {
236                    Ok(()) => {
237                        error_message.set(None);
238                        schedule_filter_apply(Vec::new());
239                    }
240                    Err(err) => {
241                        enabled.set(false);
242                        write_enabled(false);
243                        error_message.set(Some(err));
244                    }
245                }
246            });
247        }
248    };
249    let on_chip_none = move |_| {
250        enabled.set(false);
251        write_enabled(false);
252        spawn_local(async move {
253            system_audio_ipc::stop_system_audio_capture().await;
254        });
255    };
256    let on_chip_suggested = move |_| {
257        let pick = suggested_bundle_ids(&apps.get());
258        selected_ids.set(pick.clone());
259        write_selected_ids(&pick);
260        if enabled.get() {
261            schedule_filter_apply(pick);
262        } else {
263            enabled.set(true);
264            write_enabled(true);
265            let pick_for_start = pick.clone();
266            spawn_local(async move {
267                match system_audio_ipc::start_system_audio_capture().await {
268                    Ok(()) => {
269                        error_message.set(None);
270                        schedule_filter_apply(pick_for_start);
271                    }
272                    Err(err) => {
273                        enabled.set(false);
274                        write_enabled(false);
275                        error_message.set(Some(err));
276                    }
277                }
278            });
279        }
280    };
281
282    view! {
283        <div class="system-audio-picker">
284            <div class="system-audio-picker-header">
285                <button
286                    type="button"
287                    class="system-audio-picker-toggle"
288                    role="switch"
289                    aria-checked=move || enabled.get()
290                    data-enabled=move || if enabled.get() { "true" } else { "false" }
291                    prop:disabled=move || recording_lock.get()
292                    title=move || if recording_lock.get() { "Recording in progress — stop the recording to toggle system audio" } else { "" }
293                    on:click=move |evt| {
294                        if recording_lock.get() { return; }
295                        on_toggle_enabled(evt);
296                    }
297                >
298                    <span class="system-audio-picker-icon" aria-hidden="true">"🔈"</span>
299                    <span class="system-audio-picker-label">"System audio"</span>
300                    <span class="system-audio-picker-state">
301                        {move || if enabled.get() { "On" } else { "Off" }}
302                    </span>
303                </button>
304                <button
305                    type="button"
306                    class="system-audio-picker-expand"
307                    aria-haspopup="listbox"
308                    aria-expanded=move || expanded.get()
309                    on:click=on_toggle_expand
310                >
311                    <span class="system-audio-picker-summary">
312                        {move || summary_label(selected_ids.get().len())}
313                    </span>
314                    <span class="system-audio-picker-chevron" aria-hidden="true">
315                        <ChevronDown />
316                    </span>
317                </button>
318            </div>
319            <div class="audio-meter" aria-label="System audio output level">
320                <div
321                    class="audio-meter-bar"
322                    style:width=move || format!("{:.1}%", (level.get() * 100.0).clamp(0.0, 100.0))
323                ></div>
324            </div>
325            <Show when=move || expanded.get() fallback=|| view! { <></> }>
326                <div class="system-audio-picker-menu" role="listbox">
327                    <div class="system-audio-picker-chips">
328                        {
329                            let chip_signal = Memo::new(move |_| {
330                                compute_active_chip(enabled.get(), &selected_ids.get(), &apps.get())
331                            });
332                            view! {
333                                <button
334                                    type="button"
335                                    class="system-audio-picker-chip"
336                                    data-active=move || (chip_signal.get() == ActiveChip::All).to_string()
337                                    on:click=on_chip_all
338                                >"All"</button>
339                                <button
340                                    type="button"
341                                    class="system-audio-picker-chip"
342                                    data-active=move || (chip_signal.get() == ActiveChip::None).to_string()
343                                    on:click=on_chip_none
344                                >"None"</button>
345                                <button
346                                    type="button"
347                                    class="system-audio-picker-chip"
348                                    data-active=move || (chip_signal.get() == ActiveChip::Suggested).to_string()
349                                    on:click=on_chip_suggested
350                                >"Suggested"</button>
351                                <button
352                                    type="button"
353                                    class="system-audio-picker-chip"
354                                    data-active=move || (chip_signal.get() == ActiveChip::Custom).to_string()
355                                    disabled=true
356                                >"Custom"</button>
357                            }
358                        }
359                    </div>
360                    <SystemAudioBody
361                        apps=apps
362                        selected_ids=selected_ids
363                        error_message=error_message
364                        on_toggle_app=Callback::new(on_toggle_app)
365                    />
366                </div>
367            </Show>
368        </div>
369    }
370}
371
372#[component]
373fn SystemAudioBody(
374    apps: RwSignal<Vec<AudioAppView>>,
375    selected_ids: RwSignal<Vec<String>>,
376    error_message: RwSignal<Option<String>>,
377    on_toggle_app: Callback<String>,
378) -> impl IntoView {
379    move || {
380        match (error_message.get(), apps.get()) {
381        (Some(msg), _) => view! {
382            <div class="system-audio-picker-state-msg system-audio-picker-state-msg--error">
383                <p>{"Couldn't list apps."}</p>
384                <p class="system-audio-picker-state-help">{msg}</p>
385                <p class="system-audio-picker-state-help">
386                    {"Request access first; macOS will then add this app to System Settings. After enabling it, quit and reopen the app."}
387                </p>
388                <button
389                    type="button"
390                    class="system-audio-picker-state-button"
391                    on:click=move |_| {
392                        spawn_local(async move {
393                            system_audio_ipc::request_screen_recording_permission().await;
394                            system_audio_ipc::open_settings_screen_recording().await;
395                        });
396                    }
397                >
398                    {"Request Screen Recording Access"}
399                </button>
400            </div>
401        }
402        .into_any(),
403        (None, list) if list.is_empty() => view! {
404            <div class="system-audio-picker-state-msg">
405                <p>{"No running apps detected."}</p>
406            </div>
407        }
408        .into_any(),
409        (None, list) => view! {
410            <ul class="system-audio-picker-list" role="none">
411                {list
412                    .into_iter()
413                    .map(|app| render_app_row(app, selected_ids.get(), on_toggle_app))
414                    .collect_view()}
415            </ul>
416        }
417        .into_any(),
418    }
419    }
420}
421
422/// Standard base64 (RFC 4648) of `input`. Tiny + pure (no dep) so it runs in
423/// the native unit tests as well as wasm — used to inline the app-icon PNG
424/// (AUT-288) as a `data:` URL.
425fn base64_encode(input: &[u8]) -> String {
426    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
427    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
428    for chunk in input.chunks(3) {
429        let b0 = u32::from(chunk[0]);
430        let b1 = chunk.get(1).copied().map_or(0, u32::from);
431        let b2 = chunk.get(2).copied().map_or(0, u32::from);
432        let n = (b0 << 16) | (b1 << 8) | b2;
433        let idx = |shift: u32| usize::try_from((n >> shift) & 63).unwrap_or(0);
434        out.push(char::from(A[idx(18)]));
435        out.push(char::from(A[idx(12)]));
436        out.push(if chunk.len() > 1 {
437            char::from(A[idx(6)])
438        } else {
439            '='
440        });
441        out.push(if chunk.len() > 2 {
442            char::from(A[idx(0)])
443        } else {
444            '='
445        });
446    }
447    out
448}
449
450/// A `data:image/png;base64,…` URL for the app icon (AUT-288), or `None` when
451/// no icon was captured (empty bytes — the row then shows its glyph). The
452/// backend ships native-size PNGs; `None` is the headless / no-icon path.
453fn icon_data_url(png_bytes: &[u8]) -> Option<String> {
454    if png_bytes.is_empty() {
455        return None;
456    }
457    Some(format!(
458        "data:image/png;base64,{}",
459        base64_encode(png_bytes)
460    ))
461}
462
463fn render_app_row(
464    app: AudioAppView,
465    selected: Vec<String>,
466    on_toggle_app: Callback<String>,
467) -> impl IntoView {
468    let is_selected = selected.iter().any(|id| id == &app.bundle_id);
469    let mut class = String::from("system-audio-picker-row");
470    if is_selected {
471        class.push_str(" system-audio-picker-row-selected");
472    }
473    let bundle_for_click = app.bundle_id.clone();
474    let bundle_for_attr = app.bundle_id.clone();
475    let bundle_for_caption = app.bundle_id.clone();
476    let name = app.display_name.clone();
477    // AUT-288 — real app icon as an inline PNG when captured; the glyph
478    // placeholder otherwise (empty bytes, e.g. a headless / no-icon app).
479    let icon = match icon_data_url(&app.icon_png_bytes) {
480        Some(url) => view! {
481            <img class="system-audio-picker-row-img" src=url alt="" />
482        }
483        .into_any(),
484        None => "·".into_any(),
485    };
486    view! {
487        <li>
488            <button
489                type="button"
490                class=class
491                role="option"
492                aria-selected=is_selected
493                data-bundle-id=bundle_for_attr
494                on:click=move |_| on_toggle_app.run(bundle_for_click.clone())
495            >
496                <span class="system-audio-picker-row-icon" aria-hidden="true">
497                    {icon}
498                </span>
499                <span class="system-audio-picker-row-label">{name}</span>
500                <span class="system-audio-picker-row-bundle">{bundle_for_caption}</span>
501                {is_selected.then(|| view! {
502                    <span class="system-audio-picker-row-check" aria-hidden="true">"✓"</span>
503                })}
504            </button>
505        </li>
506    }
507}
508
509/// Build a filter from the current selection and push it to the
510/// backend. Empty selection → `AllAudio` (capture everything);
511/// non-empty → `OnlyApps` (capture just those).
512async fn apply_filter_from_selection(selected_ids: &[String]) {
513    let filter = if selected_ids.is_empty() {
514        AudioAppFilterView::AllAudio
515    } else {
516        AudioAppFilterView::OnlyApps(selected_ids.to_vec())
517    };
518    let _ = system_audio_ipc::set_system_audio_filter(filter).await;
519}
520
521/// Debounced wrapper around [`apply_filter_from_selection`]
522/// (M-AUDIO-SYS.3 / AUT-288). Replaces any pending timeout with a
523/// fresh `FILTER_DEBOUNCE_MS`-ms one — the previous Timeout drops,
524/// which cancels it. Single-threaded wasm32 means a `RefCell` is
525/// sufficient for the shared handle.
526#[cfg(target_arch = "wasm32")]
527fn schedule_filter_apply(selected_ids: Vec<String>) {
528    use gloo_timers::callback::Timeout;
529    use std::cell::RefCell;
530    thread_local! {
531        static PENDING: RefCell<Option<Timeout>> = const { RefCell::new(None) };
532    }
533    let timeout = Timeout::new(FILTER_DEBOUNCE_MS, move || {
534        let ids = selected_ids.clone();
535        leptos::task::spawn_local(async move {
536            apply_filter_from_selection(&ids).await;
537        });
538    });
539    PENDING.with(|cell| {
540        // Replacing drops the previous Timeout (cancels it).
541        *cell.borrow_mut() = Some(timeout);
542    });
543}
544
545#[cfg(not(target_arch = "wasm32"))]
546fn schedule_filter_apply(_selected_ids: Vec<String>) {
547    // Native target — no event loop to schedule on; tests exercise
548    // `apply_filter_from_selection` + `compute_active_chip` /
549    // `suggested_bundle_ids` directly.
550}
551
552fn summary_label(count: usize) -> String {
553    match count {
554        0 => "All apps".into(),
555        1 => "1 app".into(),
556        n => format!("{n} apps"),
557    }
558}
559
560#[cfg(target_arch = "wasm32")]
561fn read_enabled() -> bool {
562    let Some(window) = web_sys::window() else {
563        return false;
564    };
565    let Ok(Some(storage)) = window.local_storage() else {
566        return false;
567    };
568    matches!(
569        storage.get_item(ENABLED_KEY).ok().flatten().as_deref(),
570        Some("true")
571    )
572}
573
574#[cfg(not(target_arch = "wasm32"))]
575fn read_enabled() -> bool {
576    false
577}
578
579#[cfg(target_arch = "wasm32")]
580fn write_enabled(value: bool) {
581    let Some(window) = web_sys::window() else {
582        return;
583    };
584    let Ok(Some(storage)) = window.local_storage() else {
585        return;
586    };
587    let _ = storage.set_item(ENABLED_KEY, if value { "true" } else { "false" });
588}
589
590#[cfg(not(target_arch = "wasm32"))]
591fn write_enabled(_value: bool) {}
592
593#[cfg(target_arch = "wasm32")]
594fn read_selected_ids() -> Vec<String> {
595    let Some(window) = web_sys::window() else {
596        return Vec::new();
597    };
598    let Ok(Some(storage)) = window.local_storage() else {
599        return Vec::new();
600    };
601    storage
602        .get_item(SELECTED_KEY)
603        .ok()
604        .flatten()
605        .and_then(|raw| serde_json::from_str(&raw).ok())
606        .unwrap_or_default()
607}
608
609#[cfg(not(target_arch = "wasm32"))]
610fn read_selected_ids() -> Vec<String> {
611    Vec::new()
612}
613
614#[cfg(target_arch = "wasm32")]
615fn write_selected_ids(ids: &[String]) {
616    let Some(window) = web_sys::window() else {
617        return;
618    };
619    let Ok(Some(storage)) = window.local_storage() else {
620        return;
621    };
622    if let Ok(json) = serde_json::to_string(ids) {
623        let _ = storage.set_item(SELECTED_KEY, &json);
624    }
625}
626
627#[cfg(not(target_arch = "wasm32"))]
628fn write_selected_ids(_ids: &[String]) {}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn base64_encode_matches_rfc4648_vectors() {
636        assert_eq!(base64_encode(b""), "");
637        assert_eq!(base64_encode(b"f"), "Zg==");
638        assert_eq!(base64_encode(b"fo"), "Zm8=");
639        assert_eq!(base64_encode(b"foo"), "Zm9v");
640        assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
641        assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
642        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
643    }
644
645    #[test]
646    fn icon_data_url_wraps_png_bytes_or_is_none() {
647        // Empty bytes (headless / no icon) → None → the row shows its glyph.
648        assert_eq!(icon_data_url(&[]), None);
649        // The PNG magic number → a proper data: URL the <img> can render.
650        let url = icon_data_url(&[0x89, 0x50, 0x4e, 0x47]).expect("non-empty → Some");
651        assert_eq!(url, "data:image/png;base64,iVBORw==");
652        assert!(url.starts_with("data:image/png;base64,"));
653    }
654
655    #[test]
656    fn summary_label_zero_one_many() {
657        assert_eq!(summary_label(0), "All apps");
658        assert_eq!(summary_label(1), "1 app");
659        assert_eq!(summary_label(5), "5 apps");
660    }
661
662    #[test]
663    fn empty_selection_yields_all_audio_filter() {
664        // The picker treats empty selection as "capture everything" —
665        // matches the master-toggle UX where flipping On without
666        // picking any specific apps should yield system-wide capture.
667        let selected: Vec<String> = Vec::new();
668        let filter = if selected.is_empty() {
669            AudioAppFilterView::AllAudio
670        } else {
671            AudioAppFilterView::OnlyApps(selected)
672        };
673        assert_eq!(filter, AudioAppFilterView::AllAudio);
674    }
675
676    #[test]
677    fn non_empty_selection_yields_only_apps_filter() {
678        let selected = vec!["com.spotify.client".to_string()];
679        let filter = if selected.is_empty() {
680            AudioAppFilterView::AllAudio
681        } else {
682            AudioAppFilterView::OnlyApps(selected.clone())
683        };
684        assert_eq!(filter, AudioAppFilterView::OnlyApps(selected));
685    }
686
687    fn app(bundle: &str) -> AudioAppView {
688        AudioAppView {
689            pid: 0,
690            bundle_id: bundle.to_string(),
691            display_name: bundle.to_string(),
692            icon_png_bytes: Vec::new(),
693        }
694    }
695
696    #[test]
697    fn suggested_picks_browsers_media_comm() {
698        let running = vec![
699            app("com.google.Chrome"),
700            app("com.spotify.client"),
701            app("us.zoom.xos"),
702            app("com.apple.Notes"), // not in suggested list
703            app("com.apple.dock"),  // not in suggested list
704        ];
705        let picked = suggested_bundle_ids(&running);
706        assert_eq!(picked.len(), 3);
707        assert!(picked.contains(&"com.google.Chrome".to_string()));
708        assert!(picked.contains(&"com.spotify.client".to_string()));
709        assert!(picked.contains(&"us.zoom.xos".to_string()));
710        assert!(!picked.contains(&"com.apple.Notes".to_string()));
711    }
712
713    #[test]
714    fn suggested_prefix_match_catches_versioned_bundles() {
715        let running = vec![app("com.google.Chrome.beta"), app("com.brave.Browser.dev")];
716        let picked = suggested_bundle_ids(&running);
717        assert_eq!(picked.len(), 2, "versioned bundles should match by prefix");
718    }
719
720    #[test]
721    fn active_chip_none_when_disabled() {
722        let chip = compute_active_chip(false, &[], &[]);
723        assert_eq!(chip, ActiveChip::None);
724    }
725
726    #[test]
727    fn active_chip_all_when_enabled_with_empty_selection() {
728        let chip = compute_active_chip(true, &[], &[]);
729        assert_eq!(chip, ActiveChip::All);
730    }
731
732    #[test]
733    fn active_chip_suggested_when_selection_matches_heuristic() {
734        let running = vec![app("com.spotify.client"), app("com.apple.Notes")];
735        let selected = vec!["com.spotify.client".to_string()];
736        assert_eq!(
737            compute_active_chip(true, &selected, &running),
738            ActiveChip::Suggested
739        );
740    }
741
742    #[test]
743    fn active_chip_custom_when_selection_doesnt_match_heuristic() {
744        let running = vec![app("com.spotify.client"), app("com.apple.Notes")];
745        let selected = vec!["com.apple.Notes".to_string()];
746        assert_eq!(
747            compute_active_chip(true, &selected, &running),
748            ActiveChip::Custom
749        );
750    }
751
752    #[test]
753    fn same_set_is_order_insensitive() {
754        let a = vec!["a".to_string(), "b".to_string()];
755        let b = vec!["b".to_string(), "a".to_string()];
756        assert!(same_set(&a, &b));
757        let c = vec!["a".to_string()];
758        assert!(!same_set(&a, &c));
759    }
760}