Skip to main content

webui_desktop/
window.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4use std::fmt::{Display, Formatter, Write};
5use std::str::FromStr;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use thiserror::Error;
10
11/// Cross-platform desktop window configuration stored in a bundle manifest.
12#[derive(Clone, Debug, Deserialize, Serialize)]
13pub struct WindowOptions {
14    /// Window title.
15    #[serde(default = "default_title")]
16    pub title: String,
17    /// Initial window width in physical-independent pixels.
18    #[serde(default = "default_width")]
19    pub width: u32,
20    /// Initial window height in physical-independent pixels.
21    #[serde(default = "default_height")]
22    pub height: u32,
23    /// Minimum width, when supported by the native backend.
24    #[serde(default, alias = "minWidth")]
25    pub min_width: Option<u32>,
26    /// Minimum height, when supported by the native backend.
27    #[serde(default, alias = "minHeight")]
28    pub min_height: Option<u32>,
29    /// Maximum width, when supported by the native backend.
30    #[serde(default, alias = "maxWidth")]
31    pub max_width: Option<u32>,
32    /// Maximum height, when supported by the native backend.
33    #[serde(default, alias = "maxHeight")]
34    pub max_height: Option<u32>,
35    /// Whether the user can resize the window.
36    #[serde(default = "default_resizable")]
37    pub resizable: bool,
38    /// Whether to start maximized.
39    #[serde(default)]
40    pub maximized: bool,
41    /// Whether to start fullscreen.
42    #[serde(default)]
43    pub fullscreen: bool,
44    /// Whether to keep the window above ordinary windows.
45    #[serde(default, alias = "alwaysOnTop")]
46    pub always_on_top: bool,
47    /// Whether the native backend should center the initial window.
48    #[serde(default = "default_center")]
49    pub center: bool,
50    /// Native pre-paint color and default document background.
51    #[serde(default)]
52    pub background: Option<Rgba>,
53    /// Native titlebar presentation.
54    #[serde(default)]
55    pub titlebar: TitlebarStyle,
56    /// Windows caption button size for overlay and hidden-inset titlebars.
57    /// Defaults to 32 DIPs independently of the application bar height.
58    #[serde(
59        default,
60        alias = "captionButtonSize",
61        skip_serializing_if = "CaptionButtonSize::is_standard"
62    )]
63    pub caption_button_size: CaptionButtonSize,
64    /// Requested platform visual effect.
65    #[serde(default)]
66    pub effect: WindowEffect,
67    /// Whether to persist validated window geometry between launches.
68    #[serde(default, alias = "rememberState")]
69    pub remember_state: bool,
70    /// Whether to enable web inspector/devtools for development builds.
71    #[serde(default)]
72    pub devtools: bool,
73}
74
75const fn default_width() -> u32 {
76    1200
77}
78const fn default_height() -> u32 {
79    800
80}
81const fn default_resizable() -> bool {
82    true
83}
84const fn default_center() -> bool {
85    true
86}
87fn default_title() -> String {
88    "WebUI".to_string()
89}
90
91impl Default for WindowOptions {
92    fn default() -> Self {
93        Self {
94            title: default_title(),
95            width: default_width(),
96            height: default_height(),
97            min_width: None,
98            min_height: None,
99            max_width: None,
100            max_height: None,
101            resizable: true,
102            maximized: false,
103            fullscreen: false,
104            always_on_top: false,
105            center: true,
106            background: None,
107            titlebar: TitlebarStyle::default(),
108            caption_button_size: CaptionButtonSize::default(),
109            effect: WindowEffect::default(),
110            remember_state: false,
111            devtools: false,
112        }
113    }
114}
115
116/// An RGBA color used for the native and document window background.
117#[derive(Clone, Copy, Debug, Eq, PartialEq)]
118pub struct Rgba {
119    /// Red component.
120    pub r: u8,
121    /// Green component.
122    pub g: u8,
123    /// Blue component.
124    pub b: u8,
125    /// Alpha component.
126    pub a: u8,
127}
128
129impl Display for Rgba {
130    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
131        if self.a == u8::MAX {
132            write!(formatter, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
133        } else {
134            write!(
135                formatter,
136                "#{:02x}{:02x}{:02x}{:02x}",
137                self.r, self.g, self.b, self.a
138            )
139        }
140    }
141}
142
143#[derive(Default)]
144pub(crate) struct LiveBackground(AtomicU64);
145
146impl LiveBackground {
147    pub(crate) fn set(&self, color: Rgba) {
148        let encoded = u64::from_be_bytes([0, 0, 0, 1, color.r, color.g, color.b, color.a]);
149        self.0.store(encoded, Ordering::Release);
150    }
151
152    pub(crate) fn current(&self) -> Option<Rgba> {
153        let encoded = self.0.load(Ordering::Acquire);
154        if encoded == 0 {
155            return None;
156        }
157        let [_, _, _, _, r, g, b, a] = encoded.to_be_bytes();
158        Some(Rgba { r, g, b, a })
159    }
160}
161
162/// Error returned when a manifest color is malformed.
163#[derive(Debug, Error)]
164pub enum RgbaParseError {
165    /// The color did not have a supported number of hex digits.
166    #[error("invalid RGBA color '{value}': expected #rrggbb or #rrggbbaa; help: use lowercase or uppercase hexadecimal digits")]
167    InvalidLength {
168        /// Rejected color text.
169        value: String,
170    },
171    /// The color contains a non-hexadecimal character.
172    #[error("invalid RGBA color '{value}': component '{component}' is not hexadecimal; help: use digits 0-9 and letters a-f")]
173    InvalidHex {
174        /// Rejected color text.
175        value: String,
176        /// Rejected component.
177        component: char,
178    },
179}
180
181impl FromStr for Rgba {
182    type Err = RgbaParseError;
183    fn from_str(value: &str) -> Result<Self, Self::Err> {
184        let bytes = value.as_bytes();
185        if !(bytes.len() == 7 || bytes.len() == 9) || bytes.first() != Some(&b'#') {
186            return Err(RgbaParseError::InvalidLength {
187                value: value.to_string(),
188            });
189        }
190        let byte = |offset: usize| -> Result<u8, RgbaParseError> {
191            let nibble = |byte: u8| match byte {
192                b'0'..=b'9' => Ok(byte - b'0'),
193                b'a'..=b'f' => Ok(byte - b'a' + 10),
194                b'A'..=b'F' => Ok(byte - b'A' + 10),
195                other => Err(RgbaParseError::InvalidHex {
196                    value: value.to_string(),
197                    component: char::from(other),
198                }),
199            };
200            Ok((nibble(bytes[offset])? << 4) | nibble(bytes[offset + 1])?)
201        };
202        Ok(Self {
203            r: byte(1)?,
204            g: byte(3)?,
205            b: byte(5)?,
206            a: if bytes.len() == 9 { byte(7)? } else { u8::MAX },
207        })
208    }
209}
210
211impl Serialize for Rgba {
212    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
213        serializer.collect_str(self)
214    }
215}
216impl<'de> Deserialize<'de> for Rgba {
217    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
218        String::deserialize(deserializer)?
219            .parse()
220            .map_err(serde::de::Error::custom)
221    }
222}
223
224/// Size of native Windows caption buttons in an application-drawn titlebar.
225#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
226#[serde(rename_all = "kebab-case")]
227pub enum CaptionButtonSize {
228    /// Standard 32-DIP caption buttons (the default).
229    #[default]
230    Standard,
231    /// Tall 48-DIP caption buttons.
232    Tall,
233}
234
235impl CaptionButtonSize {
236    const fn is_standard(&self) -> bool {
237        matches!(self, Self::Standard)
238    }
239}
240
241/// Native titlebar presentation requested by a window.
242#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
243#[serde(rename_all = "kebab-case", tag = "style")]
244pub enum TitlebarStyle {
245    /// Platform-native titlebar, matching prior WebUI behavior.
246    #[default]
247    Native,
248    /// Native controls float over full-bleed application content.
249    HiddenInset,
250    /// System caption buttons with an application-drawn bar.
251    Overlay {
252        /// Height of the overlay titlebar in CSS pixels.
253        /// The application band reserves at least this height; Windows caption
254        /// buttons are sized independently through [`WindowOptions::caption_button_size`].
255        height: u32,
256    },
257    /// Fully frameless window. The application draws all chrome.
258    None,
259}
260
261/// Platform visual effect requested by a window.
262#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
263#[serde(rename_all = "kebab-case")]
264pub enum WindowEffect {
265    /// No platform effect.
266    #[default]
267    None,
268    /// macOS vibrancy or the closest native equivalent.
269    Vibrancy,
270    /// Windows acrylic or closest equivalent.
271    Acrylic,
272    /// Windows mica or closest equivalent.
273    Mica,
274    /// Tabbed titlebar treatment where supported.
275    Tabbed,
276}
277
278/// Native platform used to calculate titlebar safe areas.
279#[derive(Clone, Copy, Debug, Eq, PartialEq)]
280pub enum DesktopPlatform {
281    /// macOS.
282    Macos,
283    /// Microsoft Windows.
284    Windows,
285    /// Linux desktop environments.
286    Linux,
287}
288impl DesktopPlatform {
289    /// Return the compilation target platform.
290    #[must_use]
291    pub const fn current() -> Self {
292        #[cfg(target_os = "macos")]
293        {
294            Self::Macos
295        }
296        #[cfg(target_os = "windows")]
297        {
298            Self::Windows
299        }
300        #[cfg(target_os = "linux")]
301        {
302            Self::Linux
303        }
304        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
305        {
306            Self::Linux
307        }
308    }
309}
310
311/// Safe-area insets reserved for native window controls.
312#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
313pub struct WindowInsets {
314    /// Leading safe inset in CSS pixels.
315    pub start: u32,
316    /// Trailing safe inset in CSS pixels.
317    pub end: u32,
318    /// Top safe inset in CSS pixels.
319    pub top: u32,
320}
321impl WindowInsets {
322    /// Calculate native control insets for a style and platform.
323    #[must_use]
324    pub const fn for_style(style: &TitlebarStyle, platform: DesktopPlatform) -> Self {
325        let height = match style {
326            TitlebarStyle::Overlay { height } => *height,
327            TitlebarStyle::HiddenInset => 28,
328            TitlebarStyle::Native | TitlebarStyle::None => 0,
329        };
330        match style {
331            TitlebarStyle::HiddenInset | TitlebarStyle::Overlay { .. } => match platform {
332                DesktopPlatform::Macos => Self {
333                    start: 78,
334                    end: 0,
335                    top: height,
336                },
337                DesktopPlatform::Windows => Self {
338                    start: 0,
339                    end: 138,
340                    top: height,
341                },
342                DesktopPlatform::Linux => Self {
343                    start: 0,
344                    end: 0,
345                    top: height,
346                },
347            },
348            TitlebarStyle::Native | TitlebarStyle::None => Self {
349                start: 0,
350                end: 0,
351                top: 0,
352            },
353        }
354    }
355}
356
357/// Baseline styling every `[webui-drag]` region needs to behave like native
358/// window chrome.
359///
360/// Without `user-select: none`, double-clicking a drag region selects the
361/// title text before the drag script turns the gesture into a maximize toggle,
362/// which no native or Electron titlebar does. `cursor: default` suppresses the
363/// text I-beam over what is really window chrome. `[webui-no-drag]` opts
364/// subtrees back in so inputs and selectable text embedded in a custom
365/// titlebar keep working.
366const DRAG_REGION_CSS: &str =
367    "[webui-drag]{-webkit-user-select:none;user-select:none;cursor:default}\
368[webui-no-drag]{-webkit-user-select:auto;user-select:auto;cursor:auto}";
369
370/// Build the constant `<style>` block carrying window CSS custom properties.
371///
372/// The block depends only on window configuration and the target platform, so
373/// hosts compute it once at startup and reuse it for every rendered document.
374/// Always contains the drag-region rules, because backends inject
375/// [`crate::DRAG_REGION_SCRIPT`] regardless of titlebar style.
376#[must_use]
377pub fn window_css_block(window: &WindowOptions, platform: DesktopPlatform) -> String {
378    window_css_block_inner(window, platform, None)
379}
380
381/// Error when a host-supplied CSP nonce cannot be placed in a style attribute.
382#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)]
383pub enum WindowCssNonceError {
384    /// The nonce is empty, too long, or not a Base64/Base64URL token.
385    #[error("invalid CSP style nonce; help: supply a Base64 or Base64URL token of at most 128 ASCII characters from the current response")]
386    Invalid,
387}
388
389/// Build a trusted window style block for a response with nonce-only `style-src`.
390///
391/// The HTTP host generates and authenticates the per-response nonce; this
392/// helper only validates its attribute-safe syntax. It does not modify a
393/// response, disable CSP, or grant renderer authority.
394///
395/// # Errors
396///
397/// Rejects an empty, oversized, or non-Base64 nonce.
398pub fn window_css_block_with_nonce(
399    window: &WindowOptions,
400    platform: DesktopPlatform,
401    nonce: &str,
402) -> Result<String, WindowCssNonceError> {
403    let bytes = nonce.as_bytes();
404    let data_len = bytes
405        .iter()
406        .position(|byte| *byte == b'=')
407        .unwrap_or(bytes.len());
408    if data_len == 0
409        || bytes.len() > 128
410        || data_len % 4 == 1
411        || bytes.len() - data_len > 2
412        || (data_len != bytes.len() && !bytes.len().is_multiple_of(4))
413        || !bytes[..data_len]
414            .iter()
415            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'-' | b'_'))
416        || bytes[data_len..].iter().any(|byte| *byte != b'=')
417    {
418        return Err(WindowCssNonceError::Invalid);
419    }
420    Ok(window_css_block_inner(window, platform, Some(nonce)))
421}
422
423fn window_css_block_inner(
424    window: &WindowOptions,
425    platform: DesktopPlatform,
426    nonce: Option<&str>,
427) -> String {
428    let insets = WindowInsets::for_style(&window.titlebar, platform);
429    let mut style = String::with_capacity(320 + nonce.map_or(0, str::len));
430    if let Some(nonce) = nonce {
431        style.push_str("<style nonce=\"");
432        style.push_str(nonce);
433        style.push_str("\">");
434    } else {
435        style.push_str("<style>");
436    }
437    style.push_str(DRAG_REGION_CSS);
438    if matches!(window.titlebar, TitlebarStyle::Native) && window.background.is_none() {
439        style.push_str("</style>");
440        return style;
441    }
442    style.push_str(":root{--webui-titlebar-inset-start:");
443    let _ = write!(
444        style,
445        "{}px;--webui-titlebar-inset-end:{}px;--webui-titlebar-height:{}px",
446        insets.start, insets.end, insets.top
447    );
448    if let Some(background) = window.background {
449        style.push_str(";--webui-window-background:");
450        let _ = write!(style, "{background}");
451    }
452    style.push('}');
453    if window.background.is_some() {
454        style.push_str("html{background:var(--webui-window-background)}");
455    }
456    style.push_str("</style>");
457    style
458}
459
460pub(crate) fn live_background_css(color: Rgba) -> String {
461    let mut style = String::with_capacity(106);
462    append_live_background_css(&mut style, color);
463    style
464}
465
466pub(crate) fn append_live_background_css(style: &mut String, color: Rgba) {
467    style.push_str("<style>:root{--webui-window-background:");
468    let _ = write!(style, "{color}");
469    style.push_str("}html{background:var(--webui-window-background)}</style>");
470}
471
472#[cfg(any(test, feature = "native"))]
473pub(crate) fn live_background_script(color: Rgba) -> String {
474    let mut script = String::with_capacity(155);
475    script.push_str("(()=>{const root=document.documentElement;if(root){root.style.setProperty('--webui-window-background','");
476    let _ = write!(script, "{color}");
477    script.push_str("');root.style.backgroundColor='var(--webui-window-background)'}})()");
478    script
479}
480
481/// Splice a precomputed window CSS block into rendered HTML in one linear scan.
482///
483/// The block is placed inside `<head>` when present. Documents without a head
484/// fall back to the start of `<body>`, and finally to the end of the document,
485/// because injecting ahead of the doctype would force quirks mode.
486#[must_use]
487pub fn apply_window_css(html: String, block: &str) -> String {
488    if block.is_empty() {
489        return html;
490    }
491    apply_window_css_ref(&html, block)
492}
493
494pub(crate) fn apply_window_css_ref(html: &str, block: &str) -> String {
495    let position = html
496        .find("</head>")
497        .or_else(|| html.find("<body"))
498        .unwrap_or(html.len());
499    let mut output = String::with_capacity(html.len() + block.len());
500    output.push_str(&html[..position]);
501    output.push_str(block);
502    output.push_str(&html[position..]);
503    output
504}
505
506#[cfg(test)]
507#[allow(clippy::disallowed_methods)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn live_background_preserves_transparency_and_updates_document_css() {
513        let background = LiveBackground::default();
514        assert_eq!(background.current(), None);
515        let transparent = "#00000000".parse().unwrap();
516        background.set(transparent);
517        assert_eq!(background.current(), Some(transparent));
518        let white = "#ffffffff".parse().unwrap();
519        background.set(white);
520        assert_eq!(background.current(), Some(white));
521
522        assert_eq!(
523            live_background_css(white),
524            "<style>:root{--webui-window-background:#ffffff}html{background:var(--webui-window-background)}</style>"
525        );
526        assert_eq!(
527            live_background_script(white),
528            "(()=>{const root=document.documentElement;if(root){root.style.setProperty('--webui-window-background','#ffffff');root.style.backgroundColor='var(--webui-window-background)'}})()"
529        );
530    }
531
532    #[test]
533    fn parses_rgba() {
534        assert_eq!(
535            "#1234ab80".parse::<Rgba>().unwrap(),
536            Rgba {
537                r: 0x12,
538                g: 0x34,
539                b: 0xab,
540                a: 0x80
541            }
542        );
543        assert_eq!("#1234ab".parse::<Rgba>().unwrap().a, 255);
544    }
545    #[test]
546    fn rejects_bad_rgba_actionably() {
547        assert!("#12"
548            .parse::<Rgba>()
549            .unwrap_err()
550            .to_string()
551            .contains("#rrggbb"));
552        assert!("#zzzzzz"
553            .parse::<Rgba>()
554            .unwrap_err()
555            .to_string()
556            .contains("not hexadecimal"));
557    }
558    #[test]
559    fn calculates_platform_insets() {
560        let style = TitlebarStyle::HiddenInset;
561        assert_eq!(
562            WindowInsets::for_style(&style, DesktopPlatform::Macos).start,
563            78
564        );
565        assert_eq!(
566            WindowInsets::for_style(&style, DesktopPlatform::Windows).end,
567            138
568        );
569        assert_eq!(
570            WindowInsets::for_style(&style, DesktopPlatform::Linux).top,
571            28
572        );
573    }
574    #[test]
575    fn injects_window_css() {
576        let options = WindowOptions {
577            titlebar: TitlebarStyle::HiddenInset,
578            background: Some("#112233".parse().unwrap()),
579            ..WindowOptions::default()
580        };
581        let block = window_css_block(&options, DesktopPlatform::Macos);
582        let html = apply_window_css(
583            "<html><head></head><body></body></html>".to_string(),
584            &block,
585        );
586        assert!(html.contains("--webui-titlebar-inset-start:78px"));
587        assert!(html.contains("html{background"));
588        assert!(html.find("<style>") < html.find("</head>"));
589    }
590
591    #[test]
592    fn native_titlebar_without_background_injects_only_drag_region_rules() {
593        let block = window_css_block(&WindowOptions::default(), DesktopPlatform::Macos);
594        // Backends inject `DRAG_REGION_SCRIPT` for every titlebar style, so the
595        // matching CSS must ship too; only the inset variables are omitted.
596        assert!(block.contains("[webui-drag]"));
597        assert!(!block.contains("--webui-titlebar-inset-start"));
598        assert!(!block.contains("--webui-window-background"));
599    }
600
601    #[test]
602    fn window_css_nonce_is_per_response_and_attribute_safe() {
603        let window = WindowOptions {
604            titlebar: TitlebarStyle::Overlay { height: 64 },
605            ..WindowOptions::default()
606        };
607        let css =
608            window_css_block_with_nonce(&window, DesktopPlatform::Windows, "aB02-_/+").unwrap();
609        assert!(css.starts_with("<style nonce=\"aB02-_/+\">"));
610        assert!(css.contains("--webui-titlebar-inset-end:138px"));
611        assert_eq!(
612            css.replacen("<style nonce=\"aB02-_/+\">", "<style>", 1),
613            window_css_block(&window, DesktopPlatform::Windows)
614        );
615        for invalid in [
616            "",
617            "a",
618            "abc=def",
619            "abcd===",
620            "====",
621            "bad value",
622            "bad\" onload=\"x",
623            "a".repeat(129).as_str(),
624        ] {
625            assert_eq!(
626                window_css_block_with_nonce(&window, DesktopPlatform::Windows, invalid),
627                Err(WindowCssNonceError::Invalid)
628            );
629        }
630    }
631
632    #[test]
633    fn drag_regions_suppress_selection_and_no_drag_restores_it() {
634        let options = WindowOptions {
635            titlebar: TitlebarStyle::HiddenInset,
636            ..WindowOptions::default()
637        };
638        let block = window_css_block(&options, DesktopPlatform::Macos);
639        // A double-click on a drag region must toggle maximize, not select the
640        // title text, so selection is suppressed on the region itself and
641        // restored on opted-out subtrees such as inputs.
642        assert!(block.contains("[webui-drag]{-webkit-user-select:none;user-select:none"));
643        assert!(block.contains("[webui-no-drag]{-webkit-user-select:auto;user-select:auto"));
644    }
645
646    #[test]
647    fn headless_documents_never_inject_ahead_of_the_doctype() {
648        let options = WindowOptions {
649            titlebar: TitlebarStyle::HiddenInset,
650            ..WindowOptions::default()
651        };
652        let block = window_css_block(&options, DesktopPlatform::Macos);
653
654        let with_body =
655            apply_window_css("<!doctype html><body><p>hi</p></body>".to_string(), &block);
656        assert!(with_body.starts_with("<!doctype html>"));
657        assert!(with_body.find("<style>") < with_body.find("<body"));
658
659        let fragment = apply_window_css("<!doctype html><p>hi</p>".to_string(), &block);
660        assert!(fragment.starts_with("<!doctype html>"));
661        assert!(fragment.ends_with("</style>"));
662    }
663    #[test]
664    fn legacy_window_manifest_defaults_new_fields() {
665        let options: WindowOptions = serde_json::from_str(
666            r#"{"title":"Legacy","width":640,"height":480,"maximized":false,"devtools":true}"#,
667        )
668        .unwrap();
669        assert!(options.resizable);
670        assert!(options.center);
671        assert_eq!(options.titlebar, TitlebarStyle::Native);
672        assert_eq!(options.caption_button_size, CaptionButtonSize::Standard);
673        assert_eq!(options.effect, WindowEffect::None);
674    }
675
676    #[test]
677    fn caption_button_size_deserializes_app_config_and_preserves_manifest_format() {
678        let options: WindowOptions = serde_json::from_str(
679            r#"{"titlebar":{"style":"overlay","height":48},"captionButtonSize":"tall"}"#,
680        )
681        .unwrap();
682        assert_eq!(options.titlebar, TitlebarStyle::Overlay { height: 48 });
683        assert_eq!(options.caption_button_size, CaptionButtonSize::Tall);
684        let manifest = serde_json::to_value(&options).unwrap();
685        assert_eq!(manifest["caption_button_size"], "tall");
686        assert!(manifest.get("captionButtonSize").is_none());
687        assert!(serde_json::to_value(WindowOptions::default())
688            .unwrap()
689            .get("caption_button_size")
690            .is_none());
691    }
692
693    #[test]
694    fn app_config_camel_case_window_settings_are_not_discarded() {
695        let options: WindowOptions = serde_json::from_str(
696            r#"{"minWidth":480,"minHeight":320,"maxWidth":1600,"maxHeight":1000,"alwaysOnTop":true,"rememberState":true}"#,
697        )
698        .unwrap();
699        assert_eq!(options.min_width, Some(480));
700        assert_eq!(options.min_height, Some(320));
701        assert_eq!(options.max_width, Some(1600));
702        assert_eq!(options.max_height, Some(1000));
703        assert!(options.always_on_top);
704        assert!(options.remember_state);
705        let manifest = serde_json::to_value(&options).unwrap();
706        assert_eq!(manifest["min_width"], 480);
707        assert_eq!(manifest["remember_state"], true);
708    }
709}