From 2102fad2f22cda2d2b53fb42df98c4e92286a6b0 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 5 Nov 2026 22:22:13 -0400 Subject: [PATCH 1/2] fix(runtime): JS delegates answer IAgileObject Event sources that only accept agile handlers, such as DispatcherQueueTimer.Tick, rejected JS functions with "The object must support the IAgileObject interface". Both delegate implementations already run off-thread invokes on the owner JS thread, so they now say so in QueryInterface (classic and napi). The IAgileObject IID moves to value.rs and is shared. --- runtime/src/collection_arg.rs | 2 +- runtime/src/lib.rs | 3 ++- runtime/src/napi_engine/delegate.rs | 5 +++-- runtime/src/struct_reference.rs | 2 +- runtime/src/value.rs | 3 +++ 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/runtime/src/collection_arg.rs b/runtime/src/collection_arg.rs index 192fb02..9d3d8bd 100644 --- a/runtime/src/collection_arg.rs +++ b/runtime/src/collection_arg.rs @@ -22,7 +22,7 @@ const E_NOINTERFACE: HRESULT = HRESULT(0x80004002u32 as i32); const E_POINTER: HRESULT = HRESULT(0x80004003u32 as i32); const S_OK: HRESULT = HRESULT(0); const IID_IINSPECTABLE: GUID = GUID::from_u128(0xAF86E2E0_B12D_4c6a_9C5A_D7AA65101E90); -const IID_IAGILE_OBJECT: GUID = GUID::from_u128(0x94ea2b94_e9cc_49e0_c0ff_ee64ca8f5b90); +use crate::value::IID_IAGILE_OBJECT; const COLLECTIONS: &str = "Windows.Foundation.Collections."; diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 1faa462..95b8b3b 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -7747,7 +7747,8 @@ unsafe extern "system" fn js_delegate_query_interface( out: *mut *mut c_void, ) -> HRESULT { let d = &*this; - if *iid == IUnknown::IID || *iid == d.guid { + // Agile: off-thread invokes run on the owner JS thread. + if *iid == IUnknown::IID || *iid == d.guid || *iid == crate::value::IID_IAGILE_OBJECT { *out = this as *mut c_void; js_delegate_add_ref(this); HRESULT(0) diff --git a/runtime/src/napi_engine/delegate.rs b/runtime/src/napi_engine/delegate.rs index 3cea5f8..cae18aa 100644 --- a/runtime/src/napi_engine/delegate.rs +++ b/runtime/src/napi_engine/delegate.rs @@ -213,7 +213,8 @@ unsafe extern "system" fn napi_delegate_query_interface( out: *mut *mut c_void, ) -> HRESULT { let d = &*this; - if *iid == IUnknown::IID || *iid == d.guid { + // Agile: off-thread invokes run on the owner JS thread. + if *iid == IUnknown::IID || *iid == d.guid || *iid == crate::value::IID_IAGILE_OBJECT { *out = this as *mut c_void; napi_delegate_add_ref(this); HRESULT(0) @@ -624,7 +625,7 @@ const AS_DELEGATE_HELPER_JS: &str = r#" "#; /// Stringify a napi value (used for exception capture); None on failure. -unsafe fn napi_value_to_rust_string(env: sys::napi_env, value: sys::napi_value) -> Option { +pub(crate) unsafe fn napi_value_to_rust_string(env: sys::napi_env, value: sys::napi_value) -> Option { let mut coerced: sys::napi_value = std::ptr::null_mut(); if sys::napi_coerce_to_string(env, value, &mut coerced) != sys::Status::napi_ok { return None; diff --git a/runtime/src/struct_reference.rs b/runtime/src/struct_reference.rs index 2d86d3b..cfed2a6 100644 --- a/runtime/src/struct_reference.rs +++ b/runtime/src/struct_reference.rs @@ -21,7 +21,7 @@ const E_NOINTERFACE: HRESULT = HRESULT(0x80004002u32 as i32); const E_POINTER: HRESULT = HRESULT(0x80004003u32 as i32); const S_OK: HRESULT = HRESULT(0); const IID_IINSPECTABLE: GUID = GUID::from_u128(0xAF86E2E0_B12D_4c6a_9C5A_D7AA65101E90); -const IID_IAGILE_OBJECT: GUID = GUID::from_u128(0x94ea2b94_e9cc_49e0_c0ff_ee64ca8f5b90); +use crate::value::IID_IAGILE_OBJECT; /// The struct declaration for an `IReference`'s `T`, or `None` when `T` isn't a struct. /// `TimeSpan` and `DateTime` stay with `PropertyValue`, which also accepts a number for them. diff --git a/runtime/src/value.rs b/runtime/src/value.rs index cf1166e..3711c39 100644 --- a/runtime/src/value.rs +++ b/runtime/src/value.rs @@ -14,6 +14,9 @@ use windows::core::{IUnknown, Interface, GUID}; #[cfg(feature = "classic")] use windows::Foundation::PropertyValue; +pub(crate) const IID_IAGILE_OBJECT: windows::core::GUID = + windows::core::GUID::from_u128(0x94ea2b94_e9cc_49e0_c0ff_ee64ca8f5b90); + pub(crate) const MAX_SAFE_INTEGER: isize = 9007199254740991; pub(crate) const MIN_SAFE_INTEGER: isize = -9007199254740991; From 04d8ea48444b4756045a6a4b099333254c4f0bb1 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 5 Nov 2026 22:22:13 -0400 Subject: [PATCH 2/2] feat(napi): XamlWindow for Windows.UI.Xaml content in Win32 windows Adds XAML Islands hosting to @nativescript/windows-napi: - xaml_host.rs: a host window ('dialog' or borderless 'popup'), DesktopWindowXamlSource attach, PreTranslateMessage from a WH_GETMESSAGE hook so Tab and access keys work whoever owns the message loop, DPI-aware placement, and activate / deactivate / close / closed events queued on the dispatcher so JS never runs inside the window procedure. - Helpers: foregroundWindow, activateWindow, enableWindow, windowProcessId. Window handles are accepted as numbers, bigints or Buffers (as returned by getNativeWindowHandle()). - nswinrt.js: XamlWindow class (theme detection, events, a clear error when the host manifest lacks maxversiontested). - test/xaml-host-test.js; README section. --- runtime/Cargo.toml | 2 + runtime/src/napi_engine/mod.rs | 1 + runtime/src/napi_engine/xaml_host.rs | 655 +++++++++++++++++++++++++++ windows-napi/README.md | 26 ++ windows-napi/index.d.ts | 23 + windows-napi/index.js | 13 +- windows-napi/nswinrt.js | 92 ++++ windows-napi/src/lib.rs | 77 ++++ windows-napi/test/xaml-host-test.js | 70 +++ 9 files changed, 958 insertions(+), 1 deletion(-) create mode 100644 runtime/src/napi_engine/xaml_host.rs create mode 100644 windows-napi/test/xaml-host-test.js diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 6e9de04..aa1d1f9 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -61,6 +61,8 @@ features = [ "Win32_Graphics_Gdi", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", + "Win32_UI_HiDpi", + "Win32_UI_Input_KeyboardAndMouse", "System", "Storage_Streams", "Data_Json", diff --git a/runtime/src/napi_engine/mod.rs b/runtime/src/napi_engine/mod.rs index 80a595f..330d488 100644 --- a/runtime/src/napi_engine/mod.rs +++ b/runtime/src/napi_engine/mod.rs @@ -26,6 +26,7 @@ pub mod timers; pub mod url; pub mod value; pub mod websocket; +pub mod xaml_host; // Engine-neutral marshaling types the napi backend and standalone hosts build against. pub use crate::value::{NativeType, NativeValue}; diff --git a/runtime/src/napi_engine/xaml_host.rs b/runtime/src/napi_engine/xaml_host.rs new file mode 100644 index 0000000..f77a042 --- /dev/null +++ b/runtime/src/napi_engine/xaml_host.rs @@ -0,0 +1,655 @@ +//! Win32 host windows for system XAML Islands (`DesktopWindowXamlSource`): attach, keyboard +//! pre-translation, placement and window events. Content and XAML events stay in JS. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::ffi::c_void; +use std::sync::OnceLock; + +use napi::{sys, Env, JsFunction, JsObject, JsUnknown, NapiRaw, ValueType}; +use windows::core::{IUnknown, Interface, BOOL, GUID, HRESULT, PCWSTR}; +use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM}; +use windows::Win32::Graphics::Dwm::{ + DwmSetWindowAttribute, DWMWA_USE_IMMERSIVE_DARK_MODE, DWMWA_WINDOW_CORNER_PREFERENCE, + DWMWINDOWATTRIBUTE, +}; +use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromPoint, MonitorFromWindow, HMONITOR, MONITORINFO, + MONITOR_DEFAULTTONEAREST, +}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::HiDpi::{AdjustWindowRectExForDpi, GetDpiForMonitor, MDT_EFFECTIVE_DPI}; +use windows::Win32::UI::Input::KeyboardAndMouse::{EnableWindow, SetFocus}; +use windows::Win32::UI::WindowsAndMessaging::{ + CallNextHookEx, CreateWindowExW, DefWindowProcW, DestroyWindow, GetAncestor, GetClientRect, + GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId, + IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW, SetForegroundWindow, SetWindowPos, + SetWindowsHookExW, ShowWindow, UnhookWindowsHookEx, GA_ROOT, GWL_EXSTYLE, GWL_STYLE, HHOOK, + IDC_ARROW, MSG, PM_REMOVE, SWP_NOACTIVATE, SWP_NOZORDER, SW_HIDE, SW_SHOW, SW_SHOWNOACTIVATE, + WA_INACTIVE, WH_GETMESSAGE, WINDOW_EX_STYLE, WINDOW_STYLE, WM_ACTIVATE, WM_CLOSE, + WM_DPICHANGED, WM_KEYFIRST, WM_KEYLAST, WM_NCDESTROY, WM_NULL, WM_SETFOCUS, WM_SIZE, + WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN, WS_EX_DLGMODALFRAME, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, + WS_POPUP, WS_SYSMENU, +}; + +use crate::napi_engine::value::try_get_external_handle; + +const CLASS_NAME: PCWSTR = windows::core::w!("NsWinRTXamlHost"); + +/// `IDesktopWindowXamlSourceNative2` (windows.ui.xaml.hosting.desktopwindowxamlsource.h). +const IID_XAML_SOURCE_NATIVE2: GUID = GUID::from_u128(0xe3dcd8c7_3057_4692_99c3_7b7720afda31); + +#[repr(C)] +struct XamlSourceNative2Vtbl { + base: windows::core::IUnknown_Vtbl, + attach_to_window: unsafe extern "system" fn(*mut c_void, HWND) -> HRESULT, + window_handle: unsafe extern "system" fn(*mut c_void, *mut HWND) -> HRESULT, + pre_translate_message: unsafe extern "system" fn(*mut c_void, *const MSG, *mut BOOL) -> HRESULT, +} + +/// An `IDesktopWindowXamlSourceNative2` reference (held as `IUnknown` for ref counting). +#[derive(Clone)] +struct SourceNative(IUnknown); + +impl SourceNative { + fn from_source(source: &IUnknown) -> windows::core::Result { + let mut raw = std::ptr::null_mut(); + unsafe { source.query(&IID_XAML_SOURCE_NATIVE2, &mut raw) }.ok()?; + Ok(Self(unsafe { IUnknown::from_raw(raw) })) + } + + fn vtbl(&self) -> &XamlSourceNative2Vtbl { + unsafe { &**(self.0.as_raw() as *const *const XamlSourceNative2Vtbl) } + } + + fn attach(&self, parent: HWND) -> windows::core::Result { + unsafe { (self.vtbl().attach_to_window)(self.0.as_raw(), parent) }.ok()?; + let mut island = HWND::default(); + unsafe { (self.vtbl().window_handle)(self.0.as_raw(), &mut island) }.ok()?; + Ok(island) + } + + fn pre_translate(&self, msg: &MSG) -> bool { + let mut handled = BOOL(0); + let hr = unsafe { (self.vtbl().pre_translate_message)(self.0.as_raw(), msg, &mut handled) }; + hr.is_ok() && handled.as_bool() + } +} + +struct Listener { + env: sys::napi_env, + func: sys::napi_ref, +} + +struct Host { + island: HWND, + source: SourceNative, + listener: Option, +} + +thread_local! { + // Keyed by host HWND. + static HOSTS: RefCell> = RefCell::new(HashMap::new()); + static KEY_HOOK: Cell = const { Cell::new(0) }; +} + +fn key(hwnd: HWND) -> isize { + hwnd.0 as isize +} + +fn island_of(hwnd: HWND) -> Option { + HOSTS.with(|h| h.borrow().get(&key(hwnd)).map(|h| h.island)) +} + +fn has_listener(hwnd: HWND) -> bool { + HOSTS.with(|h| { + h.borrow() + .get(&key(hwnd)) + .is_some_and(|h| h.listener.is_some()) + }) +} + +fn loword(v: usize) -> i32 { + (v & 0xFFFF) as u16 as i32 +} + +fn hiword(v: usize) -> i32 { + ((v >> 16) & 0xFFFF) as u16 as i32 +} + +/// Queue `event` for the window's listener, so JS never runs inside the window procedure. +fn emit(hwnd: HWND, event: &'static str) { + let k = key(hwnd); + if !crate::ui_dispatcher::enqueue_on_ui_thread(move || call_listener(k, event)) { + call_listener(k, event); + } +} + +fn call_listener(k: isize, event: &str) { + let listener = HOSTS.with(|h| { + h.borrow() + .get(&k) + .and_then(|h| h.listener.as_ref().map(|l| (l.env, l.func))) + }); + if let Some((env, func)) = listener { + unsafe { call_js(env, func, event) }; + } +} + +/// Call `func(event)`; a throw is recorded as the last JS error. +unsafe fn call_js(env: sys::napi_env, func: sys::napi_ref, event: &str) { + let mut scope: sys::napi_handle_scope = std::ptr::null_mut(); + if sys::napi_open_handle_scope(env, &mut scope) != sys::Status::napi_ok { + return; + } + let mut f: sys::napi_value = std::ptr::null_mut(); + let mut arg: sys::napi_value = std::ptr::null_mut(); + let mut recv: sys::napi_value = std::ptr::null_mut(); + if sys::napi_get_reference_value(env, func, &mut f) == sys::Status::napi_ok + && !f.is_null() + && sys::napi_create_string_utf8(env, event.as_ptr().cast(), event.len(), &mut arg) + == sys::Status::napi_ok + && sys::napi_get_undefined(env, &mut recv) == sys::Status::napi_ok + { + let mut result: sys::napi_value = std::ptr::null_mut(); + if sys::napi_call_function(env, recv, f, 1, &arg, &mut result) != sys::Status::napi_ok { + let mut exc: sys::napi_value = std::ptr::null_mut(); + if sys::napi_get_and_clear_last_exception(env, &mut exc) == sys::Status::napi_ok + && !exc.is_null() + { + if let Some(msg) = crate::napi_engine::delegate::napi_value_to_rust_string(env, exc) + { + crate::store_last_js_error(msg); + } + } + } + } + let _ = sys::napi_close_handle_scope(env, scope); +} + +/// Forget the window, then send its listener `"closed"` and release it. +fn on_destroyed(hwnd: HWND) { + let host = HOSTS.with(|h| h.borrow_mut().remove(&key(hwnd))); + if HOSTS.with(|h| h.borrow().is_empty()) { + let hook = KEY_HOOK.with(|k| k.replace(0)); + if hook != 0 { + let _ = unsafe { UnhookWindowsHookEx(HHOOK(hook as *mut c_void)) }; + } + } + let Some(Listener { env, func }) = host.and_then(|h| h.listener) else { + return; + }; + struct SendListener(sys::napi_env, sys::napi_ref); + unsafe impl Send for SendListener {} + let l = SendListener(env, func); + // Without a dispatcher the reference leaks: destruction can run during process teardown. + let _ = crate::ui_dispatcher::enqueue_on_ui_thread(move || { + let l = l; + unsafe { + call_js(l.0, l.1, "closed"); + let _ = sys::napi_delete_reference(l.0, l.1); + } + }); +} + +unsafe extern "system" fn wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_SIZE => { + if let Some(island) = island_of(hwnd) { + let (w, h) = (loword(lparam.0 as usize), hiword(lparam.0 as usize)); + let _ = SetWindowPos(island, None, 0, 0, w, h, SWP_NOZORDER | SWP_NOACTIVATE); + } + } + // Keyboard focus belongs to the island, not the empty host. + WM_SETFOCUS => { + if let Some(island) = island_of(hwnd) { + let _ = SetFocus(Some(island)); + return LRESULT(0); + } + } + WM_ACTIVATE => { + let active = loword(wparam.0) != WA_INACTIVE as i32; + emit(hwnd, if active { "activate" } else { "deactivate" }); + } + // With a listener, closing is the listener's call. + WM_CLOSE if has_listener(hwnd) => { + emit(hwnd, "close"); + return LRESULT(0); + } + WM_DPICHANGED => { + let r = &*(lparam.0 as *const RECT); + let _ = SetWindowPos( + hwnd, + None, + r.left, + r.top, + r.right - r.left, + r.bottom - r.top, + SWP_NOZORDER | SWP_NOACTIVATE, + ); + return LRESULT(0); + } + WM_NCDESTROY => on_destroyed(hwnd), + _ => {} + } + DefWindowProcW(hwnd, msg, wparam, lparam) +} + +/// Offer island key messages to XAML first so Tab navigation and access keys work. +unsafe extern "system" fn get_message_hook(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + if code >= 0 && wparam.0 == PM_REMOVE.0 as usize && lparam.0 != 0 { + let msg = &mut *(lparam.0 as *mut MSG); + if (WM_KEYFIRST..=WM_KEYLAST).contains(&msg.message) && !msg.hwnd.is_invalid() { + let root = GetAncestor(msg.hwnd, GA_ROOT); + // Cloned out: XAML key handlers run inside PreTranslateMessage and may touch HOSTS. + let source = HOSTS.with(|h| h.borrow().get(&key(root)).map(|h| h.source.clone())); + if source.is_some_and(|s| s.pre_translate(msg)) { + msg.message = WM_NULL; + } + } + } + CallNextHookEx(None, code, wparam, lparam) +} + +fn install_key_hook() { + if KEY_HOOK.with(|k| k.get()) != 0 { + return; + } + let thread = unsafe { windows::Win32::System::Threading::GetCurrentThreadId() }; + if let Ok(hook) = + unsafe { SetWindowsHookExW(WH_GETMESSAGE, Some(get_message_hook), None, thread) } + { + KEY_HOOK.with(|k| k.set(hook.0 as isize)); + } +} + +fn register_class_once() -> napi::Result<()> { + static RESULT: OnceLock> = OnceLock::new(); + RESULT + .get_or_init(|| unsafe { + let hinstance = + GetModuleHandleW(None).map_err(|e| format!("GetModuleHandleW failed: {e}"))?; + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + lpfnWndProc: Some(wnd_proc), + hInstance: hinstance.into(), + hCursor: LoadCursorW(None, IDC_ARROW).unwrap_or_default(), + lpszClassName: CLASS_NAME, + ..Default::default() + }; + if RegisterClassExW(&wc) == 0 { + Err("RegisterClassExW returned 0".to_string()) + } else { + Ok(()) + } + }) + .clone() + .map_err(napi::Error::from_reason) +} + +fn err(msg: impl Into) -> napi::Error { + napi::Error::from_reason(msg.into()) +} + +/// A window handle from a number, bigint, pointer external or Buffer; `None` for null/undefined. +pub fn hwnd_from_js(env: &Env, v: &JsUnknown) -> napi::Result> { + let raw: isize = match v.get_type()? { + ValueType::Null | ValueType::Undefined => return Ok(None), + ValueType::Number => { + let n: f64 = unsafe { v.cast::() }.get_double()?; + n as i64 as isize + } + ValueType::BigInt => { + let (n, _) = unsafe { v.cast::() }.get_i64()?; + n as isize + } + ValueType::External => crate::napi_engine::value::ptr_from_external(env, v) + .map(|p| p as isize) + .ok_or_else(|| err("window handle external is not a pointer"))?, + ValueType::Object if v.is_buffer()? => { + let buf = unsafe { v.cast::() }.into_value()?; + let bytes: &[u8] = &buf; + let n = std::mem::size_of::(); + if bytes.len() < n { + return Err(err("window handle buffer is too short")); + } + isize::from_le_bytes(bytes[..n].try_into().unwrap()) + } + _ => return Err(err("expected a window handle (number, bigint or Buffer)")), + }; + Ok(if raw == 0 { + None + } else { + Some(HWND(raw as *mut c_void)) + }) +} + +fn required_hwnd(env: &Env, v: &JsUnknown, what: &str) -> napi::Result { + hwnd_from_js(env, v)?.ok_or_else(|| err(format!("{what}: a window handle is required"))) +} + +fn opt_prop( + obj: &JsObject, + name: &str, + read: impl FnOnce(JsUnknown) -> napi::Result, +) -> napi::Result> { + if !obj.has_named_property(name)? { + return Ok(None); + } + let v: JsUnknown = obj.get_named_property(name)?; + match v.get_type()? { + ValueType::Undefined | ValueType::Null => Ok(None), + _ => read(v).map(Some), + } +} + +fn read_bool(v: JsUnknown) -> napi::Result { + v.coerce_to_bool()?.get_value() +} + +fn read_f64(v: JsUnknown) -> napi::Result { + v.coerce_to_number()?.get_double() +} + +fn read_string(v: JsUnknown) -> napi::Result { + v.coerce_to_string()?.into_utf8()?.into_owned() +} + +fn set_dwm_u32(hwnd: HWND, attr: DWMWINDOWATTRIBUTE, value: u32) { + let _ = unsafe { + DwmSetWindowAttribute( + hwnd, + attr, + (&value as *const u32).cast(), + std::mem::size_of::() as u32, + ) + }; +} + +/// Create a hidden host window for `source` (a `DesktopWindowXamlSource`); returns its HWND. +/// Options: `style` (`"dialog"` | `"popup"`), `owner`, `title`, `topmost`, `dark`. +pub fn create(env: &Env, source: &JsUnknown, options: Option) -> napi::Result { + register_class_once()?; + let source_ptr = match source.get_type()? { + ValueType::Object | ValueType::Function => { + try_get_external_handle(env, &unsafe { source.cast::() }) + } + _ => None, + } + .filter(|p| !p.is_null()) + .ok_or_else(|| err("xamlHostCreate: source is not a DesktopWindowXamlSource"))?; + let source_unknown = unsafe { IUnknown::from_raw_borrowed(&source_ptr) } + .cloned() + .ok_or_else(|| err("xamlHostCreate: source is null"))?; + let native = SourceNative::from_source(&source_unknown).map_err(|e| { + err(format!( + "xamlHostCreate: source is not a DesktopWindowXamlSource: {e}" + )) + })?; + + let mut style_name = String::from("dialog"); + let (mut owner, mut title, mut topmost, mut dark) = (None, String::new(), false, false); + if let Some(o) = &options { + style_name = opt_prop(o, "style", read_string)?.unwrap_or(style_name); + if o.has_named_property("owner")? { + owner = hwnd_from_js(env, &o.get_named_property::("owner")?)?; + } + title = opt_prop(o, "title", read_string)?.unwrap_or_default(); + topmost = opt_prop(o, "topmost", read_bool)?.unwrap_or(false); + dark = opt_prop(o, "dark", read_bool)?.unwrap_or(false); + } + let popup = match style_name.as_str() { + "dialog" => false, + "popup" => true, + other => return Err(err(format!("xamlHostCreate: unknown style '{other}'"))), + }; + + let (style, mut ex_style) = if popup { + (WS_POPUP | WS_CLIPCHILDREN, WS_EX_TOOLWINDOW) + } else { + ( + WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN, + WS_EX_DLGMODALFRAME, + ) + }; + if topmost { + ex_style |= WS_EX_TOPMOST; + } + + let hwnd = unsafe { + let hinstance = + GetModuleHandleW(None).map_err(|e| err(format!("GetModuleHandleW failed: {e}")))?; + CreateWindowExW( + ex_style, + CLASS_NAME, + &windows::core::HSTRING::from(title.as_str()), + style, + 0, + 0, + 400, + 300, + owner, + None, + Some(hinstance.into()), + None, + ) + } + .map_err(|e| err(format!("CreateWindowExW failed: {e}")))?; + + if dark { + set_dwm_u32(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, 1); + } + if popup { + set_dwm_u32(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, 2); // DWMWCP_ROUND + } + + let island = match native.attach(hwnd) { + Ok(island) => island, + Err(e) => { + let _ = unsafe { DestroyWindow(hwnd) }; + return Err(err(format!("DesktopWindowXamlSource attach failed: {e}"))); + } + }; + HOSTS.with(|h| { + h.borrow_mut().insert( + key(hwnd), + Host { + island, + source: native, + listener: None, + }, + ) + }); + install_key_hook(); + + let mut rc = RECT::default(); + let _ = unsafe { GetClientRect(hwnd, &mut rc) }; + let _ = unsafe { + SetWindowPos( + island, + None, + 0, + 0, + rc.right, + rc.bottom, + SWP_NOZORDER | SWP_NOACTIVATE, + ) + }; + let _ = unsafe { ShowWindow(island, SW_SHOW) }; + Ok(hwnd.0 as isize as f64) +} + +fn host_hwnd(env: &Env, v: &JsUnknown, what: &str) -> napi::Result { + let hwnd = required_hwnd(env, v, what)?; + if HOSTS.with(|h| h.borrow().contains_key(&key(hwnd))) { + Ok(hwnd) + } else { + Err(err(format!("{what}: not a live XAML host window"))) + } +} + +/// Set the listener for `"activate"`, `"deactivate"`, `"close"`, `"closed"`; `null` removes it. +pub fn set_listener(env: &Env, hwnd: &JsUnknown, func: Option) -> napi::Result<()> { + let hwnd = host_hwnd(env, hwnd, "xamlHostSetListener")?; + let listener = match func { + Some(f) => { + let mut r: sys::napi_ref = std::ptr::null_mut(); + let status = unsafe { sys::napi_create_reference(env.raw(), f.raw(), 1, &mut r) }; + if status != sys::Status::napi_ok { + return Err(err("xamlHostSetListener: could not reference the function")); + } + Some(Listener { + env: env.raw(), + func: r, + }) + } + None => None, + }; + let old = HOSTS.with(|h| { + let mut h = h.borrow_mut(); + h.get_mut(&key(hwnd)) + .and_then(|host| std::mem::replace(&mut host.listener, listener)) + }); + if let Some(old) = old { + unsafe { sys::napi_delete_reference(old.env, old.func) }; + } + Ok(()) +} + +fn monitor_work_area(monitor: HMONITOR) -> RECT { + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let _ = unsafe { GetMonitorInfoW(monitor, &mut info) }; + info.rcWork +} + +/// Size the client area in DIPs; center over `over`, or on the pointer's monitor at `y` (0..1). +pub fn place(env: &Env, hwnd: &JsUnknown, options: JsObject) -> napi::Result<()> { + let hwnd = host_hwnd(env, hwnd, "xamlHostPlace")?; + let width = opt_prop(&options, "width", read_f64)?.unwrap_or(400.0); + let height = opt_prop(&options, "height", read_f64)?.unwrap_or(300.0); + let y_fraction = opt_prop(&options, "y", read_f64)? + .unwrap_or(0.5) + .clamp(0.0, 1.0); + let over = if options.has_named_property("over")? { + hwnd_from_js(env, &options.get_named_property::("over")?)? + .filter(|w| unsafe { IsWindow(Some(*w)) }.as_bool()) + } else { + None + }; + + let monitor = match over { + Some(w) => unsafe { MonitorFromWindow(w, MONITOR_DEFAULTTONEAREST) }, + None => { + let mut pt = POINT::default(); + let _ = unsafe { GetCursorPos(&mut pt) }; + unsafe { MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST) } + } + }; + let (mut dpi, mut dpi_y) = (96u32, 96u32); + let _ = unsafe { GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut dpi, &mut dpi_y) }; + let scale = dpi as f64 / 96.0; + + let style = WINDOW_STYLE(unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32); + let ex_style = WINDOW_EX_STYLE(unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) } as u32); + let mut frame = RECT { + left: 0, + top: 0, + right: (width * scale).round() as i32, + bottom: (height * scale).round() as i32, + }; + let _ = unsafe { AdjustWindowRectExForDpi(&mut frame, style, false, ex_style, dpi) }; + let (fw, fh) = (frame.right - frame.left, frame.bottom - frame.top); + + let work = monitor_work_area(monitor); + let area = match over { + Some(w) => { + let mut r = RECT::default(); + let _ = unsafe { GetWindowRect(w, &mut r) }; + r + } + None => work, + }; + let mut x = area.left + (area.right - area.left - fw) / 2; + let mut y = match over { + Some(_) => area.top + (area.bottom - area.top - fh) / 2, + None => area.top + ((area.bottom - area.top - fh) as f64 * y_fraction) as i32, + }; + x = x.clamp(work.left, (work.right - fw).max(work.left)); + y = y.clamp(work.top, (work.bottom - fh).max(work.top)); + unsafe { SetWindowPos(hwnd, None, x, y, fw, fh, SWP_NOZORDER | SWP_NOACTIVATE) } + .map_err(|e| err(format!("SetWindowPos failed: {e}"))) +} + +/// Show the window; with `activate` it takes the foreground and keyboard focus. +pub fn show(env: &Env, hwnd: &JsUnknown, activate: bool) -> napi::Result { + let hwnd = host_hwnd(env, hwnd, "xamlHostShow")?; + unsafe { + if !activate { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + return Ok(true); + } + let _ = ShowWindow(hwnd, SW_SHOW); + let foreground = SetForegroundWindow(hwnd).as_bool(); + if let Some(island) = island_of(hwnd) { + let _ = SetFocus(Some(island)); + } + Ok(foreground) + } +} + +pub fn hide(env: &Env, hwnd: &JsUnknown) -> napi::Result<()> { + let hwnd = host_hwnd(env, hwnd, "xamlHostHide")?; + let _ = unsafe { ShowWindow(hwnd, SW_HIDE) }; + Ok(()) +} + +pub fn is_visible(env: &Env, hwnd: &JsUnknown) -> napi::Result { + let hwnd = host_hwnd(env, hwnd, "xamlHostIsVisible")?; + Ok(unsafe { IsWindowVisible(hwnd) }.as_bool()) +} + +/// Destroy the window; its listener still receives `"closed"`. +pub fn destroy(env: &Env, hwnd: &JsUnknown) -> napi::Result<()> { + let Some(hwnd) = hwnd_from_js(env, hwnd)? else { + return Ok(()); + }; + if HOSTS.with(|h| h.borrow().contains_key(&key(hwnd))) { + let _ = unsafe { DestroyWindow(hwnd) }; + } + Ok(()) +} + +pub fn foreground_window() -> f64 { + unsafe { GetForegroundWindow() }.0 as isize as f64 +} + +/// Bring `hwnd` to the foreground, if Windows lets this process do so. +pub fn activate_window(env: &Env, hwnd: &JsUnknown) -> napi::Result { + let Some(hwnd) = hwnd_from_js(env, hwnd)? else { + return Ok(false); + }; + Ok(unsafe { IsWindow(Some(hwnd)).as_bool() && SetForegroundWindow(hwnd).as_bool() }) +} + +pub fn enable_window(env: &Env, hwnd: &JsUnknown, enabled: bool) -> napi::Result<()> { + let hwnd = required_hwnd(env, hwnd, "enableWindow")?; + let _ = unsafe { EnableWindow(hwnd, enabled) }; + Ok(()) +} + +pub fn window_process_id(env: &Env, hwnd: &JsUnknown) -> napi::Result { + let Some(hwnd) = hwnd_from_js(env, hwnd)? else { + return Ok(0); + }; + let mut pid = 0u32; + unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)) }; + Ok(pid) +} diff --git a/windows-napi/README.md b/windows-napi/README.md index 52012ba..1e7dbc4 100644 --- a/windows-napi/README.md +++ b/windows-napi/README.md @@ -16,6 +16,32 @@ const winrt = require('@nativescript/windows-napi'); Requires Windows (x64 or arm64) and Node >= 18 (or a Node-API-compatible runtime such as Bun or Deno). +## XAML windows + +`XamlWindow` hosts `Windows.UI.Xaml` content (XAML Islands) in a Win32 window: + +```js +const { Windows, XamlWindow } = require('@nativescript/windows-napi'); + +const win = new XamlWindow({ style: 'dialog', owner: browserWindow.getNativeWindowHandle() }); +const button = new Windows.UI.Xaml.Controls.Button(); +button.Content = 'OK'; +button.Click = () => win.close(); +win.content = button; +win.place({ width: 320, height: 120, over: browserWindow.getNativeWindowHandle() }); +win.show(); +``` + +- `style`: `'dialog'` (title bar, close box) or `'popup'` (borderless, not in the taskbar or Alt+Tab). +- Options: `owner`, `title`, `topmost`, `theme` (`'system'`, `'dark'`, `'light'`). +- Events: `activate`, `deactivate`, `close` (title-bar X or Alt+F4; closes when unhandled), `closed`. +- Helpers: `XamlWindow.foregroundWindow()`, `activateWindow(hwnd)`, `enableWindow(hwnd, enabled)`, + `windowProcessId(hwnd)`. + +The host executable's manifest must declare `` or later; +`electron.exe` does and `node.exe` doesn't, so `XamlWindow` throws in plain Node. XAML events run +from the host's message loop, so a host with its own UI loop needs no `pumpMessages`. + ## Development ```sh diff --git a/windows-napi/index.d.ts b/windows-napi/index.d.ts index 0fcb83e..7188d82 100644 --- a/windows-napi/index.d.ts +++ b/windows-napi/index.d.ts @@ -107,6 +107,29 @@ export declare function pollWindowEvents(hwnd: unknown): object export declare function getWindowSize(hwnd: unknown): unknown /** Set the title-bar text of a `createWindow` window. */ export declare function setWindowTitle(hwnd: unknown, title: string): void +/** + * Create a hidden window hosting a `DesktopWindowXamlSource`; returns its HWND as a number. + * Options: `style` (`"dialog"` | `"popup"`), `owner`, `title`, `topmost`, `dark`. + */ +export declare function xamlHostCreate(source: unknown, options?: object | undefined | null): number +/** Set the listener for `"activate"`, `"deactivate"`, `"close"`, `"closed"`; `null` removes it. */ +export declare function xamlHostSetListener(hwnd: unknown, listener?: (...args: any[]) => any | undefined | null): void +/** Size the client area in DIPs; center over `over`, or on the pointer's monitor at `y` (0..1). */ +export declare function xamlHostPlace(hwnd: unknown, options: object): void +/** Show the host; with `activate` it takes the foreground. Returns whether it is in front. */ +export declare function xamlHostShow(hwnd: unknown, activate: boolean): boolean +export declare function xamlHostHide(hwnd: unknown): void +export declare function xamlHostIsVisible(hwnd: unknown): boolean +/** Destroy the host window (its listener still receives `"closed"`). */ +export declare function xamlHostDestroy(hwnd: unknown): void +/** The foreground window (any process) as a number, or 0. */ +export declare function foregroundWindow(): number +/** Bring a window to the foreground; `false` when Windows refuses. */ +export declare function activateWindow(hwnd: unknown): boolean +/** Enable or disable input to a window (used to make an owner modal). */ +export declare function enableWindow(hwnd: unknown, enabled: boolean): void +/** The id of the process that owns a window, or 0. */ +export declare function windowProcessId(hwnd: unknown): number /** * Whether the WinRT class `name` is sealed (metadata flag). Test hook: lets suites assert * they are really covering the composable (non-sealed, null-outer) constructor path. diff --git a/windows-napi/index.js b/windows-napi/index.js index 7ff279d..f3042e0 100644 --- a/windows-napi/index.js +++ b/windows-napi/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { formatValue, tableFor, makeDelegate, invokeDelegate, releaseDelegate, callStaticMethod, callInstanceMethod, makeNativeProxy, ffiRoundtrip, pointerValue, makeExternal, bufferInfo, structPtr, writeReadPtr, outParamValue, setOutParam, structFieldBytes, boxTyped, pumpMessages, lastError, installGlobals, getNamespace, nsUuid, createWindow, attachCompositorToWindow, pollWindowEvents, getWindowSize, setWindowTitle, classIsSealed, registerWinmd, scanWinmdDir, arrayBufferFromBuffer, installInterop, installDotnet } = nativeBinding +const { formatValue, tableFor, makeDelegate, invokeDelegate, releaseDelegate, callStaticMethod, callInstanceMethod, makeNativeProxy, ffiRoundtrip, pointerValue, makeExternal, bufferInfo, structPtr, writeReadPtr, outParamValue, setOutParam, structFieldBytes, boxTyped, pumpMessages, lastError, installGlobals, getNamespace, nsUuid, createWindow, attachCompositorToWindow, pollWindowEvents, getWindowSize, setWindowTitle, xamlHostCreate, xamlHostSetListener, xamlHostPlace, xamlHostShow, xamlHostHide, xamlHostIsVisible, xamlHostDestroy, foregroundWindow, activateWindow, enableWindow, windowProcessId, classIsSealed, registerWinmd, scanWinmdDir, arrayBufferFromBuffer, installInterop, installDotnet } = nativeBinding module.exports.formatValue = formatValue module.exports.tableFor = tableFor @@ -340,6 +340,17 @@ module.exports.attachCompositorToWindow = attachCompositorToWindow module.exports.pollWindowEvents = pollWindowEvents module.exports.getWindowSize = getWindowSize module.exports.setWindowTitle = setWindowTitle +module.exports.xamlHostCreate = xamlHostCreate +module.exports.xamlHostSetListener = xamlHostSetListener +module.exports.xamlHostPlace = xamlHostPlace +module.exports.xamlHostShow = xamlHostShow +module.exports.xamlHostHide = xamlHostHide +module.exports.xamlHostIsVisible = xamlHostIsVisible +module.exports.xamlHostDestroy = xamlHostDestroy +module.exports.foregroundWindow = foregroundWindow +module.exports.activateWindow = activateWindow +module.exports.enableWindow = enableWindow +module.exports.windowProcessId = windowProcessId module.exports.classIsSealed = classIsSealed module.exports.registerWinmd = registerWinmd module.exports.scanWinmdDir = scanWinmdDir diff --git a/windows-napi/nswinrt.js b/windows-napi/nswinrt.js index c0c9542..d6fa2d0 100644 --- a/windows-napi/nswinrt.js +++ b/windows-napi/nswinrt.js @@ -113,6 +113,97 @@ const interop = globalThis.NSWinRT.interop; native.installDotnet(); const dotnet = globalThis.NSWinRT.dotnet; +// Windows.UI.Xaml content in a Win32 window (XAML Islands). Needs a thread that pumps messages. +let xamlManager = null; + +function systemTheme() { + const vm = Windows.UI.ViewManagement; + const bg = new vm.UISettings().GetColorValue(vm.UIColorType.Background); + return bg.R < 128 ? 'dark' : 'light'; +} + +class XamlWindow { + /** + * @param {object} [options] + * @param {'dialog'|'popup'} [options.style] dialog: title bar and close box; popup: borderless, + * not in the taskbar or Alt+Tab. + * @param {number|bigint|Buffer} [options.owner] owner window handle. + * @param {string} [options.title] + * @param {boolean} [options.topmost] + * @param {'system'|'dark'|'light'} [options.theme] title bar theme; read it back from `theme`. + */ + constructor(options = {}) { + if (!xamlManager) { + try { + xamlManager = Windows.UI.Xaml.Hosting.WindowsXamlManager.InitializeForCurrentThread(); + } catch (e) { + if (!/0x8000FFFF/i.test(String(e && e.message))) throw e; + throw new Error('XamlWindow: XAML Islands are unavailable in this process. The host ' + + 'executable\'s manifest must declare or later.'); + } + } + const { theme = 'system', ...rest } = options; + this.theme = theme === 'system' ? systemTheme() : theme; + this.source = new Windows.UI.Xaml.Hosting.DesktopWindowXamlSource(); + this.hwnd = native.xamlHostCreate(this.source, { ...rest, dark: this.theme === 'dark' }); + this.closed = false; + this._handlers = { activate: [], deactivate: [], close: [], closed: [] }; + native.xamlHostSetListener(this.hwnd, (type) => this._emit(type)); + } + + get content() { return this.source.Content; } + set content(element) { this.source.Content = element; } + + get visible() { return !this.closed && native.xamlHostIsVisible(this.hwnd); } + + /** `activate`, `deactivate`, `close` (title-bar X or Alt+F4; closes when unhandled), `closed`. */ + on(type, fn) { + if (!this._handlers[type]) throw new TypeError(`XamlWindow: unknown event '${type}'`); + this._handlers[type].push(fn); + return this; + } + + off(type, fn) { + const list = this._handlers[type]; + if (list && list.includes(fn)) list.splice(list.indexOf(fn), 1); + return this; + } + + /** Client size in DIPs; centered over `over`, or on the pointer's monitor at `y` (0..1). */ + place({ width, height, over, y } = {}) { + native.xamlHostPlace(this.hwnd, { width, height, over, y }); + return this; + } + + /** Returns whether the window is in the foreground. */ + show({ activate = true } = {}) { return native.xamlHostShow(this.hwnd, activate); } + + hide() { if (!this.closed) native.xamlHostHide(this.hwnd); } + + close() { if (!this.closed) native.xamlHostDestroy(this.hwnd); } + + _emit(type) { + if (type === 'closed') { + this.closed = true; + try { this.source.Close(); } catch {} + } + const handlers = this._handlers[type].slice(); + if (type === 'close' && handlers.length === 0) this.close(); + for (const fn of handlers) { + // Rethrown outside the native callback so the host reports it as uncaught. + try { fn(); } catch (e) { setTimeout(() => { throw e; }); } + } + } + + static systemTheme() { return systemTheme(); } + /** The foreground window of any process, as a number (0 if none). */ + static foregroundWindow() { return native.foregroundWindow(); } + /** Bring a window to the foreground; `false` if Windows refuses. */ + static activateWindow(hwnd) { return native.activateWindow(hwnd); } + static enableWindow(hwnd, enabled) { native.enableWindow(hwnd, enabled); } + static windowProcessId(hwnd) { return native.windowProcessId(hwnd); } +} + module.exports = { native, Windows, @@ -122,4 +213,5 @@ module.exports = { disableAutoPump, interop, dotnet, + XamlWindow, }; diff --git a/windows-napi/src/lib.rs b/windows-napi/src/lib.rs index 38444c9..755fd82 100644 --- a/windows-napi/src/lib.rs +++ b/windows-napi/src/lib.rs @@ -87,6 +87,83 @@ pub fn set_window_title(env: Env, hwnd: napi::JsUnknown, title: String) -> napi: runtime::napi_engine::composition_window::set_window_title(&env, &hwnd, &title) } +/// Create a hidden window hosting a `DesktopWindowXamlSource`; returns its HWND as a number. +/// Options: `style` (`"dialog"` | `"popup"`), `owner`, `title`, `topmost`, `dark`. +#[napi] +pub fn xaml_host_create( + env: Env, + source: napi::JsUnknown, + options: Option, +) -> napi::Result { + runtime::napi_engine::xaml_host::create(&env, &source, options) +} + +/// Set the listener for `"activate"`, `"deactivate"`, `"close"`, `"closed"`; `null` removes it. +#[napi] +pub fn xaml_host_set_listener( + env: Env, + hwnd: napi::JsUnknown, + listener: Option, +) -> napi::Result<()> { + runtime::napi_engine::xaml_host::set_listener(&env, &hwnd, listener) +} + +/// Size the client area in DIPs; center over `over`, or on the pointer's monitor at `y` (0..1). +#[napi] +pub fn xaml_host_place( + env: Env, + hwnd: napi::JsUnknown, + options: napi::JsObject, +) -> napi::Result<()> { + runtime::napi_engine::xaml_host::place(&env, &hwnd, options) +} + +/// Show the host; with `activate` it takes the foreground. Returns whether it is in front. +#[napi] +pub fn xaml_host_show(env: Env, hwnd: napi::JsUnknown, activate: bool) -> napi::Result { + runtime::napi_engine::xaml_host::show(&env, &hwnd, activate) +} + +#[napi] +pub fn xaml_host_hide(env: Env, hwnd: napi::JsUnknown) -> napi::Result<()> { + runtime::napi_engine::xaml_host::hide(&env, &hwnd) +} + +#[napi] +pub fn xaml_host_is_visible(env: Env, hwnd: napi::JsUnknown) -> napi::Result { + runtime::napi_engine::xaml_host::is_visible(&env, &hwnd) +} + +/// Destroy the host window (its listener still receives `"closed"`). +#[napi] +pub fn xaml_host_destroy(env: Env, hwnd: napi::JsUnknown) -> napi::Result<()> { + runtime::napi_engine::xaml_host::destroy(&env, &hwnd) +} + +/// The foreground window (any process) as a number, or 0. +#[napi] +pub fn foreground_window() -> f64 { + runtime::napi_engine::xaml_host::foreground_window() +} + +/// Bring a window to the foreground; `false` when Windows refuses. +#[napi] +pub fn activate_window(env: Env, hwnd: napi::JsUnknown) -> napi::Result { + runtime::napi_engine::xaml_host::activate_window(&env, &hwnd) +} + +/// Enable or disable input to a window (used to make an owner modal). +#[napi] +pub fn enable_window(env: Env, hwnd: napi::JsUnknown, enabled: bool) -> napi::Result<()> { + runtime::napi_engine::xaml_host::enable_window(&env, &hwnd, enabled) +} + +/// The id of the process that owns a window, or 0. +#[napi] +pub fn window_process_id(env: Env, hwnd: napi::JsUnknown) -> napi::Result { + runtime::napi_engine::xaml_host::window_process_id(&env, &hwnd) +} + /// Whether the WinRT class `name` is sealed (metadata flag). Test hook: lets suites assert /// they are really covering the composable (non-sealed, null-outer) constructor path. #[napi] diff --git a/windows-napi/test/xaml-host-test.js b/windows-napi/test/xaml-host-test.js new file mode 100644 index 0000000..2ffb809 --- /dev/null +++ b/windows-napi/test/xaml-host-test.js @@ -0,0 +1,70 @@ +// Agile delegates (DispatcherQueueTimer.Tick) and XamlWindow: XAML Islands in a Win32 window. +const { Windows, XamlWindow, native } = require('../nswinrt.js'); + +let pass = 0, fail = 0; +function check(name, got, expected) { + const ok = typeof expected === 'function' ? expected(got) : Object.is(got, expected); + if (ok) pass++; + else { console.log(`FAIL ${name}: got ${String(got)}, expected ${String(expected)}`); fail++; } +} + +async function pumpUntil(cond, ms = 3000) { + const start = Date.now(); + while (!cond() && Date.now() - start < ms) { + native.pumpMessages(); + await new Promise((r) => setImmediate(r)); + } + return cond(); +} + +(async () => { + // DispatcherQueueTimer only accepts agile handlers. + const timer = Windows.System.DispatcherQueue.GetForCurrentThread().CreateTimer(); + timer.Interval = { Duration: 200000 }; // 20ms + timer.IsRepeating = false; + let ticks = 0; + let added = true; + try { timer.Tick = () => { ticks++; }; } catch (e) { added = false; console.log(e.message); } + check('Tick accepts a JS handler', added, true); + timer.Start(); + check('Tick fires', await pumpUntil(() => ticks > 0), true); + + let win; + try { + win = new XamlWindow({ style: 'popup', topmost: true, theme: 'dark' }); + } catch (e) { + // XAML Islands need maxversiontested in the host manifest; without it, check the error. + check('clear error without island support', /maxversiontested/.test(e.message), true); + console.log('XamlWindow checks skipped: this host cannot run XAML Islands'); + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); + } + check('hwnd is a number', typeof win.hwnd === 'number' && win.hwnd > 0, true); + check('theme resolved', win.theme, 'dark'); + const text = new Windows.UI.Xaml.Controls.TextBlock(); + text.Text = 'island'; + win.content = text; + check('content set', win.content.Text, 'island'); + + const events = []; + win.on('closed', () => events.push('closed')); + win.place({ width: 320, height: 80, y: 0.25 }); + check('show without activation', win.show({ activate: false }), true); + check('visible after show', win.visible, true); + win.hide(); + check('hidden after hide', win.visible, false); + + let threw = false; + try { native.xamlHostShow(12345, true); } catch { threw = true; } + check('unknown handle rejected', threw, true); + check('foregroundWindow is a number', typeof XamlWindow.foregroundWindow(), 'number'); + check('own pid', XamlWindow.windowProcessId(win.hwnd), process.pid); + + win.close(); + check('closed event', await pumpUntil(() => events.includes('closed')), true); + check('closed flag', win.closed, true); + check('not visible once closed', win.visible, false); + + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +})();