diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 97ee5260ba..f9be9e11cd 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -982,6 +982,14 @@ pub(super) fn compile_module_entry( let pending = std::mem::take(&mut ctx.pending_declares); let buffer_alias_used = ctx.buffer_data_slots.len() as u32; let native_rep_records = std::mem::take(&mut ctx.native_rep_records); + let has_plugin_activate = hir + .exported_functions + .iter() + .any(|(name, _)| name == "activate"); + let has_plugin_deactivate = hir + .exported_functions + .iter() + .any(|(name, _)| name == "deactivate"); drop(ctx); llmod.ic_counter = ic_end; llmod.buffer_alias_counter += buffer_alias_used; @@ -989,6 +997,66 @@ pub(super) fn compile_module_entry( for (name, ret, params) in pending { llmod.declare_function(&name, ret, ¶ms); } + + // Plugin ABI shim — only emitted when the entry module is being + // built as a dylib (perry compile --output-type dylib). The host's + // `loadPlugin` calls `GetProcAddress(handle, "plugin_activate")` + // (Windows) / `dlsym(handle, "plugin_activate")` (macOS/Linux) to + // find the entry, so every dylib must export that name (and + // `plugin_deactivate` if the user supplied one). The shim unwraps + // the NaN-boxed `api` handle, calls the user's `activate(api)` + // with the raw pointer, and returns 1 on success / 0 if the + // module doesn't export `activate` (host treats that as load + // failure). `perry_plugin_abi_version` is the version the runtime + // checks against the host's expected ABI before calling activate + // — bump when the shim contract changes. + if is_dylib { + use crate::codegen::helpers::scoped_fn_name; + use crate::nanbox::{POINTER_MASK_I64, POINTER_TAG_I64}; + + { + let abi_fn = llmod.define_function("perry_plugin_abi_version", I64, vec![]); + let _ = abi_fn.create_block("entry"); + let blk = abi_fn.block_mut(0).unwrap(); + blk.ret(I64, "2"); + } + + if has_plugin_activate { + let user_activate = scoped_fn_name(module_prefix, "activate"); + llmod.declare_function(&user_activate, DOUBLE, &[DOUBLE]); + let fn_def = llmod.define_function( + "plugin_activate", + I64, + vec![(I64, "api_handle".to_string())], + ); + let _ = fn_def.create_block("entry"); + let blk = fn_def.block_mut(0).unwrap(); + let lower48 = blk.and(I64, "api_handle", POINTER_MASK_I64); + let tagged = blk.or(I64, &lower48, POINTER_TAG_I64); + let boxed = blk.bitcast_i64_to_double(&tagged); + let _ = blk.call(DOUBLE, &user_activate, &[(DOUBLE, &boxed)]); + blk.ret(I64, "1"); + } else { + let fn_def = llmod.define_function( + "plugin_activate", + I64, + vec![(I64, "_api_handle".to_string())], + ); + let _ = fn_def.create_block("entry"); + let blk = fn_def.block_mut(0).unwrap(); + blk.ret(I64, "0"); + } + + if has_plugin_deactivate { + let user_deactivate = scoped_fn_name(module_prefix, "deactivate"); + llmod.declare_function(&user_deactivate, DOUBLE, &[]); + let fn_def = llmod.define_function("plugin_deactivate", VOID, vec![]); + let _ = fn_def.create_block("entry"); + let blk = fn_def.block_mut(0).unwrap(); + blk.call_void(&user_deactivate, &[]); + blk.ret_void(); + } + } for ic_name in &ic_globals { llmod.add_raw_global(format!( "@{} = private global [2 x i64] zeroinitializer", diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index e5fc948479..6846b3ddd9 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -175,10 +175,14 @@ mimalloc = { version = "0.1", default-features = false } # Windows raw-mode for perry/tui input (#406). The Unix path uses # libc tcsetattr; Windows uses SetConsoleMode via windows-sys. +# `Win32_System_LibraryLoader` powers `perry_plugin_load` / `perry_plugin_unload` +# (`LoadLibraryW` / `GetProcAddress` / `FreeLibrary` — the Win32 equivalents of +# `libc::dlopen` / `dlsym` / `dlclose` that the Unix branch uses). [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = [ "Win32_System_Console", "Win32_Foundation", + "Win32_System_LibraryLoader", ] } # #855: libc::mach_host_self is deprecated; the mach2 crate is the diff --git a/crates/perry-runtime/src/plugin.rs b/crates/perry-runtime/src/plugin.rs index 854458bfe7..936fe9362f 100644 --- a/crates/perry-runtime/src/plugin.rs +++ b/crates/perry-runtime/src/plugin.rs @@ -42,6 +42,111 @@ const DEFAULT_PRIORITY: i32 = 10; struct LibHandle(*mut libc::c_void); unsafe impl Send for LibHandle {} +// ============================================================================ +// Platform-specific raw library load/sym/close — implemented per OS. +// +// The plugin ABI is the same on every platform (perry_plugin_abi_version, +// plugin_activate, plugin_deactivate + the per-handler FFI surface in +// `register_hook` / `register_tool` / etc. above), so the only divergence +// between Unix and Windows is the dynamic-loader API: +// - Unix: `libc::dlopen` / `libc::dlsym` / `libc::dlclose` (POSIX) +// - Windows: `LoadLibraryW` / `GetProcAddress` / `FreeLibrary` (Win32) +// +// The helpers below collapse those to a small uniform shape so the +// `perry_plugin_load` / `perry_plugin_unload` bodies don't have to fork. +// ============================================================================ + +#[cfg(unix)] +type RawLibHandle = *mut libc::c_void; + +#[cfg(windows)] +type RawLibHandle = windows_sys::Win32::Foundation::HMODULE; + +#[cfg(unix)] +unsafe fn open_library(path: &str) -> Option { + let c_path = CString::new(path).ok()?; + let h = libc::dlopen(c_path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL); + if h.is_null() { + None + } else { + Some(h) + } +} + +#[cfg(windows)] +unsafe fn open_library(path: &str) -> Option { + use windows_sys::Win32::System::LibraryLoader::LoadLibraryW; + // LoadLibraryW takes a NUL-terminated UTF-16 path. Encode once, free on return. + let wide: Vec = path.encode_utf16().chain(std::iter::once(0)).collect(); + let h = LoadLibraryW(wide.as_ptr()); + if h.is_null() { + None + } else { + Some(h) + } +} + +#[cfg(unix)] +unsafe fn lookup_symbol(handle: RawLibHandle, name: &str) -> Option<*mut libc::c_void> { + let c_name = CString::new(name).ok()?; + let p = libc::dlsym(handle, c_name.as_ptr()); + if p.is_null() { + None + } else { + Some(p) + } +} + +#[cfg(windows)] +unsafe fn lookup_symbol(handle: RawLibHandle, name: &str) -> Option<*mut libc::c_void> { + use windows_sys::Win32::System::LibraryLoader::GetProcAddress; + // GetProcAddress's `lpProcName` is a NUL-terminated *ANSI* string. The + // plugin ABI surface (perry_plugin_*, plugin_activate, plugin_deactivate) + // is all ASCII, so a CString is the right shape. GetProcAddress returns + // `Option isize>` — None means "symbol + // not found", which is what we want to forward up the call chain. + let c_name = CString::new(name).ok()?; + let p = GetProcAddress(handle, c_name.as_ptr() as *const u8); + match p { + Some(f) => Some(f as *mut libc::c_void), + None => None, + } +} + +#[cfg(unix)] +unsafe fn close_library(_handle: RawLibHandle) { + libc::dlclose(_handle); +} + +#[cfg(windows)] +unsafe fn close_library(handle: RawLibHandle) { + use windows_sys::Win32::Foundation::FreeLibrary; + let _ = FreeLibrary(handle); +} + +#[cfg(unix)] +fn last_load_error(path: &str) -> String { + unsafe { + let err = libc::dlerror(); + if err.is_null() { + format!("dlopen failed for {}", path) + } else { + format!( + "dlopen failed for {}: {}", + path, + CStr::from_ptr(err).to_string_lossy() + ) + } + } +} + +#[cfg(windows)] +fn last_load_error(path: &str) -> String { + use windows_sys::Win32::Foundation::GetLastError; + let err = unsafe { GetLastError() }; + format!("LoadLibraryW failed for {}: Win32 error {}", path, err) +} + struct PluginMetadata { name: String, version: String, @@ -51,7 +156,10 @@ struct PluginMetadata { struct PluginEntry { id: u64, path_name: String, - #[cfg(unix)] + // `*mut c_void` because the concrete handle type differs per OS + // (`*mut c_void` on Unix = `libc::dlopen` return; `HMODULE` on Windows + // = `*mut c_void` per windows-sys). Stored as a raw void pointer so the + // field's shape is platform-agnostic. lib_handle: LibHandle, activate_called: bool, metadata: Option, @@ -587,36 +695,31 @@ pub extern "C" fn perry_plugin_off(api_handle: i64, event: f64, handler: f64) -> // Host-side functions — called by the host application // ============================================================================ -/// Load a plugin from a shared library path -/// Returns the plugin ID (> 0) on success, 0 on failure -#[cfg(unix)] +/// Load a plugin from a shared library path. +/// Returns the plugin ID (> 0) on success, 0 on failure. +/// +/// Cross-platform: Unix uses `libc::dlopen` (POSIX), Windows uses +/// `LoadLibraryW` (Win32). Both paths converge through the +/// `open_library` / `lookup_symbol` / `close_library` / `last_load_error` +/// helpers above, so the activate/deactivate orchestration is shared. #[no_mangle] pub extern "C" fn perry_plugin_load(path_val: f64) -> i64 { let path_str = unsafe { extract_string(path_val) }; - let c_path = match CString::new(path_str.clone()) { - Ok(p) => p, - Err(_) => { - eprintln!("[plugin] Invalid path: {}", path_str); + let handle = match unsafe { open_library(&path_str) } { + Some(h) => h, + None => { + eprintln!("[plugin] {}", last_load_error(&path_str)); return 0; } }; unsafe { - let handle = libc::dlopen(c_path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL); - if handle.is_null() { - let err = libc::dlerror(); - if !err.is_null() { - let err_str = CStr::from_ptr(err).to_string_lossy(); - eprintln!("[plugin] dlopen failed for {}: {}", path_str, err_str); - } - return 0; - } - - // Check ABI version if available - let abi_sym = CString::new("perry_plugin_abi_version").unwrap(); - let abi_fn_ptr = libc::dlsym(handle, abi_sym.as_ptr()); - if !abi_fn_ptr.is_null() { + // Check ABI version if the plugin exports it. A plugin that omits + // this symbol still loads — version checks are an opt-in safety net + // for plugin authors, not a hard requirement (matches the Unix + // behavior pre-Windows-support). + if let Some(abi_fn_ptr) = lookup_symbol(handle, "perry_plugin_abi_version") { let abi_fn: extern "C" fn() -> u64 = std::mem::transmute(abi_fn_ptr); let version = abi_fn(); if version != PLUGIN_ABI_VERSION { @@ -624,19 +727,20 @@ pub extern "C" fn perry_plugin_load(path_val: f64) -> i64 { "[plugin] ABI version mismatch for {}: plugin={}, host={}", path_str, version, PLUGIN_ABI_VERSION ); - libc::dlclose(handle); + close_library(handle); return 0; } } - // Look up plugin_activate - let activate_sym = CString::new("plugin_activate").unwrap(); - let activate_ptr = libc::dlsym(handle, activate_sym.as_ptr()); - if activate_ptr.is_null() { - eprintln!("[plugin] No plugin_activate symbol in {}", path_str); - libc::dlclose(handle); - return 0; - } + // plugin_activate is mandatory — every plugin must export it. + let activate_ptr = match lookup_symbol(handle, "plugin_activate") { + Some(p) => p, + None => { + eprintln!("[plugin] No plugin_activate symbol in {}", path_str); + close_library(handle); + return 0; + } + }; let mut reg = REGISTRY.lock().unwrap(); let plugin_id = reg.alloc_plugin_id(); @@ -651,7 +755,7 @@ pub extern "C" fn perry_plugin_load(path_val: f64) -> i64 { reg.plugins.push(PluginEntry { id: plugin_id, path_name: name.clone(), - lib_handle: LibHandle(handle), + lib_handle: LibHandle(handle as *mut libc::c_void), activate_called: false, metadata: None, }); @@ -682,8 +786,7 @@ pub extern "C" fn perry_plugin_load(path_val: f64) -> i64 { } } -/// Unload a plugin by its ID -#[cfg(unix)] +/// Unload a plugin by its ID. Cross-platform: see `perry_plugin_load`. #[no_mangle] pub extern "C" fn perry_plugin_unload(plugin_id_val: i64) { let plugin_id = plugin_id_val as u64; @@ -711,13 +814,14 @@ pub extern "C" fn perry_plugin_unload(plugin_id_val: i64) { drop(reg); unsafe { - let deactivate_sym = CString::new("plugin_deactivate").unwrap(); - let deactivate_ptr = libc::dlsym(handle, deactivate_sym.as_ptr()); - if !deactivate_ptr.is_null() { + // plugin_deactivate is optional — a plugin that doesn't export it + // still unloads cleanly (the registry teardown already cleared + // its hooks/tools/services/routes/events). + if let Some(deactivate_ptr) = lookup_symbol(handle as RawLibHandle, "plugin_deactivate") { let deactivate_fn: extern "C" fn() = std::mem::transmute(deactivate_ptr); deactivate_fn(); } - libc::dlclose(handle); + close_library(handle as RawLibHandle); } eprintln!("[plugin] Unloaded: {} (id={})", name, plugin_id); diff --git a/crates/perry-ui-windows/src/ffi/widget_layout_extras.rs b/crates/perry-ui-windows/src/ffi/widget_layout_extras.rs index 839d83f743..78bf898804 100644 --- a/crates/perry-ui-windows/src/ffi/widget_layout_extras.rs +++ b/crates/perry-ui-windows/src/ffi/widget_layout_extras.rs @@ -1,195 +1,185 @@ -// FFI: widget sizing/layout/overlay/insets, button color, embed HWND, -// plugin stubs, QR code, scroll-refresh stubs, stack distribution. -use crate::{app, widgets}; - -/// Add a child widget at a specific index. -#[no_mangle] -pub extern "C" fn perry_ui_widget_add_child_at(parent_handle: i64, child_handle: i64, index: f64) { - widgets::add_child_at(parent_handle, child_handle, index as i64); - app::request_layout(); -} - -// ============================================================================= -// Stubs for symbols referenced by codegen but not yet implemented on Windows -// ============================================================================= - -/// Set button text color. -#[no_mangle] -pub extern "C" fn perry_ui_button_set_text_color(handle: i64, r: f64, g: f64, b: f64, a: f64) { - widgets::button::set_text_color(handle, r, g, b, a); -} - -/// Set widget width (DPI-scaled). -#[no_mangle] -pub extern "C" fn perry_ui_widget_set_width(handle: i64, width: f64) { - let scaled = (width * app::get_dpi_scale()) as i32; - widgets::set_fixed_width(handle, scaled); -} - -/// Set widget hugging priority. -#[no_mangle] -pub extern "C" fn perry_ui_widget_set_hugging(handle: i64, priority: f64) { - widgets::set_hugging_priority(handle, priority); -} - -/// Set on-click callback (stub — not yet implemented on Windows). -#[no_mangle] -pub extern "C" fn perry_ui_widget_set_on_click(handle: i64, callback: f64) { - let _ = handle; - #[cfg(feature = "geisterhand")] - { - extern "C" { - fn perry_geisterhand_register( - handle: i64, - widget_type: u8, - callback_kind: u8, - closure_f64: f64, - label_ptr: *const u8, - ); - } - unsafe { - perry_geisterhand_register(handle, 0, 0, callback, std::ptr::null()); - } - } -} - -/// Set widget height (fixed, DPI-scaled). -#[no_mangle] -pub extern "C" fn perry_ui_widget_set_height(handle: i64, height: f64) { - let scaled = (height * app::get_dpi_scale()) as i32; - widgets::set_fixed_height(handle, scaled); -} - -/// Match parent height — marks the widget to stretch vertically to fill its parent. -#[no_mangle] -pub extern "C" fn perry_ui_widget_match_parent_height(handle: i64) { - widgets::set_match_parent_height(handle, true); -} - -/// Match parent width — marks the widget to stretch horizontally to fill its parent. -#[no_mangle] -pub extern "C" fn perry_ui_widget_match_parent_width(handle: i64) { - widgets::set_match_parent_width(handle, true); -} - -/// Set hidden state (perry_ui_widget_set_hidden — matches macOS naming convention). -#[no_mangle] -pub extern "C" fn perry_ui_widget_set_hidden(handle: i64, hidden: i64) { - widgets::set_hidden(handle, hidden != 0); -} - -/// Stack: detach hidden children from layout calculation. -/// When enabled, hidden children don't occupy any space. -#[no_mangle] -pub extern "C" fn perry_ui_stack_set_detaches_hidden(handle: i64, flag: i64) { - widgets::set_detaches_hidden(handle, flag != 0); -} - -/// Embed a native HWND into the Perry widget system. -/// Takes the HWND pointer value and returns a 1-based widget handle. -/// The widget is marked as fills_remaining so it absorbs remaining space in VStack/HStack. -#[no_mangle] -pub extern "C" fn perry_ui_embed_nsview(hwnd_ptr: i64) -> i64 { - if hwnd_ptr == 0 { - return 0; - } - #[cfg(target_os = "windows")] - { - let hwnd = windows::Win32::Foundation::HWND(hwnd_ptr as *mut std::ffi::c_void); - let handle = widgets::register_widget(hwnd, widgets::WidgetKind::Canvas, 0); - widgets::set_fills_remaining(handle, true); - handle - } - #[cfg(not(target_os = "windows"))] - { - let _ = hwnd_ptr; - 0 - } -} - -/// Request location permission (stub — not available on Windows desktop). -#[no_mangle] -pub extern "C" fn perry_system_request_location(_callback: f64) {} - -/// Load a plugin (stub — not yet implemented on Windows). -#[no_mangle] -pub extern "C" fn perry_plugin_load(_path_ptr: i64) -> i64 { - 0 -} - -/// Unload a plugin (stub — not yet implemented on Windows). -#[no_mangle] -pub extern "C" fn perry_plugin_unload(_handle: i64) {} - -// NOTE: backOff, js_crypto_random_bytes_buffer, js_fetch_*, js_ws_handle_to_i64, -// and js_fetch_stream_status are provided by perry-stdlib. When linking the IDE -// (which uses both perry-stdlib and perry-ui-windows), these stubs caused -// duplicate symbol errors (LNK2005). Removed — perry-stdlib provides the real -// implementations. - -#[no_mangle] -pub extern "C" fn perry_ui_qrcode_create(data_ptr: i64, size: f64) -> i64 { - widgets::qrcode::create(data_ptr as *const u8, size) -} - -#[no_mangle] -pub extern "C" fn perry_ui_qrcode_set_data(handle: i64, data_ptr: i64) { - widgets::qrcode::set_data(handle, data_ptr as *const u8); -} - -#[no_mangle] -pub extern "C" fn perry_ui_scrollview_end_refreshing(_handle: i64) {} - -#[no_mangle] -pub extern "C" fn perry_ui_scrollview_set_refresh_control(_handle: i64, _callback: f64) {} - -#[no_mangle] -pub extern "C" fn perry_ui_stack_set_distribution(handle: i64, distribution: f64) { - // Dispatch declares this as `[Widget, F64]` (matches every other platform). - // The Windows runtime previously took `i64` — on Win64 ABI the f64 arg lands - // in XMM1 while `i64` is read from RDX (uninitialized garbage), so the - // distribution enum tag was random. - widgets::set_distribution(handle, distribution as i64); -} - -#[no_mangle] -pub extern "C" fn perry_ui_widget_reorder_child(_parent: i64, _child: i64, _index: i64) {} - -// perry_debug_trace_init and perry_debug_trace_init_done are provided by perry_runtime - -// ============================================================================= -// Stack alignment + Widget overlay & edge insets -// ============================================================================= - -#[no_mangle] -pub extern "C" fn perry_ui_stack_set_alignment(handle: i64, alignment: f64) { - widgets::set_alignment(handle, alignment as i64); -} - -#[no_mangle] -pub extern "C" fn perry_ui_widget_add_overlay(_parent: i64, _child: i64) { - // For now, treat as regular add_child - widgets::add_child(_parent, _child); - app::request_layout(); -} - -#[no_mangle] -pub extern "C" fn perry_ui_widget_set_overlay_frame( - _handle: i64, - _x: f64, - _y: f64, - _w: f64, - _h: f64, -) { -} - -#[no_mangle] -pub extern "C" fn perry_ui_widget_set_edge_insets( - handle: i64, - top: f64, - left: f64, - bottom: f64, - right: f64, -) { - widgets::set_insets(handle, top, left, bottom, right); -} +// FFI: widget sizing/layout/overlay/insets, button color, embed HWND, +// QR code, scroll-refresh stubs, stack distribution. +use crate::{app, widgets}; + +/// Add a child widget at a specific index. +#[no_mangle] +pub extern "C" fn perry_ui_widget_add_child_at(parent_handle: i64, child_handle: i64, index: f64) { + widgets::add_child_at(parent_handle, child_handle, index as i64); + app::request_layout(); +} + +// ============================================================================= +// Stubs for symbols referenced by codegen but not yet implemented on Windows +// ============================================================================= + +/// Set button text color. +#[no_mangle] +pub extern "C" fn perry_ui_button_set_text_color(handle: i64, r: f64, g: f64, b: f64, a: f64) { + widgets::button::set_text_color(handle, r, g, b, a); +} + +/// Set widget width (DPI-scaled). +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_width(handle: i64, width: f64) { + let scaled = (width * app::get_dpi_scale()) as i32; + widgets::set_fixed_width(handle, scaled); +} + +/// Set widget hugging priority. +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_hugging(handle: i64, priority: f64) { + widgets::set_hugging_priority(handle, priority); +} + +/// Set on-click callback (stub — not yet implemented on Windows). +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_on_click(handle: i64, callback: f64) { + let _ = handle; + #[cfg(feature = "geisterhand")] + { + extern "C" { + fn perry_geisterhand_register( + handle: i64, + widget_type: u8, + callback_kind: u8, + closure_f64: f64, + label_ptr: *const u8, + ); + } + unsafe { + perry_geisterhand_register(handle, 0, 0, callback, std::ptr::null()); + } + } +} + +/// Set widget height (fixed, DPI-scaled). +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_height(handle: i64, height: f64) { + let scaled = (height * app::get_dpi_scale()) as i32; + widgets::set_fixed_height(handle, scaled); +} + +/// Match parent height — marks the widget to stretch vertically to fill its parent. +#[no_mangle] +pub extern "C" fn perry_ui_widget_match_parent_height(handle: i64) { + widgets::set_match_parent_height(handle, true); +} + +/// Match parent width — marks the widget to stretch horizontally to fill its parent. +#[no_mangle] +pub extern "C" fn perry_ui_widget_match_parent_width(handle: i64) { + widgets::set_match_parent_width(handle, true); +} + +/// Set hidden state (perry_ui_widget_set_hidden — matches macOS naming convention). +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_hidden(handle: i64, hidden: i64) { + widgets::set_hidden(handle, hidden != 0); +} + +/// Stack: detach hidden children from layout calculation. +/// When enabled, hidden children don't occupy any space. +#[no_mangle] +pub extern "C" fn perry_ui_stack_set_detaches_hidden(handle: i64, flag: i64) { + widgets::set_detaches_hidden(handle, flag != 0); +} + +/// Embed a native HWND into the Perry widget system. +/// Takes the HWND pointer value and returns a 1-based widget handle. +/// The widget is marked as fills_remaining so it absorbs remaining space in VStack/HStack. +#[no_mangle] +pub extern "C" fn perry_ui_embed_nsview(hwnd_ptr: i64) -> i64 { + if hwnd_ptr == 0 { + return 0; + } + #[cfg(target_os = "windows")] + { + let hwnd = windows::Win32::Foundation::HWND(hwnd_ptr as *mut std::ffi::c_void); + let handle = widgets::register_widget(hwnd, widgets::WidgetKind::Canvas, 0); + widgets::set_fills_remaining(handle, true); + handle + } + #[cfg(not(target_os = "windows"))] + { + let _ = hwnd_ptr; + 0 + } +} + +/// Request location permission (stub — not available on Windows desktop). +#[no_mangle] +pub extern "C" fn perry_system_request_location(_callback: f64) {} + +// NOTE: backOff, js_crypto_random_bytes_buffer, js_fetch_*, js_ws_handle_to_i64, +// and js_fetch_stream_status are provided by perry-stdlib. When linking the IDE +// (which uses both perry-stdlib and perry-ui-windows), these stubs caused +// duplicate symbol errors (LNK2005). Removed — perry-stdlib provides the real +// implementations. + +#[no_mangle] +pub extern "C" fn perry_ui_qrcode_create(data_ptr: i64, size: f64) -> i64 { + widgets::qrcode::create(data_ptr as *const u8, size) +} + +#[no_mangle] +pub extern "C" fn perry_ui_qrcode_set_data(handle: i64, data_ptr: i64) { + widgets::qrcode::set_data(handle, data_ptr as *const u8); +} + +#[no_mangle] +pub extern "C" fn perry_ui_scrollview_end_refreshing(_handle: i64) {} + +#[no_mangle] +pub extern "C" fn perry_ui_scrollview_set_refresh_control(_handle: i64, _callback: f64) {} + +#[no_mangle] +pub extern "C" fn perry_ui_stack_set_distribution(handle: i64, distribution: f64) { + // Dispatch declares this as `[Widget, F64]` (matches every other platform). + // The Windows runtime previously took `i64` — on Win64 ABI the f64 arg lands + // in XMM1 while `i64` is read from RDX (uninitialized garbage), so the + // distribution enum tag was random. + widgets::set_distribution(handle, distribution as i64); +} + +#[no_mangle] +pub extern "C" fn perry_ui_widget_reorder_child(_parent: i64, _child: i64, _index: i64) {} + +// perry_debug_trace_init and perry_debug_trace_init_done are provided by perry_runtime + +// ============================================================================= +// Stack alignment + Widget overlay & edge insets +// ============================================================================= + +#[no_mangle] +pub extern "C" fn perry_ui_stack_set_alignment(handle: i64, alignment: f64) { + widgets::set_alignment(handle, alignment as i64); +} + +#[no_mangle] +pub extern "C" fn perry_ui_widget_add_overlay(_parent: i64, _child: i64) { + // For now, treat as regular add_child + widgets::add_child(_parent, _child); + app::request_layout(); +} + +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_overlay_frame( + _handle: i64, + _x: f64, + _y: f64, + _w: f64, + _h: f64, +) { +} + +#[no_mangle] +pub extern "C" fn perry_ui_widget_set_edge_insets( + handle: i64, + top: f64, + left: f64, + bottom: f64, + right: f64, +) { + widgets::set_insets(handle, top, left, bottom, right); +} diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 8345ecd78d..4c47c6e93d 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -5313,7 +5313,64 @@ pub fn run_with_parse_cache( // For dylib output, skip runtime/stdlib linking — symbols resolve from host at dlopen time if is_dylib { - let mut cmd = if is_linux { + let is_dylib_windows = matches!(target.as_deref(), Some("windows") | Some("windows-winui")) + || (target.is_none() && cfg!(target_os = "windows")); + let has_plugin_deactivate = ctx + .native_modules + .values() + .any(|m| m.exported_functions.iter().any(|(n, _)| n == "deactivate")); + let mut cmd = if is_dylib_windows { + // Windows — emit a .dll via lld-link. The plugin DLL's external + // references to `perry_*` / `js_*` resolve against the host + // process at LoadLibrary time, just like macOS + // `-flat_namespace -undefined dynamic_lookup`. + // + // A .def file IS still needed here — lld-link's default is to + // emit an empty export table, and the host's `loadPlugin` calls + // `GetProcAddress(handle, "plugin_activate")` to find the + // plugin's entry point. The `LIBRARY` directive names the DLL + // and the `EXPORTS` section lists the three plugin ABI symbols + // that the codegen layer emits for the dylib's entry module + // (see `compile_module_entry`). `plugin_deactivate` is + // optional and only listed when the user's `deactivate` + // function is actually exported. + // + // `/FORCE:UNRESOLVED` lets the linker produce the DLL even though + // every `perry_*` / `js_*` symbol is undefined; the loader fills + // them in from the host at LoadLibrary time. Without it, the + // link fails with LNK2019 on the first unresolved `js_*` symbol + // and no DLL is emitted. + // + // We use lld-link rather than MSVC link.exe here: lld-link honors + // /FORCE:UNRESOLVED on the LLVM .o files that Perry emits (treating + // the missing symbols as warnings that produce a runnable DLL), + // whereas MSVC link.exe returns 0 without writing the DLL — see + // the cross-linker note in `select_linker_command`. + let linker = find_lld_link().unwrap_or_else(|| PathBuf::from("lld-link")); + let mut c = Command::new(linker); + c.arg("/NOLOGO").arg("/DLL").arg("/FORCE:UNRESOLVED"); + let stem = exe_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("perry_plugin"); + let def_path = std::env::temp_dir().join(format!( + "perry_plugin_dylib_{}_{}.def", + std::process::id(), + stem + )); + if let Ok(mut def_file) = std::fs::File::create(&def_path) { + use std::io::Write; + let _ = writeln!(def_file, "LIBRARY {}", stem); + let _ = writeln!(def_file, "EXPORTS"); + let _ = writeln!(def_file, " plugin_activate"); + let _ = writeln!(def_file, " perry_plugin_abi_version"); + if has_plugin_deactivate { + let _ = writeln!(def_file, " plugin_deactivate"); + } + } + c.arg(format!("/DEF:{}", def_path.display())); + c + } else if is_linux { let mut c = Command::new("cc"); c.arg("-shared"); c @@ -5331,7 +5388,12 @@ pub fn run_with_parse_cache( cmd.arg(obj_path); } - cmd.arg("-o").arg(&exe_path); + if is_dylib_windows { + // MSVC link.exe takes the output path as `/OUT:`, not `-o`. + cmd.arg(format!("/OUT:{}", exe_path.display())); + } else { + cmd.arg("-o").arg(&exe_path); + } let status = cmd.status()?; if !status.success() { diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs index 44aa803980..a6beb4e243 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -409,55 +409,61 @@ pub(crate) fn build_and_run_link( // 1. hone_host_api_* (plugin→host calls) // 2. js_*/perry_* (Perry runtime used by compiled plugin code) // We use -u to prevent dead_strip from removing these, keeping binary size small. - if ctx.needs_plugins && !is_windows { - #[cfg(target_os = "macos")] - { - // Force-keep all functions from plugin-related native libraries - for native_lib in &ctx.native_libraries { - if native_lib.module.contains("plugin") { - for func in &native_lib.functions { - cmd.arg(format!("-Wl,-u,_{}", func.name)); + if ctx.needs_plugins { + if is_windows { + // Windows: write a per-build `.def` file listing the runtime + + // plugin-manager exports and pass `/DEF:` to link.exe. This + // is the MSVC equivalent of `-rdynamic` / `-Wl,-u,_` — + // symbols listed in EXPORTS survive dead-strip and become visible + // to `LoadLibraryW`'d plugin DLLs via `GetProcAddress`. + // + // Use `NAME ` rather than `LIBRARY `: this code path + // links a host .exe, and `LIBRARY` tells link.exe the output is + // a DLL named `.dll`. link.exe would then emit a + // `.exp` carrying `/OUT:.dll` and reject the + // resulting .exe as a non-Win32 application (LNK4070 is + // emitted and ignored, but the EXE itself is broken). `NAME` + // declares the output file name without that side effect. + let def_path = + std::env::temp_dir().join(format!("perry_plugin_host_{}.def", std::process::id())); + if let Ok(mut def_file) = fs::File::create(&def_path) { + let _ = writeln!( + def_file, + "NAME {}", + exe_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("perry_host") + ); + let _ = writeln!(def_file, "EXPORTS"); + for sym in PLUGIN_HOST_SYMBOLS { + let _ = writeln!(def_file, " {}", sym); + } + } + cmd.arg(format!("/DEF:{}", def_path.display())); + } else { + #[cfg(target_os = "macos")] + { + // Force-keep all functions from plugin-related native libraries + for native_lib in &ctx.native_libraries { + if native_lib.module.contains("plugin") { + for func in &native_lib.functions { + cmd.arg(format!("-Wl,-u,_{}", func.name)); + } } } + // Force-keep Perry runtime symbols that plugin dylibs reference. + // These are collected from the Perry runtime's public API. + // Using -u tells the linker "treat as referenced" so dead_strip keeps them. + for sym in PLUGIN_HOST_SYMBOLS { + cmd.arg(format!("-Wl,-u,_{}", sym)); + } } - // Force-keep Perry runtime symbols that plugin dylibs reference. - // These are collected from the Perry runtime's public API. - // Using -u tells the linker "treat as referenced" so dead_strip keeps them. - let runtime_syms = [ - "js_array_alloc", - "js_array_from_f64", - "js_array_push_f64", - "js_bigint_is_zero", - "js_closure_alloc", - "js_console_log_spread", - "js_dynamic_object_get_property", - "js_dynamic_string_equals", - "js_gc_register_global_root", - "js_is_truthy", - "js_jsvalue_compare", - "js_jsvalue_equals", - "js_nanbox_get_pointer", - "js_nanbox_pointer", - "js_nanbox_string", - "js_native_call_method", - "js_object_alloc_class_with_keys", - "js_object_alloc_with_shape", - "js_register_class_method", - "js_string_char_code_at", - "js_string_from_bytes", - "js_string_length", - "perry_debug_trace_init", - "perry_debug_trace_init_done", - "perry_init_guard_check_and_set", - ]; - for sym in &runtime_syms { - cmd.arg(format!("-Wl,-u,_{}", sym)); + #[cfg(target_os = "linux")] + { + cmd.arg("-rdynamic"); } } - #[cfg(target_os = "linux")] - { - cmd.arg("-rdynamic"); - } } if is_watchos { diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index ab8af1faa6..891afdb0cc 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -24,6 +24,7 @@ use anyhow::{anyhow, Result}; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; @@ -52,6 +53,73 @@ pub(super) use link_cache::{write_link_cache_manifest, LinkCacheStatus}; pub use platform_cmd::select_linker_command; pub(super) use windows_link::WINDOWS_APP_MANIFEST; // guarded by windows_link_tests +/// Symbols a plugin host must export so `dlopen`'d / `LoadLibrary`'d plugin +/// shared libraries can resolve them against the host process at load time. +/// +/// Plugins (`.dylib` / `.so` / `.dll`) link against the host's copies of +/// these symbols rather than bringing their own — see +/// `crates/perry-runtime/src/plugin.rs::perry_plugin_load`. On macOS we +/// use `-Wl,-u,_` to force the linker to keep them past dead-strip; +/// on Linux `-rdynamic` exports the whole set; on Windows we write a +/// `.def` file and pass `/DEF:` to `link.exe` (the MSVC equivalent +/// of `-rdynamic`). +pub(super) const PLUGIN_HOST_SYMBOLS: &[&str] = &[ + // Runtime allocation / value primitives + "js_array_alloc", + "js_array_from_f64", + "js_array_push_f64", + "js_bigint_is_zero", + "js_closure_alloc", + "js_console_log_spread", + "js_dynamic_object_get_property", + "js_dynamic_string_equals", + "js_gc_register_global_root", + "js_is_truthy", + "js_jsvalue_compare", + "js_jsvalue_equals", + "js_nanbox_get_pointer", + "js_nanbox_pointer", + "js_nanbox_string", + "js_native_call_method", + "js_object_alloc_class_with_keys", + "js_object_alloc_with_shape", + "js_register_class_method", + "js_string_char_code_at", + "js_string_from_bytes", + "js_string_length", + "perry_debug_trace_init", + "perry_debug_trace_init_done", + "perry_init_guard_check_and_set", + // Plugin manager (perry-plugin) + "perry_plugin_abi_version", + "perry_plugin_load", + "perry_plugin_unload", + "perry_plugin_lookup_symbol", + "perry_plugin_emit_hook", + "perry_plugin_emit_event", + "perry_plugin_invoke_tool", + "perry_plugin_register_hook", + "perry_plugin_register_hook_ex", + "perry_plugin_unregister_hook", + "perry_plugin_register_tool", + "perry_plugin_unregister_tool", + "perry_plugin_register_service", + "perry_plugin_unregister_service", + "perry_plugin_register_route", + "perry_plugin_unregister_route", + "perry_plugin_list_plugins", + "perry_plugin_list_hooks", + "perry_plugin_list_tools", + "perry_plugin_plugin_count", + "perry_plugin_set_metadata", + "perry_plugin_get_config", + "perry_plugin_subscribe_event", + "perry_plugin_unsubscribe_event", + "perry_plugin_emit_event_bus", + "perry_plugin_init", + "perry_plugin_last_load_error", +]; + #[derive(Debug, Clone, PartialEq, Eq)] struct NativeBackendLinkMetadata { backend: super::NativeBackend, diff --git a/tests/test_plugin_lifecycle.sh b/tests/test_plugin_lifecycle.sh new file mode 100644 index 0000000000..9f4025ebaa --- /dev/null +++ b/tests/test_plugin_lifecycle.sh @@ -0,0 +1,183 @@ +#!/bin/bash +# End-to-end test: build a host and a plugin, load the plugin, fire a hook, +# verify the hook callback ran. Cross-platform via extension detection: +# - macOS: host=host plugin=plugin.dylib +# - Linux: host=host plugin=plugin.so +# - Windows: host=host.exe plugin=plugin.dll +# +# This exercises the full perry/plugin pipeline: +# 1. compile-as-dylib link path (compile.rs) +# 2. host-side symbol export (link/mod.rs /DEF on Windows, -u/-rdynamic elsewhere) +# 3. runtime plugin loader (plugin.rs: open_library / lookup_symbol / activate) +# 4. hook dispatch (plugin.rs: perry_plugin_emit_hook) +# 5. unload teardown (plugin.rs: perry_plugin_unload) +# +# On any host where the build is unsupported (e.g. cross-compile without a +# matching toolchain), the test skips with an explanatory message rather than +# failing — the per-platform support matrix is exercised in CI per runner OS. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PERRY="$SCRIPT_DIR/../target/release/perry" + +if [ ! -f "$PERRY" ]; then + PERRY="$SCRIPT_DIR/../target/debug/perry" +fi +if [ ! -f "$PERRY" ]; then + echo "SKIP: perry binary not found (build with cargo build --release)" + exit 0 +fi + +# Platform detection +case "$(uname -s 2>/dev/null || echo Windows)" in + Darwin) + HOST_BIN="host" + PLUGIN_EXT="dylib" + ;; + Linux) + HOST_BIN="host" + PLUGIN_EXT="so" + ;; + MINGW*|MSYS*|CYGWIN*|Windows) + HOST_BIN="host.exe" + PLUGIN_EXT="dll" + ;; + *) + echo "SKIP: unsupported host $(uname -s 2>/dev/null || echo unknown)" + exit 0 + ;; +esac + +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +# --- Plugin source --------------------------------------------------------- +# Registers a filter hook that uppercases a `name` field, and a tool that +# returns a static greeting. The host exercises both. + +cat > "$TMPDIR/plugin.ts" << 'PLUGIN' +import type { PluginApi } from "perry/plugin" + +export function activate(api: PluginApi) { + api.setMetadata("test-plugin", "1.0.0", "End-to-end plugin test") + + api.registerHook("transform", (data: any) => { + if (data && typeof data.name === "string") { + data.name = data.name.toUpperCase() + } + return data + }) + + api.registerTool("greet", "test greeting", (args: any) => { + return `hello, ${args?.who ?? "world"}` + }) +} + +export function deactivate() { + // no-op; teardown is exercised by the host's unloadPlugin call +} +PLUGIN + +# --- Host source ----------------------------------------------------------- +# Loads the plugin, fires the hook, invokes the tool, then unloads. + +cat > "$TMPDIR/host.ts" << 'HOST' +import { + initPlugins, + loadPlugin, + unloadPlugin, + emitHook, + invokeTool, +} from "perry/plugin" + +function main(): number { + initPlugins() + + const pluginPath = process.argv[2] + if (!pluginPath) { + console.error("usage: host ") + return 2 + } + + const id = loadPlugin(pluginPath) + if (id === 0) { + console.error("FAIL: loadPlugin returned 0") + return 1 + } + + const transformed = emitHook("transform", { name: "perry" }) + console.log(`name=${transformed.name}`) + + const greeting = invokeTool("greet", { who: "windows" }) + console.log(`greeting=${greeting}`) + + unloadPlugin(id) + return 0 +} + +main() +HOST + +# --- Compile --------------------------------------------------------------- + +cd "$TMPDIR" + +echo "Compiling plugin to $PLUGIN_EXT..." +"$PERRY" compile plugin.ts --output-type dylib -o "plugin.$PLUGIN_EXT" 2>&1 + +echo "Compiling host to $HOST_BIN..." +"$PERRY" compile host.ts -o "$HOST_BIN" 2>&1 + +if [ ! -f "plugin.$PLUGIN_EXT" ]; then + echo "FAIL: plugin.$PLUGIN_EXT was not produced" + exit 1 +fi +if [ ! -f "$HOST_BIN" ]; then + echo "FAIL: $HOST_BIN was not produced" + exit 1 +fi + +# --- Run ------------------------------------------------------------------- + +echo "Running host with plugin..." +# `set -e` would short-circuit on a non-zero host exit before `EXIT=$?` ran; +# capture the exit code in the same subshell assignment so the diagnostic +# block below always sees the real status. Pass `./plugin.` (not a bare +# filename) so the OS loader resolves it from the test's cwd instead of +# any PATH / DLL-search-list entry — a bare `plugin.dll` on Windows would +# silently pick up an unrelated file from C:\Windows\System32 if one ever +# landed there. +set +e +OUTPUT=$(./"$HOST_BIN" "./plugin.$PLUGIN_EXT" 2>&1) +EXIT=$? +set -e + +if [ "$EXIT" -ne 0 ]; then + echo "FAIL: host exited with status $EXIT" + echo "$OUTPUT" + exit 1 +fi + +echo "Output:" +echo "$OUTPUT" +echo "" + +# Hook should have uppercased the name (filter mode returns transformed ctx) +if ! echo "$OUTPUT" | grep -qF "name=PERRY"; then + echo "FAIL: hook did not transform 'perry' to 'PERRY'" + echo "Expected 'name=PERRY' in output, got:" + echo "$OUTPUT" + exit 1 +fi + +# Tool invocation should have produced a greeting +if ! echo "$OUTPUT" | grep -qF "greeting=hello, windows"; then + echo "FAIL: tool did not return expected greeting" + echo "Expected 'greeting=hello, windows' in output, got:" + echo "$OUTPUT" + exit 1 +fi + +echo "PASS: end-to-end plugin lifecycle (load -> hook -> tool -> unload)" +exit 0