use std::ffi::c_void;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::ptr::NonNull;
use std::sync::{Arc, Once};
use frust_plugin::desktop::{DesktopViewFactory, DesktopViewHandle, register_view_factory};
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2_app_kit::NSView;
use super::{NativeCtx, theme};
use crate::NativeWidgetError;
use crate::runtime::{self, Params, UpdateOutcome};
pub(crate) const VIEW_TYPE: &str = "dev.frust.nativewidgets.FrustNativeControlFactory";
pub(crate) struct AppKitFactory;
impl DesktopViewFactory for AppKitFactory {
fn create(&self, params_json: &str) -> Option<DesktopViewHandle> {
let Some(mtm) = MainThreadMarker::new() else {
log::error!("{OFF_MAIN_CREATE_MESSAGE}");
return None;
};
let created = catch_unwind(AssertUnwindSafe(|| create_control(mtm, params_json)));
let view = match created {
Ok(Some(view)) => view,
Ok(None) => dead_slot_view(mtm),
Err(_) => {
log::warn!("{PANIC_MESSAGE_CREATE}");
dead_slot_view(mtm)
}
};
theme::apply_brightness(&view, theme::brightness_is_dark(params_json));
into_handle(view)
}
fn update_params(&self, _view: &DesktopViewHandle, params_json: &str) {
let outcome = catch_unwind(AssertUnwindSafe(|| match MainThreadMarker::new() {
Some(mtm) => update_control(mtm, params_json),
None => log::error!(
"frust-native-widgets: macOS update_params called off the main thread — ignored"
),
}));
if outcome.is_err() {
log::warn!("{PANIC_MESSAGE_UPDATE}");
}
}
fn dispose(&self, view: DesktopViewHandle) {
let ptr = view.into_raw();
let Some(mtm) = MainThreadMarker::new() else {
log::error!(
"frust-native-widgets: macOS dispose called off the main thread — leaking the \
view's retain rather than releasing it off-main"
);
return;
};
let outcome = catch_unwind(AssertUnwindSafe(|| dispose_control(mtm, ptr)));
if outcome.is_err() {
log::warn!("{PANIC_MESSAGE_DISPOSE}");
}
}
}
pub(crate) fn ensure_registered() {
static ONCE: Once = Once::new();
ONCE.call_once(
|| match register_view_factory(VIEW_TYPE, Arc::new(AppKitFactory)) {
Ok(()) => log::info!(
"frust-native-widgets: registered the macOS desktop view factory for {VIEW_TYPE:?}"
),
Err(error) => log::debug!(
"frust-native-widgets: macOS desktop view factory not registered ({error}) — the \
incumbent keeps {VIEW_TYPE:?}"
),
},
);
}
fn create_control(mtm: MainThreadMarker, params: &str) -> Option<Retained<NSView>> {
let outcome = runtime::with_runtime(|runtime| {
let mut ctx = NativeCtx::new(mtm);
let slot_id = runtime.create(&mut ctx, params)?;
let view = runtime
.instance(slot_id)
.map(|instance| Retained::clone(instance.view().view(mtm)))
.ok_or_else(|| {
NativeWidgetError::Platform(format!(
"slot {slot_id} has no live instance right after a successful create"
))
})?;
Ok::<_, NativeWidgetError>((slot_id, view))
})
.unwrap_or_else(reentrant_create_failure);
match outcome {
Ok((slot_id, view)) => {
log::debug!("frust-native-widgets: macOS created slot {slot_id}");
Some(view)
}
Err(error) => {
log::warn!("{}", create_failure_message(&error));
None
}
}
}
fn update_control(mtm: MainThreadMarker, params: &str) {
let dark = theme::brightness_is_dark(params);
let outcome = runtime::with_runtime(|runtime| {
let mut ctx = NativeCtx::new(mtm);
let result = runtime.update_params(&mut ctx, params);
if let Ok(UpdateOutcome::Applied | UpdateOutcome::Unchanged) = result
&& let Ok((_, slot_id)) = Params::new(params).identity()
&& let Some(instance) = runtime.instance(slot_id)
{
theme::apply_brightness(instance.view().view(mtm), dark);
}
result
});
match outcome {
Some(Ok(UpdateOutcome::Applied | UpdateOutcome::Unchanged)) => {}
Some(Ok(UpdateOutcome::UnknownSlot)) => {
log::debug!("frust-native-widgets: macOS update_params for a slot with no instance");
}
Some(Err(error)) => log::warn!("frust-native-widgets: macOS update_params failed: {error}"),
None => log::debug!("frust-native-widgets: macOS update_params dropped (re-entrant)"),
}
}
fn dispose_control(mtm: MainThreadMarker, ptr: NonNull<c_void>) {
let target = ptr.as_ptr().cast::<NSView>().cast_const();
let reclaimed = unsafe { Retained::<NSView>::from_raw(ptr.as_ptr().cast()) };
let taken = runtime::with_runtime(|runtime| {
runtime.take_matching(|handle| same_view(Retained::as_ptr(handle.view(mtm)), target))
});
match taken {
Some(Some((slot_id, instance))) => {
let mut ctx = NativeCtx::new(mtm);
if let Err(error) = instance.dispose(&mut ctx) {
log::warn!("frust-native-widgets: macOS disposing slot {slot_id}: {error}");
}
let live = runtime::with_runtime(|runtime| runtime.live_count()).unwrap_or_default();
log::debug!(
"frust-native-widgets: macOS disposed slot {slot_id}; live controls={live}"
);
}
Some(None) => {
log::debug!("frust-native-widgets: macOS dispose for a view with no instance")
}
None => log::warn!(
"frust-native-widgets: macOS dispose dropped (re-entrant runtime) — the instance \
stays live until its slot is disposed again"
),
}
drop(reclaimed);
}
fn into_handle(view: Retained<NSView>) -> Option<DesktopViewHandle> {
let ptr = NonNull::new(Retained::into_raw(view).cast::<c_void>())?;
Some(unsafe { DesktopViewHandle::from_raw(ptr) })
}
fn dead_slot_view(mtm: MainThreadMarker) -> Retained<NSView> {
NSView::new(mtm)
}
fn same_view(a: *const NSView, b: *const NSView) -> bool {
std::ptr::eq(a, b)
}
fn reentrant_create_failure<T>() -> Result<T, NativeWidgetError> {
Err(NativeWidgetError::Platform(
"runtime re-entrant — a platform setter fired its own action during create".into(),
))
}
fn create_failure_message(error: &NativeWidgetError) -> String {
format!(
"frust-native-widgets: macOS create failed: {error} — returning an empty placeholder view \
(dead slot)"
)
}
const OFF_MAIN_CREATE_MESSAGE: &str = "frust-native-widgets: macOS create called off the main \
thread — declining (no NSView can be built off-main)";
const PANIC_MESSAGE_CREATE: &str = "frust-native-widgets: panic caught in macOS create — \
returning an empty placeholder view (dead slot)";
const PANIC_MESSAGE_UPDATE: &str =
"frust-native-widgets: panic caught in macOS update_params — the slot keeps its previous props";
const PANIC_MESSAGE_DISPOSE: &str =
"frust-native-widgets: panic caught in macOS dispose — the slot's native references may leak";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_view_type_is_the_wire_string_the_builders_publish() {
assert_eq!(
VIEW_TYPE,
"dev.frust.nativewidgets.FrustNativeControlFactory"
);
}
#[test]
fn create_failure_message_names_the_error_and_the_dead_slot() {
let message = create_failure_message(&NativeWidgetError::UnknownControl("bogus".into()));
assert!(message.contains("create failed"));
assert!(message.contains("bogus"));
assert!(message.contains("dead slot"));
}
#[test]
fn reentrancy_is_reported_distinctly() {
let Err(error) = reentrant_create_failure::<()>() else {
panic!("reentrant_create_failure must always return Err");
};
assert!(create_failure_message(&error).contains("re-entrant"));
}
#[test]
fn same_view_is_address_identity_and_never_dereferences() {
let a = 0x1000 as *const NSView;
let b = 0x2000 as *const NSView;
assert!(same_view(a, a));
assert!(!same_view(a, b));
assert!(!same_view(a, std::ptr::null()));
}
#[test]
fn create_off_the_main_thread_declines_instead_of_building_a_view() {
if MainThreadMarker::new().is_some() {
return;
}
assert!(AppKitFactory.create("{}").is_none());
}
}