#[cfg(feature = "application-ipc")]
use std::net::TcpListener;
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, Weak};
#[cfg(all(
feature = "application-ipc",
any(target_os = "macos", target_os = "windows", target_os = "linux")
))]
use crate::ipc::{IpcBridge, IpcWindow};
#[cfg(feature = "application-ipc")]
use crate::ipc::{IpcHost, IpcOptions, IpcRegistry, IpcWindowOwner};
use crate::{
DesktopError, DesktopEvent, DesktopShellConfig, EventRegistrationError, EventRegistry,
EventResponse, EventSubscription, Result, WindowHandle, WindowOptions,
};
mod listener;
pub use listener::bind_owned_local_server;
#[cfg(feature = "application-ipc")]
mod owned_ipc;
#[cfg(feature = "native-url-activation")]
pub(crate) mod url_activation;
#[cfg(feature = "application-ipc")]
pub(crate) use owned_ipc::OwnedLocalServerIpc;
#[cfg(all(feature = "native-url-activation", target_os = "macos"))]
use url_activation::ActivationSender;
#[cfg(feature = "native-url-activation")]
pub use url_activation::{
UrlActivation, UrlActivationRegistrationError, MAX_URL_ACTIVATIONS_PER_BATCH,
MAX_URL_ACTIVATION_BYTES,
};
#[cfg(all(
feature = "application-ipc",
any(target_os = "macos", target_os = "windows", target_os = "linux")
))]
pub const LOCAL_IPC_RUNTIME_PATH: &str = "/_webui/ipc/local-runtime.js";
#[cfg(all(
feature = "application-ipc",
any(target_os = "macos", target_os = "windows", target_os = "linux")
))]
#[must_use]
pub fn local_ipc_runtime_asset() -> &'static [u8] {
crate::ipc_assets::LOCAL_BROWSER_RUNTIME
}
#[derive(Clone)]
enum CloseCallback {
#[cfg(any(target_os = "macos", test))]
Native(Arc<dyn Fn() + Send + Sync>),
#[cfg(any(target_os = "windows", target_os = "linux", test))]
Fallible(Arc<dyn Fn() -> std::result::Result<(), HostCloseError> + Send + Sync>),
}
impl CloseCallback {
fn same(&self, other: &Self) -> bool {
match (self, other) {
#[cfg(any(target_os = "macos", test))]
(Self::Native(left), Self::Native(right)) => Arc::ptr_eq(left, right),
#[cfg(any(target_os = "windows", target_os = "linux", test))]
(Self::Fallible(left), Self::Fallible(right)) => Arc::ptr_eq(left, right),
#[cfg(test)]
_ => false,
}
}
fn dispatch(&self) -> std::result::Result<(), HostCloseError> {
match self {
#[cfg(any(target_os = "macos", test))]
Self::Native(callback) => {
callback();
Ok(())
}
#[cfg(any(target_os = "windows", target_os = "linux", test))]
Self::Fallible(callback) => callback(),
}
}
}
#[derive(Clone, Debug, thiserror::Error)]
pub enum HostCloseError {
#[error("host lifetime is still active; help: call revoke() before retry_close()")]
StillActive,
#[error("native close wake failed: {message}; help: keep the listener bound and retry_close() until WindowClosed")]
WakeFailed {
message: String,
},
}
struct HostCloseState {
callback: Option<CloseCallback>,
dispatching: bool,
result: Option<std::result::Result<(), HostCloseError>>,
}
struct HostLifetimeInner {
active: AtomicBool,
close: Mutex<HostCloseState>,
close_completed: Condvar,
}
#[derive(Clone)]
pub struct HostLifetime(Weak<HostLifetimeInner>);
#[must_use = "retain the owner until the verified server or attached daemon disconnects"]
pub struct HostLifetimeOwner(Arc<HostLifetimeInner>);
pub(crate) struct HostCloseRegistration {
lifetime: HostLifetime,
callback: CloseCallback,
}
impl Drop for HostCloseRegistration {
fn drop(&mut self) {
if let Some(inner) = self.lifetime.0.upgrade() {
let mut close = inner
.close
.lock()
.unwrap_or_else(|error| error.into_inner());
if close
.callback
.as_ref()
.is_some_and(|current| current.same(&self.callback))
{
close.callback.take();
}
}
}
}
impl HostLifetime {
#[must_use = "retain the returned owner until its local-server window closes"]
pub fn new() -> (HostLifetimeOwner, Self) {
let inner = Arc::new(HostLifetimeInner {
active: AtomicBool::new(true),
close: Mutex::new(HostCloseState {
callback: None,
dispatching: false,
result: None,
}),
close_completed: Condvar::new(),
});
(
HostLifetimeOwner(Arc::clone(&inner)),
Self(Arc::downgrade(&inner)),
)
}
#[must_use]
pub fn is_active(&self) -> bool {
self.0
.upgrade()
.is_some_and(|inner| inner.active.load(Ordering::Acquire))
}
pub(crate) fn allows_navigation(&self, origin: &LoopbackOrigin, url: &str) -> bool {
self.is_active() && origin.allows(url)
}
pub(crate) fn require_active(&self) -> Result<()> {
if self.is_active() {
Ok(())
} else {
Err(retired_host())
}
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn register_close(
&self,
callback: Arc<dyn Fn() + Send + Sync>,
) -> Result<HostCloseRegistration> {
self.install_close(CloseCallback::Native(callback))
}
#[cfg(any(target_os = "windows", target_os = "linux", test))]
pub(crate) fn register_close_fallible(
&self,
callback: Arc<dyn Fn() -> std::result::Result<(), HostCloseError> + Send + Sync>,
) -> Result<HostCloseRegistration> {
self.install_close(CloseCallback::Fallible(callback))
}
fn install_close(&self, callback: CloseCallback) -> Result<HostCloseRegistration> {
let inner = self.0.upgrade().ok_or_else(retired_host)?;
let mut close = inner
.close
.lock()
.unwrap_or_else(|error| error.into_inner());
if !inner.active.load(Ordering::Acquire) {
return Err(retired_host());
}
if close.callback.is_some() {
return Err(DesktopError::UnsupportedRuntime {
message: "this host lifetime already owns a running desktop window".to_string(),
help: "Create one HostLifetime per local-server window".to_string(),
});
}
close.callback = Some(callback.clone());
Ok(HostCloseRegistration {
lifetime: self.clone(),
callback,
})
}
}
impl HostLifetimeOwner {
pub fn revoke(&self) -> std::result::Result<(), HostCloseError> {
if !self.0.active.swap(false, Ordering::AcqRel) {
return self.completed_close_result();
}
self.dispatch_close()
}
pub fn retry_close(&self) -> std::result::Result<(), HostCloseError> {
if self.0.active.load(Ordering::Acquire) {
return Err(HostCloseError::StillActive);
}
self.dispatch_close()
}
fn dispatch_close(&self) -> std::result::Result<(), HostCloseError> {
let callback = {
let mut close = self
.0
.close
.lock()
.unwrap_or_else(|error| error.into_inner());
while close.dispatching {
close = self
.0
.close_completed
.wait(close)
.unwrap_or_else(|error| error.into_inner());
}
close.dispatching = true;
close.callback.clone()
};
let result = callback.as_ref().map_or(Ok(()), CloseCallback::dispatch);
let mut close = self
.0
.close
.lock()
.unwrap_or_else(|error| error.into_inner());
close.result = Some(result.clone());
close.dispatching = false;
self.0.close_completed.notify_all();
result
}
fn completed_close_result(&self) -> std::result::Result<(), HostCloseError> {
let mut close = self
.0
.close
.lock()
.unwrap_or_else(|error| error.into_inner());
while close.dispatching || close.result.is_none() {
close = self
.0
.close_completed
.wait(close)
.unwrap_or_else(|error| error.into_inner());
}
match &close.result {
Some(result) => result.clone(),
None => Ok(()),
}
}
}
impl Drop for HostLifetimeOwner {
fn drop(&mut self) {
if let Err(error) = self.revoke() {
eprintln!(
"WebUI: host lifetime retired but the native close could not be scheduled: {error}"
);
}
}
}
#[cold]
#[inline(never)]
pub(crate) fn retired_host() -> DesktopError {
DesktopError::UnsupportedRuntime {
message: "the verified local-server owner has retired".to_string(),
help: "Keep the authenticated host owner alive until the window closes; create a new frame only after verifying the replacement server".to_string(),
}
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn should_close_for_cookie(
lifetime: Option<&HostLifetime>,
window_cookie: Option<usize>,
received: usize,
) -> bool {
window_cookie.is_some_and(|cookie| cookie != 0 && cookie == received)
&& lifetime.is_some_and(|lifetime| !lifetime.is_active())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LoopbackOrigin(String);
impl LoopbackOrigin {
pub fn from_socket_addr(address: SocketAddr) -> Result<Self> {
if !address.ip().is_loopback() || address.port() == 0 {
return Err(invalid_local_server(
"origin must be a bound loopback IP and nonzero port",
));
}
let host = match address.ip() {
IpAddr::V4(ip) => ip.to_string(),
IpAddr::V6(ip) => format!("[{ip}]"),
};
Ok(Self(format!("http://{host}:{}", address.port())))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
pub(crate) fn allows(&self, url: &str) -> bool {
url.strip_prefix(&self.0).is_some_and(|path| {
(path.is_empty() || path.starts_with('/'))
&& !url.contains('\\')
&& !url.bytes().any(|byte| byte.is_ascii_control())
})
}
#[cfg(target_os = "macos")]
pub(crate) fn matches_security_origin(&self, scheme: &str, host: &str, port: isize) -> bool {
if scheme != "http" || port <= 0 {
return false;
}
let Ok(ip) = host
.trim_start_matches('[')
.trim_end_matches(']')
.parse::<IpAddr>()
else {
return false;
};
let Ok(port) = u16::try_from(port) else {
return false;
};
LoopbackOrigin::from_socket_addr(SocketAddr::new(ip, port))
.is_ok_and(|origin| origin == *self)
}
}
pub struct LocalServerOptions {
origin: LoopbackOrigin,
path: String,
lifetime: HostLifetime,
}
impl LocalServerOptions {
#[must_use]
pub fn new(origin: LoopbackOrigin, lifetime: HostLifetime) -> Self {
Self {
origin,
path: "/".to_string(),
lifetime,
}
}
pub fn initial_path(mut self, path_and_query: &str) -> Result<Self> {
if !path_and_query.starts_with('/')
|| path_and_query.starts_with("//")
|| path_and_query.contains(['#', '\\'])
|| path_and_query.bytes().any(|byte| byte.is_ascii_control())
{
return Err(invalid_local_server("initial path must be a single-rooted path with optional query, without fragment or control characters"));
}
self.path = path_and_query.to_string();
Ok(self)
}
pub(crate) fn url(&self) -> String {
let mut url = String::with_capacity(self.origin.0.len() + self.path.len());
url.push_str(&self.origin.0);
url.push_str(&self.path);
url
}
}
#[cold]
#[inline(never)]
fn invalid_local_server(message: &str) -> DesktopError {
DesktopError::UnsupportedRuntime {
message: message.to_string(),
help: "Supply the verified address of a bound loopback HTTP listener and a root-relative startup path".to_string(),
}
}
pub struct LocalServerAppBuilder {
options: LocalServerOptions,
window: WindowOptions,
shell: DesktopShellConfig,
app_id: Option<String>,
persistent_website_data: bool,
#[cfg(feature = "application-ipc")]
ipc: Option<(OwnedLocalServerIpc, IpcRegistry, IpcOptions)>,
}
impl LocalServerAppBuilder {
pub(crate) fn new(options: LocalServerOptions) -> Self {
Self {
options,
window: WindowOptions::default(),
shell: DesktopShellConfig::default(),
app_id: None,
persistent_website_data: false,
#[cfg(feature = "application-ipc")]
ipc: None,
}
}
#[must_use]
pub fn window(mut self, window: WindowOptions) -> Self {
self.window = window;
self
}
#[must_use]
pub fn shell(mut self, shell: DesktopShellConfig) -> Self {
self.shell = shell;
self
}
#[must_use]
pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
self.app_id = Some(app_id.into());
self
}
#[must_use]
pub fn persistent_website_data(mut self) -> Self {
self.persistent_website_data = true;
self
}
#[cfg(feature = "application-ipc")]
pub fn application_ipc(
mut self,
listener: &TcpListener,
registry: IpcRegistry,
options: IpcOptions,
) -> Result<Self> {
let owned = OwnedLocalServerIpc::from_listener(
listener,
&self.options.origin,
&self.options.lifetime,
)?;
self.ipc = Some((owned, registry, options));
Ok(self)
}
pub fn build(self) -> Result<LocalServerFrame> {
self.options.lifetime.require_active()?;
if self.persistent_website_data {
let id = self
.app_id
.as_deref()
.ok_or(crate::WebsiteDataError::MissingIdentity)?;
if !crate::app_identity::valid_app_id(id) {
return Err(crate::WebsiteDataError::InvalidIdentity.into());
}
#[cfg(target_os = "macos")]
crate::macos::website_data::validate(Some(id))?;
#[cfg(not(any(target_os = "macos", windows)))]
return Err(crate::WebsiteDataError::Unsupported.into());
}
let capabilities = crate::frame::local_platform_capabilities();
crate::validate_frame_capabilities(&self.window, &self.shell, capabilities)?;
if matches!(self.window.titlebar, crate::TitlebarStyle::None) {
return Err(DesktopError::UnsupportedRuntime {
message: "local-server frames cannot use a frameless titlebar".to_string(),
help: "Use a native titlebar or native controls over full-bleed content"
.to_string(),
});
}
if !cfg!(any(
target_os = "macos",
target_os = "windows",
target_os = "linux"
)) {
return Err(DesktopError::UnsupportedRuntime {
message: "local-server native frames are not supported on this target".to_string(),
help: "Use macOS, Windows or Linux with a native webview runtime".to_string(),
});
}
#[cfg(feature = "application-ipc")]
let ipc_owner = self
.ipc
.map(|(owned, registry, options)| {
IpcWindowOwner::new(
Arc::new(registry),
options,
IpcHost::LocalOwned(Arc::new(owned)),
)
})
.transpose()?;
let live_background = std::sync::Arc::default();
let frame_policy = crate::frame_policy::FramePolicy::new(self.options.lifetime.clone());
Ok(LocalServerFrame {
options: self.options,
window: self.window,
#[cfg(target_os = "macos")]
shell: self.shell,
app_id: self.app_id,
#[cfg(target_os = "macos")]
persistent_website_data: self.persistent_website_data,
events: EventRegistry::default(),
#[cfg(all(feature = "native-url-activation", target_os = "macos"))]
url_activation: Mutex::new(None),
window_handle: WindowHandle::with_background(std::sync::Arc::clone(&live_background)),
live_background,
frame_policy,
#[cfg(feature = "native")]
executor: std::sync::Arc::default(),
#[cfg(feature = "native-services")]
native_services: Mutex::new(None),
#[cfg(feature = "application-ipc")]
ipc_owner,
})
}
}
#[cfg_attr(
not(feature = "native-url-activation"),
doc = r#"
Incoming URL registration and its types require `native-url-activation`:
```compile_fail
use webui_desktop::LocalServerFrame;
fn register(frame: &LocalServerFrame) {
frame.on_url_activation("myapp", |_| {}).unwrap();
}
```
```compile_fail
use webui_desktop::UrlActivation;
```
"#
)]
pub struct LocalServerFrame {
pub(crate) options: LocalServerOptions,
pub(crate) window: WindowOptions,
#[cfg(target_os = "macos")]
pub(crate) shell: DesktopShellConfig,
pub(crate) app_id: Option<String>,
#[cfg(target_os = "macos")]
pub(crate) persistent_website_data: bool,
pub(crate) events: EventRegistry,
#[cfg(all(feature = "native-url-activation", target_os = "macos"))]
pub(crate) url_activation: Mutex<Option<Arc<ActivationSender>>>,
pub(crate) window_handle: WindowHandle,
pub(crate) live_background: std::sync::Arc<crate::window::LiveBackground>,
pub(crate) frame_policy: std::sync::Arc<crate::frame_policy::FramePolicy>,
#[cfg(feature = "native")]
pub(crate) executor: std::sync::Arc<crate::execution::ApplicationExecutor>,
#[cfg(feature = "native-services")]
pub(crate) native_services: Mutex<Option<crate::native_services::NativeServices>>,
#[cfg(feature = "application-ipc")]
pub(crate) ipc_owner: Option<IpcWindowOwner>,
}
impl LocalServerFrame {
#[must_use]
pub fn frame_policy(&self) -> crate::FramePolicyHandle {
self.frame_policy.handle()
}
#[cfg(feature = "native-services")]
pub fn native_services(
&self,
) -> std::result::Result<crate::NativeServices, crate::NativeServiceError> {
let mut cached = self
.native_services
.lock()
.map_err(|_| crate::NativeServiceError::Unavailable)?;
if let Some(services) = cached.as_ref() {
return Ok(services.clone());
}
let services = crate::NativeServices::new(
&self.events,
std::sync::Arc::clone(&self.executor),
self.lifetime().clone(),
)?;
*cached = Some(services.clone());
Ok(services)
}
#[cfg(all(
feature = "application-ipc",
any(target_os = "macos", target_os = "windows", target_os = "linux")
))]
#[must_use]
pub fn ipc(&self) -> Option<IpcWindow> {
self.ipc_owner.as_ref().map(IpcWindowOwner::window)
}
#[cfg(all(
feature = "application-ipc",
any(target_os = "macos", target_os = "windows", target_os = "linux")
))]
pub(crate) fn ipc_bridge(&self) -> Option<IpcBridge> {
self.ipc_owner.as_ref().map(IpcWindowOwner::bridge)
}
#[must_use]
pub fn origin(&self) -> &LoopbackOrigin {
&self.options.origin
}
pub(crate) fn lifetime(&self) -> &HostLifetime {
&self.options.lifetime
}
#[must_use]
pub fn window_handle(&self) -> &WindowHandle {
&self.window_handle
}
pub fn on_event<F>(&self, handler: F) -> std::result::Result<(), EventRegistrationError>
where
F: Fn(&DesktopEvent) -> EventResponse + Send + Sync + 'static,
{
self.events.on_event(handler)
}
#[cfg(feature = "native-url-activation")]
pub fn on_url_activation<F>(
&self,
scheme: &str,
handler: F,
) -> std::result::Result<(), UrlActivationRegistrationError>
where
F: Fn(UrlActivation) + Send + 'static,
{
#[cfg(not(target_os = "macos"))]
{
let _ = (scheme, handler);
Err(UrlActivationRegistrationError::Unsupported)
}
#[cfg(target_os = "macos")]
{
let mut slot = self
.url_activation
.lock()
.map_err(|_| UrlActivationRegistrationError::Unavailable)?;
if slot.is_some() {
return Err(UrlActivationRegistrationError::AlreadyRegistered);
}
*slot = Some(Arc::new(ActivationSender::register(
scheme,
self.lifetime().clone(),
handler,
)?));
Ok(())
}
}
pub fn subscribe<F>(
&self,
handler: F,
) -> std::result::Result<EventSubscription, EventRegistrationError>
where
F: Fn(&DesktopEvent) -> EventResponse + Send + Sync + 'static,
{
self.events.subscribe(handler)
}
}
impl Drop for LocalServerFrame {
fn drop(&mut self) {
#[cfg(all(feature = "native-url-activation", target_os = "macos"))]
{
if let Ok(mut activation) = self.url_activation.lock() {
if let Some(sender) = activation.take() {
sender.close();
}
}
}
self.frame_policy.close();
#[cfg(feature = "native-services")]
{
if let Ok(mut services) = self.native_services.lock() {
if let Some(services) = services.take() {
services.close();
}
}
}
#[cfg(feature = "application-ipc")]
if let Some(owner) = &self.ipc_owner {
owner.close();
}
#[cfg(feature = "native")]
self.executor.close();
self.window_handle.close();
self.events.close();
}
}
pub fn run_local_server_frame(frame: LocalServerFrame) -> Result<()> {
frame.lifetime().require_active()?;
crate::frame::platform_run_local_server_frame(frame)
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
#[cfg(all(
feature = "application-ipc",
any(target_os = "macos", target_os = "windows")
))]
#[test]
fn local_runtime_is_explicit_and_never_substituted_for_the_default_bundle() {
assert_eq!(LOCAL_IPC_RUNTIME_PATH, "/_webui/ipc/local-runtime.js");
let local = std::str::from_utf8(local_ipc_runtime_asset()).unwrap();
assert!(local.contains("createNativeDesktopTransport"));
assert!(local.contains("nativeCarrierVersion"));
let bundled = std::str::from_utf8(crate::ipc_assets::BROWSER_RUNTIME).unwrap();
assert!(!bundled.contains("createNativeDesktopTransport"));
}
#[cfg(all(feature = "application-ipc", target_os = "macos"))]
#[test]
fn wk_security_origin_must_match_the_bound_ip_and_port() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let origin = LoopbackOrigin::from_socket_addr(address).unwrap();
let port = isize::try_from(u32::from(address.port())).unwrap();
assert!(origin.matches_security_origin("http", "127.0.0.1", port));
assert!(!origin.matches_security_origin("http", "127.0.0.2", port));
assert!(!origin.matches_security_origin("https", "127.0.0.1", port));
assert!(!origin.matches_security_origin("http", "127.0.0.1", port + 1));
}
#[test]
fn origin_requires_bound_loopback_literal() {
assert!(LoopbackOrigin::from_socket_addr("0.0.0.0:80".parse().unwrap()).is_err());
assert!(LoopbackOrigin::from_socket_addr("127.0.0.1:0".parse().unwrap()).is_err());
assert!(LoopbackOrigin::from_socket_addr("192.0.2.1:80".parse().unwrap()).is_err());
assert_eq!(
LoopbackOrigin::from_socket_addr("[::1]:443".parse().unwrap())
.unwrap()
.as_str(),
"http://[::1]:443"
);
}
#[test]
fn startup_path_cannot_escape_origin() {
let origin = LoopbackOrigin::from_socket_addr("127.0.0.1:3456".parse().unwrap()).unwrap();
let (_owner, lifetime) = HostLifetime::new();
for path in ["//evil/", "http://evil/", "/x#hash", "/\\evil", "/\n", ""] {
assert!(LocalServerOptions::new(origin.clone(), lifetime.clone())
.initial_path(path)
.is_err());
}
let options = LocalServerOptions::new(origin.clone(), lifetime)
.initial_path("/app?q=a")
.unwrap();
assert_eq!(options.url(), "http://127.0.0.1:3456/app?q=a");
for url in [
"http://127.0.0.1:3456.evil/",
"http://127.0.0.1:34560/",
"http://user@127.0.0.1:3456/",
"https://127.0.0.1:3456/",
"http://127.0.0.1:3456\\@evil/",
] {
assert!(!origin.allows(url), "{url}");
}
assert!(origin.allows("http://127.0.0.1:3456/deep?q=a"));
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn builder_owns_window_shell_events_and_commands_without_a_protocol() {
let origin = LoopbackOrigin::from_socket_addr("127.0.0.1:3456".parse().unwrap()).unwrap();
let (_owner, lifetime) = HostLifetime::new();
let frame = crate::DesktopApp::from_local_server(LocalServerOptions::new(origin, lifetime))
.app_id("com.example.local-smoke")
.window(WindowOptions {
title: "HTTP".to_string(),
..WindowOptions::default()
})
.shell(DesktopShellConfig::default())
.build()
.unwrap();
assert_eq!(frame.app_id.as_deref(), Some("com.example.local-smoke"));
assert_eq!(frame.window.title, "HTTP");
assert_eq!(frame.origin().as_str(), "http://127.0.0.1:3456");
frame.on_event(|_| EventResponse::Continue).unwrap();
let subscription = frame.subscribe(|_| EventResponse::Continue).unwrap();
assert!(frame.window_handle().set_title("HTTP ready").is_ok());
drop(subscription);
drop(frame);
}
#[test]
fn persistent_website_data_requires_an_explicit_valid_app_identity() {
fn build(id: Option<&str>, persistent: bool) -> crate::Result<LocalServerFrame> {
let origin =
LoopbackOrigin::from_socket_addr("127.0.0.1:3456".parse().unwrap()).unwrap();
let (_owner, lifetime) = HostLifetime::new();
let mut builder =
crate::DesktopApp::from_local_server(LocalServerOptions::new(origin, lifetime));
if let Some(id) = id {
builder = builder.app_id(id);
}
if persistent {
builder = builder.persistent_website_data();
}
builder.build()
}
assert!(matches!(
build(None, true),
Err(DesktopError::WebsiteData(
crate::WebsiteDataError::MissingIdentity
))
));
for id in [
"",
"../foreign",
"foreign.",
"a/b",
"a".repeat(256).as_str(),
] {
assert!(matches!(
build(Some(id), true),
Err(DesktopError::WebsiteData(
crate::WebsiteDataError::InvalidIdentity
))
));
}
assert!(build(Some("com.example.unbundled"), false).is_ok());
#[cfg(target_os = "macos")]
assert!(matches!(
build(Some("com.example.unbundled"), true),
Err(DesktopError::WebsiteData(
crate::WebsiteDataError::Unpackaged
)) | Err(DesktopError::WebsiteData(
crate::WebsiteDataError::IdentityMismatch
))
));
#[cfg(target_os = "windows")]
assert!(build(Some("com.example.windows"), true).is_ok());
#[cfg(not(any(target_os = "macos", windows)))]
assert!(matches!(
build(Some("com.example.linux"), true),
Err(DesktopError::WebsiteData(
crate::WebsiteDataError::Unsupported
))
));
}
#[cfg(feature = "native-url-activation")]
#[test]
fn url_activation_registration_is_single_owner_or_explicitly_unsupported() {
let origin = LoopbackOrigin::from_socket_addr("127.0.0.1:3456".parse().unwrap()).unwrap();
let (_owner, lifetime) = HostLifetime::new();
let frame = crate::DesktopApp::from_local_server(LocalServerOptions::new(origin, lifetime))
.build()
.unwrap();
#[cfg(target_os = "macos")]
{
assert!(matches!(
frame.on_url_activation("HTTP", |_| {}),
Err(UrlActivationRegistrationError::InvalidScheme)
));
frame.on_url_activation("testapp", |_| {}).unwrap();
assert!(matches!(
frame.on_url_activation("testapp", |_| {}),
Err(UrlActivationRegistrationError::AlreadyRegistered)
));
}
#[cfg(not(target_os = "macos"))]
assert!(matches!(
frame.on_url_activation("testapp", |_| {}),
Err(UrlActivationRegistrationError::Unsupported)
));
}
#[cfg(all(feature = "native-url-activation", target_os = "macos"))]
#[test]
fn frame_drop_retires_its_activation_sender_without_retiring_the_host() {
let origin = LoopbackOrigin::from_socket_addr("127.0.0.1:3456".parse().unwrap()).unwrap();
let (_owner, lifetime) = HostLifetime::new();
let frame =
crate::DesktopApp::from_local_server(LocalServerOptions::new(origin, lifetime.clone()))
.build()
.unwrap();
frame.on_url_activation("testapp", |_| {}).unwrap();
let sender = frame.url_activation.lock().unwrap().clone().unwrap();
assert!(sender.accept("testapp://open/before").is_ok());
drop(frame);
assert!(lifetime.is_active());
assert!(matches!(
sender.accept("testapp://open/after"),
Err(url_activation::Rejection::Retired)
));
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn local_server_accepts_native_controls_over_full_bleed_content() {
for titlebar in [
crate::TitlebarStyle::HiddenInset,
crate::TitlebarStyle::Overlay { height: 48 },
] {
let origin =
LoopbackOrigin::from_socket_addr("127.0.0.1:3456".parse().unwrap()).unwrap();
let (_owner, lifetime) = HostLifetime::new();
let window = WindowOptions {
titlebar: titlebar.clone(),
..WindowOptions::default()
};
let frame =
crate::DesktopApp::from_local_server(LocalServerOptions::new(origin, lifetime))
.window(window.clone())
.build()
.unwrap();
assert_eq!(frame.window.titlebar, titlebar);
let css = crate::window_css_block(&window, crate::DesktopPlatform::current());
assert!(css.contains("[webui-drag]"));
assert!(css.contains("--webui-titlebar-inset-start:"));
assert!(css.contains("--webui-titlebar-height:"));
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn local_server_rejects_fully_frameless_titlebar() {
let origin = LoopbackOrigin::from_socket_addr("127.0.0.1:3456".parse().unwrap()).unwrap();
let (_owner, lifetime) = HostLifetime::new();
assert!(matches!(
crate::DesktopApp::from_local_server(LocalServerOptions::new(origin, lifetime))
.window(WindowOptions {
titlebar: crate::TitlebarStyle::None,
..WindowOptions::default()
})
.build(),
Err(DesktopError::UnsupportedRuntime { .. })
));
}
#[cfg(target_os = "linux")]
#[test]
fn local_server_does_not_advertise_unavailable_linux_titlebar_controls() {
let origin = LoopbackOrigin::from_socket_addr("127.0.0.1:3456".parse().unwrap()).unwrap();
let (_owner, lifetime) = HostLifetime::new();
assert!(matches!(
crate::DesktopApp::from_local_server(LocalServerOptions::new(origin, lifetime))
.window(WindowOptions {
titlebar: crate::TitlebarStyle::Overlay { height: 64 },
..WindowOptions::default()
})
.build(),
Err(DesktopError::UnsupportedRuntime { .. })
));
}
#[test]
fn revocation_between_navigation_start_and_commit_blocks_the_same_origin() {
let origin = LoopbackOrigin::from_socket_addr("127.0.0.1:3456".parse().unwrap()).unwrap();
let (owner, lifetime) = HostLifetime::new();
let url = "http://127.0.0.1:3456/deep";
assert!(lifetime.allows_navigation(&origin, url));
owner.revoke().unwrap();
assert!(!lifetime.allows_navigation(&origin, url));
assert!(matches!(
crate::DesktopApp::from_local_server(LocalServerOptions::new(origin, lifetime)).build(),
Err(DesktopError::UnsupportedRuntime { .. })
));
}
#[test]
fn owner_drop_retires_weak_frame_even_if_frame_is_retained() {
let origin = LoopbackOrigin::from_socket_addr("127.0.0.1:3456".parse().unwrap()).unwrap();
let (owner, lifetime) = HostLifetime::new();
#[cfg(any(target_os = "macos", target_os = "windows"))]
let frame = crate::DesktopApp::from_local_server(LocalServerOptions::new(
origin.clone(),
lifetime.clone(),
))
.build()
.unwrap();
drop(owner);
assert!(!lifetime.is_active());
assert!(!lifetime.allows_navigation(&origin, "http://127.0.0.1:3456/"));
#[cfg(any(target_os = "macos", target_os = "windows"))]
assert!(run_local_server_frame(frame).is_err());
}
#[test]
fn revoke_precedes_single_close_wake_even_when_command_queue_is_full() {
use std::sync::atomic::AtomicUsize;
let (owner, lifetime) = HostLifetime::new();
let calls = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&calls);
let state = lifetime.clone();
let _registration = lifetime
.register_close(Arc::new(move || {
assert!(!state.is_active());
observed.fetch_add(1, Ordering::Relaxed);
}))
.unwrap();
let handle = WindowHandle::default();
for _ in 0..crate::MAX_QUEUED_WINDOW_COMMANDS {
handle.set_title("queue full").unwrap();
}
assert!(handle.set_title("overflow").is_err());
owner.revoke().unwrap();
owner.revoke().unwrap();
assert_eq!(calls.load(Ordering::Relaxed), 1);
assert!(!lifetime.is_active());
}
#[test]
fn registration_drop_releases_callback_without_extending_owner() {
let (owner, lifetime) = HostLifetime::new();
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let captured = Arc::clone(&calls);
let registration = lifetime
.register_close(Arc::new(move || {
captured.fetch_add(1, Ordering::Relaxed);
}))
.unwrap();
drop(registration);
drop(owner);
assert!(!lifetime.is_active());
assert_eq!(calls.load(Ordering::Relaxed), 0);
}
#[test]
fn retired_and_already_running_lifetimes_reject_close_registration() {
let (owner, lifetime) = HostLifetime::new();
let first = lifetime.register_close(Arc::new(|| {})).unwrap();
assert!(matches!(
lifetime.register_close(Arc::new(|| {})),
Err(DesktopError::UnsupportedRuntime { .. })
));
drop(first);
owner.revoke().unwrap();
assert!(matches!(
lifetime.register_close(Arc::new(|| {})),
Err(DesktopError::UnsupportedRuntime { .. })
));
}
#[test]
fn attached_owner_revocation_does_not_terminate_its_listener() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (owner, lifetime) = HostLifetime::new();
owner.revoke().unwrap();
assert!(!lifetime.is_active());
assert!(std::net::TcpStream::connect_timeout(
&address,
std::time::Duration::from_millis(250)
)
.is_ok());
}
#[test]
fn failed_native_wake_preserves_revocation_and_allows_one_explicit_retry() {
use std::sync::atomic::AtomicUsize;
let (owner, lifetime) = HostLifetime::new();
let attempts = Arc::new(AtomicUsize::new(0));
let received = Arc::clone(&attempts);
let registration = lifetime
.register_close_fallible(Arc::new(move || {
if received.fetch_add(1, Ordering::Relaxed) == 0 {
Err(HostCloseError::WakeFailed {
message: "injected full OS message queue".to_string(),
})
} else {
Ok(())
}
}))
.unwrap();
assert!(matches!(
owner.retry_close(),
Err(HostCloseError::StillActive)
));
assert!(matches!(
owner.revoke(),
Err(HostCloseError::WakeFailed { .. })
));
assert!(!lifetime.is_active());
assert!(owner.retry_close().is_ok());
assert_eq!(attempts.load(Ordering::Relaxed), 2);
drop(registration);
assert!(owner.retry_close().is_ok());
assert_eq!(attempts.load(Ordering::Relaxed), 2);
}
#[test]
fn concurrent_revoke_waits_for_the_in_flight_close_result() {
let (owner, lifetime) = HostLifetime::new();
let owner = Arc::new(owner);
let (entered_sender, entered_receiver) = std::sync::mpsc::channel();
let release = Arc::new(std::sync::Barrier::new(2));
let callback_release = Arc::clone(&release);
let _registration = lifetime
.register_close_fallible(Arc::new(move || {
entered_sender.send(()).unwrap();
callback_release.wait();
Err(HostCloseError::WakeFailed {
message: "injected delayed failure".to_string(),
})
}))
.unwrap();
let first_owner = Arc::clone(&owner);
let first = std::thread::spawn(move || first_owner.revoke());
entered_receiver.recv().unwrap();
let second_owner = Arc::clone(&owner);
let (second_sender, second_receiver) = std::sync::mpsc::channel();
let second = std::thread::spawn(move || {
second_sender.send(second_owner.revoke()).unwrap();
});
assert!(second_receiver
.recv_timeout(std::time::Duration::from_millis(50))
.is_err());
release.wait();
assert!(matches!(
first.join().unwrap(),
Err(HostCloseError::WakeFailed { .. })
));
assert!(matches!(
second_receiver.recv().unwrap(),
Err(HostCloseError::WakeFailed { .. })
));
second.join().unwrap();
}
#[test]
fn stale_window_generation_cannot_close_a_reused_hwnd() {
let (owner, lifetime) = HostLifetime::new();
assert!(!should_close_for_cookie(Some(&lifetime), Some(7), 7));
owner.revoke().unwrap();
assert!(should_close_for_cookie(Some(&lifetime), Some(7), 7));
assert!(!should_close_for_cookie(Some(&lifetime), Some(8), 7));
assert!(!should_close_for_cookie(Some(&lifetime), None, 7));
assert!(!should_close_for_cookie(None, Some(7), 7));
assert!(!should_close_for_cookie(Some(&lifetime), Some(0), 0));
}
}