Skip to main content

frust_shell_desktop/
config.rs

1//! Desktop app identity: the platform-independent configuration the shared
2//! winit core threads into window creation and carries for the per-OS shell
3//! crates.
4//!
5//! Everything here is plain data — no `winit`, no platform types, no behavior.
6//! The core itself consumes exactly one field ([`DesktopConfig::app_name`], as
7//! the window title); the rest exists so a per-OS shell can read one config
8//! rather than inventing its own (`app_id` becomes a Wayland `app_id`/X11
9//! `WM_CLASS` or a Windows AppUserModelID; `window_icon` becomes an
10//! `NSApplication` icon / `HICON` / X11 icon; `menu_spec` becomes an NSApp menu
11//! bar or an HMENU).
12//!
13//! # Why the menu vocabulary lives here, below the facade
14//!
15//! [`MenuSpec`]/[`MenuItemSpec`] are app-facing types, so the obvious home
16//! looks like the `frust` facade. It cannot be: the facade depends on the shell
17//! crates, never the reverse, and both the shared core (which carries the spec)
18//! and the per-OS shells (which build a native menu from it) need to name the
19//! type. So the vocabulary is *defined* here — the lowest crate that all of
20//! them share — and the facade *re-exports* it, exactly as it re-exports the
21//! reactive seams it likewise cannot own.
22//!
23//! # Defaults are today's behavior
24//!
25//! [`DesktopConfig::default()`] reproduces the zero-config dev-preview window
26//! byte-for-byte: the title falls back to [`DEFAULT_APP_NAME`], no icon, no
27//! menu, and a close request quits. An app that never configures anything gets
28//! precisely the window it got before this seam existed.
29
30/// The window title used when no [`DesktopConfig::app_name`] is configured —
31/// the dev-preview shell's historical hardcoded title, preserved so the
32/// zero-config path is unchanged.
33pub const DEFAULT_APP_NAME: &str = "Frust";
34
35/// The desktop app's identity and native-integration configuration.
36///
37/// Built by the facade from an app's own declaration and handed to
38/// [`run_desktop_with`](crate::run_desktop_with); every field is optional and
39/// the [`Default`] value is today's dev-preview behavior (see the module docs).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct DesktopConfig {
42    /// The app's display name: the window title, and (for the per-OS shells)
43    /// the name a macOS menu bar's application menu shows. `None` falls back to
44    /// [`DEFAULT_APP_NAME`] for the title — see [`DesktopConfig::window_title`].
45    pub app_name: Option<String>,
46    /// The app's reverse-DNS identifier (`com.example.myapp`). Carried, never
47    /// consumed by the shared core: the Linux shell turns it into a Wayland
48    /// `app_id`/X11 `WM_CLASS` so the window pairs with its `.desktop` entry,
49    /// and the Windows shell into an AppUserModelID so taskbar grouping and
50    /// notifications attribute correctly.
51    pub app_id: Option<String>,
52    /// The window/taskbar icon as platform-independent RGBA (see [`IconData`]).
53    /// Carried for the per-OS shells; the shared core never attaches it, since
54    /// winit's own `WindowAttributes::with_window_icon` reaches X11 and Windows
55    /// only and macOS wants an application icon rather than a window one.
56    pub window_icon: Option<IconData>,
57    /// The native menu bar to install, if any (see [`MenuSpec`]). Carried for
58    /// the per-OS shells that can build one; a platform with no native menu
59    /// (Linux, where the menu is widget-drawn) simply ignores it.
60    pub menu_spec: Option<MenuSpec>,
61    /// Whether closing the last window quits the app.
62    ///
63    /// Carried, not consumed by the shared core: with no extension installed a
64    /// close request always exits the event loop (today's behavior, and what
65    /// `true` means anyway). It exists for the macOS shell, where the platform
66    /// convention is for an app to stay running with no window until ⌘Q —
67    /// see `DesktopExtensions::on_close_requested`.
68    pub quit_on_last_window_closed: bool,
69}
70
71impl Default for DesktopConfig {
72    /// Hand-written rather than derived because
73    /// [`quit_on_last_window_closed`](DesktopConfig::quit_on_last_window_closed)
74    /// defaults to `true` — the shell's existing unconditional
75    /// `event_loop.exit()` on `CloseRequested`.
76    fn default() -> Self {
77        Self {
78            app_name: None,
79            app_id: None,
80            window_icon: None,
81            menu_spec: None,
82            quit_on_last_window_closed: true,
83        }
84    }
85}
86
87impl DesktopConfig {
88    /// A config that reproduces today's zero-config preview window (see
89    /// [`Default`]).
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Set the app's display name (the window title).
95    pub fn with_app_name(mut self, app_name: impl Into<String>) -> Self {
96        self.app_name = Some(app_name.into());
97        self
98    }
99
100    /// Set the app's reverse-DNS identifier.
101    pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
102        self.app_id = Some(app_id.into());
103        self
104    }
105
106    /// Set the window/taskbar icon.
107    pub fn with_window_icon(mut self, icon: IconData) -> Self {
108        self.window_icon = Some(icon);
109        self
110    }
111
112    /// Set the native menu bar to install.
113    pub fn with_menu_spec(mut self, menu_spec: MenuSpec) -> Self {
114        self.menu_spec = Some(menu_spec);
115        self
116    }
117
118    /// Set whether closing the last window quits the app.
119    pub fn with_quit_on_last_window_closed(mut self, quit: bool) -> Self {
120        self.quit_on_last_window_closed = quit;
121        self
122    }
123
124    /// The title to give the preview window: the configured
125    /// [`app_name`](DesktopConfig::app_name), else [`DEFAULT_APP_NAME`].
126    ///
127    /// The fallback lives here rather than in the field itself so a per-OS
128    /// shell can still tell "the app named itself `Frust`" apart from "the app
129    /// named itself nothing" — a macOS menu bar wants the real name or no
130    /// application menu at all, not a placeholder.
131    pub fn window_title(&self) -> &str {
132        self.app_name.as_deref().unwrap_or(DEFAULT_APP_NAME)
133    }
134}
135
136/// A decoded window/app icon: tightly-packed, non-premultiplied RGBA8 rows,
137/// top-to-bottom — the one representation every desktop platform can be fed
138/// from (winit's `Icon::from_rgba`, an `NSImage` bitmap rep, an `HICON` DIB).
139///
140/// Decoding a PNG/ICNS/ICO into this is the caller's job: this crate carries no
141/// image decoder, and the tooling tier already owns the icon pipeline.
142///
143/// The `rgba`/`width`/`height` triple is an invariant, not three independent
144/// fields (`rgba.len() == width * height * 4`), so the fields are private and
145/// [`IconData::from_rgba`] is the only way in — an inconsistent icon reaches a
146/// platform API as a buffer overrun, not a wrong picture.
147///
148/// `Debug` is hand-written rather than derived (see the manual `impl` below):
149/// a derived one would dump the whole pixel buffer, drowning a log line in
150/// thousands of byte values for even a small icon.
151#[derive(Clone, PartialEq, Eq)]
152pub struct IconData {
153    rgba: Vec<u8>,
154    width: u32,
155    height: u32,
156}
157
158/// No real window/taskbar icon approaches this — winit's own `Icon::from_rgba`
159/// already limits a *Windows* icon to `u16::MAX` per side (65535), and macOS/
160/// Linux icons are conventionally well under 1024px. The cap forecloses an
161/// absurd `width`/`height` (however it arrived — a corrupt decode, a
162/// deliberately hostile input) from reaching a platform icon API at all, on
163/// top of [`from_rgba`](IconData::from_rgba)'s own overflow-safe size check.
164const MAX_ICON_SIDE: u32 = 4096;
165
166impl IconData {
167    /// Wrap decoded RGBA8 pixels, or `None` when they do not describe a
168    /// `width × height` image: either dimension is zero or exceeds
169    /// `MAX_ICON_SIDE`, or `rgba.len() != width * height * 4`.
170    ///
171    /// `Option` rather than a `<Type>Error` enum because there is exactly one
172    /// failure mode and nothing to match on — and this crate carries no
173    /// `thiserror` dependency to add one with (version pins are law). The
174    /// caller that decoded the image is the one holding the context worth
175    /// reporting.
176    ///
177    /// **Check order matters.** The dimension cap runs *before* the size
178    /// arithmetic below it, so a hostile `width`/`height` is rejected on a
179    /// cheap comparison rather than reaching the multiply (or, on a caller
180    /// that already allocated `rgba` to match, whatever cost that
181    /// allocation carried) at all.
182    pub fn from_rgba(rgba: Vec<u8>, width: u32, height: u32) -> Option<Self> {
183        if width == 0 || height == 0 || width > MAX_ICON_SIDE || height > MAX_ICON_SIDE {
184            return None;
185        }
186        // Checked, not `u64::from(..) * u64::from(..) * 4`: a u64 product of
187        // two u32s can't overflow, but a *third* factor can — width = height =
188        // 2^31 multiplies out to exactly 2^64, which wraps to 0 and would
189        // validate an empty buffer against a two-billion-pixel image. The
190        // `MAX_ICON_SIDE` cap above already forecloses this in practice, but
191        // the arithmetic stays checked regardless — a size invariant this
192        // load-bearing must not depend on a second, separate check staying in
193        // sync with it.
194        let expected = u64::from(width)
195            .checked_mul(u64::from(height))
196            .and_then(|pixels| pixels.checked_mul(4))?;
197        if rgba.len() as u64 != expected {
198            return None;
199        }
200        Some(Self {
201            rgba,
202            width,
203            height,
204        })
205    }
206
207    /// The tightly-packed RGBA8 pixels (`width * height * 4` bytes).
208    pub fn rgba(&self) -> &[u8] {
209        &self.rgba
210    }
211
212    /// The image width in pixels (never zero, never above `MAX_ICON_SIDE`).
213    pub fn width(&self) -> u32 {
214        self.width
215    }
216
217    /// The image height in pixels (never zero, never above `MAX_ICON_SIDE`).
218    pub fn height(&self) -> u32 {
219        self.height
220    }
221}
222
223impl std::fmt::Debug for IconData {
224    /// Prints `rgba.len()` rather than the buffer itself (see the type's doc
225    /// comment) — the pixel count is all a log line needs to say.
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        f.debug_struct("IconData")
228            .field("rgba_len", &self.rgba.len())
229            .field("width", &self.width)
230            .field("height", &self.height)
231            .finish()
232    }
233}
234
235/// A native menu, as a platform-independent tree: the top-level value is the
236/// menu *bar* (whose items are conventionally [`MenuItemSpec::Submenu`]s), and
237/// the same type describes each submenu below it.
238///
239/// Nothing here is rendered by Frust — a per-OS shell translates it into the
240/// host's own menu API, and reports an activation back through
241/// `frust_reactive::push_menu_event` keyed by the activated item's
242/// [`id`](MenuItemSpec::Item::id).
243#[derive(Debug, Clone, Default, PartialEq, Eq)]
244pub struct MenuSpec {
245    /// The menu's items, in display order.
246    pub items: Vec<MenuItemSpec>,
247}
248
249impl MenuSpec {
250    /// An empty menu.
251    pub fn new() -> Self {
252        Self::default()
253    }
254
255    /// Append one item, builder-style.
256    pub fn with_item(mut self, item: MenuItemSpec) -> Self {
257        self.items.push(item);
258        self
259    }
260
261    /// Whether the menu has no items — a shell installs nothing at all rather
262    /// than an empty menu bar.
263    pub fn is_empty(&self) -> bool {
264        self.items.is_empty()
265    }
266}
267
268/// One entry in a [`MenuSpec`].
269///
270/// Deliberately **not** `#[non_exhaustive]`: a per-OS shell must match it
271/// exhaustively, so a new variant is a compile error in every shell that would
272/// otherwise silently drop the item from the menu it builds.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub enum MenuItemSpec {
275    /// An app-defined item. Activating it pushes [`id`](MenuItemSpec::Item::id)
276    /// through `frust_reactive::push_menu_event`, which app code observes via
277    /// the facade's `menu_events()`.
278    Item {
279        /// The app's own id for this item, echoed back verbatim on activation.
280        id: String,
281        /// The text shown in the menu.
282        label: String,
283        /// An accelerator in the cross-platform `"CmdOrCtrl+Shift+P"` shorthand
284        /// (`Cmd`/`Ctrl`/`Alt`/`Shift` + a key, joined by `+`), or `None` for
285        /// no keyboard shortcut.
286        ///
287        /// Kept a string rather than a parsed chord type because this crate
288        /// binds no menu library: the per-OS shell parses it with whatever its
289        /// menu backend already accepts, and is also where an unparseable
290        /// accelerator is reported — validating it twice, in two vocabularies,
291        /// would only let the two disagree.
292        accelerator: Option<String>,
293        /// Whether the item is selectable. `false` renders it greyed out.
294        enabled: bool,
295    },
296    /// A platform-standard item ([`MenuRole`]) the host implements itself —
297    /// About/Hide/Quit and friends. No activation is reported for these: the
298    /// platform performs the action, so app code has nothing to handle.
299    Role {
300        /// Which standard action this item performs.
301        role: MenuRole,
302        /// An override for the platform's own label, or `None` to use it (the
303        /// normal case — a localized system label beats a hand-written one).
304        label: Option<String>,
305    },
306    /// A separator line.
307    Separator,
308    /// A nested menu.
309    Submenu {
310        /// The text shown for the submenu itself.
311        label: String,
312        /// The submenu's own contents.
313        menu: MenuSpec,
314    },
315}
316
317impl MenuItemSpec {
318    /// An enabled, accelerator-free app item.
319    pub fn item(id: impl Into<String>, label: impl Into<String>) -> Self {
320        Self::Item {
321            id: id.into(),
322            label: label.into(),
323            accelerator: None,
324            enabled: true,
325        }
326    }
327
328    /// A platform-standard item at the platform's own label.
329    pub fn role(role: MenuRole) -> Self {
330        Self::Role { role, label: None }
331    }
332
333    /// A separator line.
334    pub fn separator() -> Self {
335        Self::Separator
336    }
337
338    /// A nested menu under `label`.
339    pub fn submenu(label: impl Into<String>, menu: MenuSpec) -> Self {
340        Self::Submenu {
341            label: label.into(),
342            menu,
343        }
344    }
345
346    /// Attach a keyboard accelerator (see
347    /// [`Item::accelerator`](MenuItemSpec::Item::accelerator)).
348    ///
349    /// Returns the item unchanged for every other variant: a role item's
350    /// shortcut is the platform's own (⌘Q for Quit), and a separator/submenu
351    /// has nothing to activate.
352    pub fn with_accelerator(mut self, accelerator: impl Into<String>) -> Self {
353        if let Self::Item {
354            accelerator: slot, ..
355        } = &mut self
356        {
357            *slot = Some(accelerator.into());
358        }
359        self
360    }
361
362    /// Mark the item greyed out. Returns the item unchanged for every variant
363    /// but [`MenuItemSpec::Item`] (see [`with_accelerator`](Self::with_accelerator)).
364    pub fn disabled(mut self) -> Self {
365        if let Self::Item { enabled, .. } = &mut self {
366            *enabled = false;
367        }
368        self
369    }
370
371    /// Override the label of a [`MenuItemSpec::Role`] item. Returns the item
372    /// unchanged for every other variant (their labels are set at
373    /// construction).
374    pub fn with_label(mut self, label: impl Into<String>) -> Self {
375        if let Self::Role { label: slot, .. } = &mut self {
376            *slot = Some(label.into());
377        }
378        self
379    }
380
381    /// The activation id this item reports, or `None` for an item that reports
382    /// none (a role item, a separator, a submenu).
383    pub fn id(&self) -> Option<&str> {
384        match self {
385            Self::Item { id, .. } => Some(id),
386            Self::Role { .. } | Self::Separator | Self::Submenu { .. } => None,
387        }
388    }
389}
390
391/// A platform-standard menu action the host implements itself.
392///
393/// The set is the one a macOS application menu is expected to carry (the
394/// platform whose menu conventions are strictest); a host that has no notion of
395/// a given role simply omits the item rather than faking it.
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub enum MenuRole {
398    /// Show the standard about panel.
399    About,
400    /// Hide the application.
401    Hide,
402    /// Hide every other application.
403    HideOthers,
404    /// Show every hidden application.
405    ShowAll,
406    /// Minimize the focused window.
407    Minimize,
408    /// Close the focused window (which is a close *request* — see
409    /// `DesktopExtensions::on_close_requested`).
410    CloseWindow,
411    /// Quit the application.
412    Quit,
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    // --- DesktopConfig defaults ---
420
421    #[test]
422    fn the_default_config_reproduces_todays_preview_window() {
423        let config = DesktopConfig::default();
424        // The historical hardcoded title, now the documented fallback.
425        assert_eq!(config.window_title(), "Frust");
426        assert_eq!(config.app_name, None);
427        assert_eq!(config.app_id, None);
428        assert_eq!(config.window_icon, None);
429        assert_eq!(config.menu_spec, None);
430        // A close request quits, exactly as the unconditional
431        // `event_loop.exit()` did before this seam existed.
432        assert!(config.quit_on_last_window_closed);
433        assert_eq!(DesktopConfig::new(), config);
434    }
435
436    #[test]
437    fn a_configured_app_name_becomes_the_window_title() {
438        let config = DesktopConfig::new().with_app_name("Huddle");
439        assert_eq!(config.window_title(), "Huddle");
440        assert_eq!(config.app_name.as_deref(), Some("Huddle"));
441    }
442
443    #[test]
444    fn the_builders_set_each_field_independently() {
445        let icon = IconData::from_rgba(vec![0; 4], 1, 1).expect("1x1 RGBA is valid");
446        let config = DesktopConfig::new()
447            .with_app_name("Huddle")
448            .with_app_id("dev.frust.huddle")
449            .with_window_icon(icon.clone())
450            .with_menu_spec(MenuSpec::new().with_item(MenuItemSpec::role(MenuRole::Quit)))
451            .with_quit_on_last_window_closed(false);
452
453        assert_eq!(config.app_id.as_deref(), Some("dev.frust.huddle"));
454        assert_eq!(config.window_icon, Some(icon));
455        assert_eq!(config.menu_spec.map(|m| m.items.len()), Some(1));
456        assert!(!config.quit_on_last_window_closed);
457    }
458
459    // --- IconData's invariant ---
460
461    #[test]
462    fn icon_data_accepts_exactly_width_times_height_times_four_bytes() {
463        let icon = IconData::from_rgba(vec![7; 2 * 3 * 4], 2, 3).expect("2x3 RGBA is valid");
464        assert_eq!(icon.width(), 2);
465        assert_eq!(icon.height(), 3);
466        assert_eq!(icon.rgba().len(), 24);
467    }
468
469    #[test]
470    fn icon_data_rejects_a_buffer_that_does_not_match_its_dimensions() {
471        // One byte short of a 2x2 image — the case that reaches a platform API
472        // as a buffer overrun rather than a wrong picture.
473        assert_eq!(IconData::from_rgba(vec![0; 15], 2, 2), None);
474        assert_eq!(IconData::from_rgba(vec![0; 17], 2, 2), None);
475    }
476
477    #[test]
478    fn icon_data_rejects_a_zero_dimension() {
479        assert_eq!(IconData::from_rgba(Vec::new(), 0, 4), None);
480        assert_eq!(IconData::from_rgba(Vec::new(), 4, 0), None);
481    }
482
483    #[test]
484    fn icon_data_rejects_a_size_product_that_overflows_u64() {
485        // width = height = 2^31: the naive `u64::from(w) * u64::from(h) * 4`
486        // multiplies out to exactly 2^64, which wraps to 0 and would validate
487        // an empty buffer against a two-billion-pixel image. The dimension cap
488        // rejects this long before the multiply would even run, but the
489        // checked arithmetic is what actually closes the overflow — assert
490        // `None`, not just "doesn't panic": a debug build already panics on
491        // unchecked overflow, so the real regression this guards is a release
492        // build silently wrapping to a validated `Some`.
493        assert_eq!(IconData::from_rgba(Vec::new(), 1 << 31, 1 << 31), None);
494    }
495
496    #[test]
497    fn icon_data_rejects_a_dimension_just_over_the_cap() {
498        assert_eq!(IconData::from_rgba(Vec::new(), MAX_ICON_SIDE + 1, 1), None);
499        assert_eq!(IconData::from_rgba(Vec::new(), 1, MAX_ICON_SIDE + 1), None);
500    }
501
502    #[test]
503    fn icon_data_accepts_the_max_allowed_dimension() {
504        // A real `MAX_ICON_SIDE`-square buffer (67MB) is wasteful to allocate
505        // just to prove the cap's boundary is inclusive — a `MAX_ICON_SIDE ×
506        // 1` strip already exercises the same `width == MAX_ICON_SIDE` cap
507        // comparison, at a 16KB buffer instead.
508        let icon = IconData::from_rgba(vec![0; MAX_ICON_SIDE as usize * 4], MAX_ICON_SIDE, 1)
509            .expect("MAX_ICON_SIDE is inclusive, not an exclusive bound");
510        assert_eq!(icon.width(), MAX_ICON_SIDE);
511        assert_eq!(icon.height(), 1);
512    }
513
514    // --- MenuSpec construction ---
515
516    #[test]
517    fn a_fresh_menu_spec_is_empty() {
518        let menu = MenuSpec::new();
519        assert!(menu.is_empty());
520        assert_eq!(menu, MenuSpec::default());
521    }
522
523    #[test]
524    fn menu_items_build_the_full_item_vocabulary() {
525        let file = MenuSpec::new()
526            .with_item(MenuItemSpec::item("file.open", "Open…").with_accelerator("CmdOrCtrl+O"))
527            .with_item(MenuItemSpec::item("file.export", "Export").disabled())
528            .with_item(MenuItemSpec::separator())
529            .with_item(MenuItemSpec::role(MenuRole::Quit));
530        let bar = MenuSpec::new().with_item(MenuItemSpec::submenu("File", file.clone()));
531
532        assert!(!bar.is_empty());
533        assert_eq!(
534            bar.items,
535            vec![MenuItemSpec::Submenu {
536                label: "File".to_string(),
537                menu: file,
538            }]
539        );
540    }
541
542    #[test]
543    fn an_app_item_carries_its_id_accelerator_and_enabled_state() {
544        let item = MenuItemSpec::item("file.open", "Open…").with_accelerator("CmdOrCtrl+O");
545        assert_eq!(
546            item,
547            MenuItemSpec::Item {
548                id: "file.open".to_string(),
549                label: "Open…".to_string(),
550                accelerator: Some("CmdOrCtrl+O".to_string()),
551                enabled: true,
552            }
553        );
554        assert_eq!(item.id(), Some("file.open"));
555        assert!(matches!(
556            item.disabled(),
557            MenuItemSpec::Item { enabled: false, .. }
558        ));
559    }
560
561    #[test]
562    fn only_app_items_report_an_activation_id() {
563        // The shell pushes `push_menu_event(id)` for exactly these: a role item
564        // is performed by the platform and a separator/submenu activates
565        // nothing.
566        assert_eq!(MenuItemSpec::role(MenuRole::About).id(), None);
567        assert_eq!(MenuItemSpec::separator().id(), None);
568        assert_eq!(MenuItemSpec::submenu("File", MenuSpec::new()).id(), None);
569    }
570
571    #[test]
572    fn the_item_builders_leave_the_wrong_variant_untouched() {
573        // Documented no-ops (see each builder's doc comment), pinned so a
574        // future edit can't silently start mutating a role/separator instead.
575        let role = MenuItemSpec::role(MenuRole::Quit);
576        assert_eq!(role.clone().with_accelerator("CmdOrCtrl+Q"), role);
577        assert_eq!(role.clone().disabled(), role);
578        assert_eq!(MenuItemSpec::separator().with_label("nope"), {
579            MenuItemSpec::Separator
580        });
581    }
582
583    #[test]
584    fn a_role_item_can_override_the_platform_label() {
585        assert_eq!(
586            MenuItemSpec::role(MenuRole::About).with_label("About Huddle"),
587            MenuItemSpec::Role {
588                role: MenuRole::About,
589                label: Some("About Huddle".to_string()),
590            }
591        );
592    }
593}