1use 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#[derive(Clone, Debug, Deserialize, Serialize)]
13pub struct WindowOptions {
14 #[serde(default = "default_title")]
16 pub title: String,
17 #[serde(default = "default_width")]
19 pub width: u32,
20 #[serde(default = "default_height")]
22 pub height: u32,
23 #[serde(default, alias = "minWidth")]
25 pub min_width: Option<u32>,
26 #[serde(default, alias = "minHeight")]
28 pub min_height: Option<u32>,
29 #[serde(default, alias = "maxWidth")]
31 pub max_width: Option<u32>,
32 #[serde(default, alias = "maxHeight")]
34 pub max_height: Option<u32>,
35 #[serde(default = "default_resizable")]
37 pub resizable: bool,
38 #[serde(default)]
40 pub maximized: bool,
41 #[serde(default)]
43 pub fullscreen: bool,
44 #[serde(default, alias = "alwaysOnTop")]
46 pub always_on_top: bool,
47 #[serde(default = "default_center")]
49 pub center: bool,
50 #[serde(default)]
52 pub background: Option<Rgba>,
53 #[serde(default)]
55 pub titlebar: TitlebarStyle,
56 #[serde(
59 default,
60 alias = "captionButtonSize",
61 skip_serializing_if = "CaptionButtonSize::is_standard"
62 )]
63 pub caption_button_size: CaptionButtonSize,
64 #[serde(default)]
66 pub effect: WindowEffect,
67 #[serde(default, alias = "rememberState")]
69 pub remember_state: bool,
70 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
118pub struct Rgba {
119 pub r: u8,
121 pub g: u8,
123 pub b: u8,
125 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#[derive(Debug, Error)]
164pub enum RgbaParseError {
165 #[error("invalid RGBA color '{value}': expected #rrggbb or #rrggbbaa; help: use lowercase or uppercase hexadecimal digits")]
167 InvalidLength {
168 value: String,
170 },
171 #[error("invalid RGBA color '{value}': component '{component}' is not hexadecimal; help: use digits 0-9 and letters a-f")]
173 InvalidHex {
174 value: String,
176 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#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
226#[serde(rename_all = "kebab-case")]
227pub enum CaptionButtonSize {
228 #[default]
230 Standard,
231 Tall,
233}
234
235impl CaptionButtonSize {
236 const fn is_standard(&self) -> bool {
237 matches!(self, Self::Standard)
238 }
239}
240
241#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
243#[serde(rename_all = "kebab-case", tag = "style")]
244pub enum TitlebarStyle {
245 #[default]
247 Native,
248 HiddenInset,
250 Overlay {
252 height: u32,
256 },
257 None,
259}
260
261#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
263#[serde(rename_all = "kebab-case")]
264pub enum WindowEffect {
265 #[default]
267 None,
268 Vibrancy,
270 Acrylic,
272 Mica,
274 Tabbed,
276}
277
278#[derive(Clone, Copy, Debug, Eq, PartialEq)]
280pub enum DesktopPlatform {
281 Macos,
283 Windows,
285 Linux,
287}
288impl DesktopPlatform {
289 #[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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
313pub struct WindowInsets {
314 pub start: u32,
316 pub end: u32,
318 pub top: u32,
320}
321impl WindowInsets {
322 #[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
357const 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#[must_use]
377pub fn window_css_block(window: &WindowOptions, platform: DesktopPlatform) -> String {
378 window_css_block_inner(window, platform, None)
379}
380
381#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)]
383pub enum WindowCssNonceError {
384 #[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
389pub 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#[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 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 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}