From da56bcb7ddaea56c927d9cf4cf43f7a392239087 Mon Sep 17 00:00:00 2001 From: Perry Windows Plugin Contributor Date: Thu, 18 Jun 2026 14:10:17 -0500 Subject: [PATCH 1/2] feat(plugin): enable Windows plugin support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compile-side: - crates/perry/src/commands/compile.rs: add Windows arm to the `--output-type dylib` link path. On Linux this is `cc -shared`; on macOS `cc -dynamiclib -flat_namespace -undefined dynamic_lookup`; on Windows we now invoke `link /NOLOGO /DLL /OUT:`. The DLL leaves perry_* / js_* external references unresolved, the same way the macOS dylib does — they bind to the host process at LoadLibrary time. - crates/perry/src/commands/compile/link/mod.rs: when `ctx.needs_plugins && is_windows`, write a per-build `perry_plugin_host_.def` listing the runtime + plugin-manager exports and pass `/DEF:` to link.exe. This is the MSVC equivalent of `-rdynamic` / `-Wl,-u,_`. The shared PLUGIN_HOST_SYMBOLS const is now the single source of truth for both the macOS `-u` force-keep list and the Windows .def file; Linux still uses `-rdynamic`. The Windows /OPT:REF + /OPT:ICF defaults are overridden to /OPT:NOREF for plugin hosts so dead strip does not discard exports the .def file is preserving. Runtime: - crates/perry-runtime/src/plugin.rs: factor open_library / lookup_symbol / close_library / last_load_error into cfg-gated helpers, then drop the `#[cfg(unix)]` gates on perry_plugin_load and perry_plugin_unload. Unix keeps `libc::dlopen` / `dlsym` / `dlclose`; Windows now uses `LoadLibraryW` / `GetProcAddress` / `FreeLibrary` (via windows-sys 0.61). The `LibHandle` newtype stays platform-agnostic (`*mut c_void`) so the rest of the registry/dispatch surface is unchanged. ABI-check, activate, deactivate, hook/tool/service/ route/event registration all work the same on Windows. - crates/perry-runtime/Cargo.toml: enable `Win32_System_LibraryLoader` on the existing windows-sys block. (FreeLibrary is actually exported from `Win32::Foundation`, not the LibraryLoader module, so the import path lives in code.) UI: - crates/perry-ui-windows/src/ffi/widget_layout_extras.rs: drop the no-op `perry_plugin_load` (returned 0) and `perry_plugin_unload` stubs. The real implementations in perry-runtime now link through for both console and UI plugin hosts. Test: - tests/test_plugin_lifecycle.sh: end-to-end bash test that compiles a `plugin.ts` (with `registerHook` + `registerTool` + `setMetadata` + `deactivate`) and a `host.ts` (with `loadPlugin` / `emitHook` / `invokeTool` / `unloadPlugin`), runs the host against the plugin, and asserts the hook transformed the input and the tool returned the expected greeting. Detects the host platform and uses the right extensions (`.dll`/`.exe` on Windows, `.so` on Linux, `.dylib` on macOS); skips on unsupported hosts. Not changed: types/perry/plugin/index.d.ts (no surface change), docs/src/plugins/* (existing macOS/Linux docs still apply), and no version bump or CHANGELOG entry per CLAUDE.md (this is a contributor PR — the maintainer folds in the patch bump and changelog at merge time). --- crates/perry-runtime/Cargo.toml | 4 + crates/perry-runtime/src/plugin.rs | 180 +- .../src/ffi/widget_layout_extras.rs | 380 +- crates/perry/src/commands/compile.rs | 21 +- crates/perry/src/commands/compile/link/mod.rs | 4348 +++++++++-------- tests/test_plugin_lifecycle.sh | 183 + 6 files changed, 2749 insertions(+), 2367 deletions(-) create mode 100644 tests/test_plugin_lifecycle.sh 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 1f7a9014f2..b860500dd2 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -5294,7 +5294,19 @@ 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 mut cmd = if is_dylib_windows { + // Windows — emit a .dll via lld-link / link.exe. 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`. No /DEF file is + // needed here — that's only required on the *host* side, see + // `link::build_and_run_link`'s `needs_plugins` branch. + let mut c = Command::new("link"); + c.arg("/NOLOGO").arg("/DLL"); + c + } else if is_linux { let mut c = Command::new("cc"); c.arg("-shared"); c @@ -5312,7 +5324,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/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index 8f0602cb51..00a7daefaa 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -1,2132 +1,2216 @@ -//! Build and run the executable link command. -//! -//! Tier 2.1 final extraction (v0.5.342) — moves the per-platform link command -//! construction out of `crates/perry/src/commands/compile.rs::run_with_parse_cache`. -//! Pre-extraction, the link logic was a ~1240-LOC inline block inside the -//! orchestrator, fanning out across macOS / iOS / tvOS / visionOS / watchOS / -//! Android / Linux / Windows / cross-compile permutations. Co-locating it here -//! lets the orchestrator stay focused on parse / lower / codegen / cache / link -//! sequencing instead of churning the same file every time a platform-specific -//! link flag changes. -//! -//! The `dylib` link path stays inline in compile.rs because it returns early -//! with a `CompileResult`. Per-platform `.app` bundling and Android companion -//! `.so` copying also stay in compile.rs — they happen after the link -//! returns and need access to many post-link variables (`exe_path`, -//! `result_bundle_id`, etc.) that don't belong in this module. -//! -//! Wave 4 split (v0.5.x) — the per-platform linker-`Command` selection (~615 -//! LOC of `if is_watchos { swiftc … } else if is_ios { clang … } else if …`) -//! moved into `platform_cmd.rs` so this file stays under the 2k-LOC soft -//! ceiling. The orchestrator below still drives the full link line; only the -//! initial `Command::new()` + sysroot/triple/entry-rewrite prelude -//! lives in the sibling module. - -use anyhow::{anyhow, Result}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use crate::OutputFormat; - -use super::{ - apple_sdk_version, build_geisterhand_libs, dedup_native_lib_for_tier3, dedup_runtime_for_tier3, - dedup_stdlib_for_tier3, find_geisterhand_library, find_geisterhand_runtime, - find_geisterhand_stdlib, find_geisterhand_ui, find_lld_link, find_llvm_tool, - find_msvc_lib_paths, find_msvc_link_exe, find_perry_windows_sdk, find_stdlib_library, - find_ui_library, find_visionos_swift_runtime, find_watchos_swift_runtime, rust_target_triple, - strip_duplicate_objects_from_lib, strip_duplicate_objects_from_well_known_lib, - windows_pe_subsystem_flag, windows_subsystem_needs_ui, CompilationContext, -}; - -mod link_cache; -mod native_features; -mod pkg_config; -mod platform_cmd; -mod windows_link; - -use link_cache::prepare_link_cache_status; -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 - -#[derive(Debug, Clone, PartialEq, Eq)] -struct NativeBackendLinkMetadata { - backend: super::NativeBackend, - prebuilt: Option, - frameworks: Vec, - libs: Vec, - lib_dirs: Vec, - pkg_config: Vec, -} - -fn select_available_backend_link_metadata( - target_config: &super::TargetNativeConfig, -) -> Vec { - if !target_config.available { - return Vec::new(); - } - - target_config - .backends - .iter() - .filter(|backend| backend.available) - .map(|backend| NativeBackendLinkMetadata { - backend: backend.backend, - prebuilt: backend.prebuilt.clone(), - frameworks: backend.frameworks.clone(), - libs: backend.libs.clone(), - lib_dirs: backend.lib_dirs.clone(), - pkg_config: backend.pkg_config.clone(), - }) - .collect() -} - -#[cfg(test)] -mod native_package_selection_tests { - use super::*; - use crate::commands::compile::{ - NativeBackend, NativeBackendConfig, NativeBackendPackageMetadata, TargetNativeConfig, - }; - - fn target_config() -> TargetNativeConfig { - TargetNativeConfig { - available: true, - unavailable_reason: None, - crate_path: PathBuf::from("crate"), - lib_name: "demo".to_string(), - prebuilt: Some(PathBuf::from("target/libdemo.a")), - frameworks: vec!["Metal".to_string()], - optional_frameworks: Vec::new(), - frameworks_env: None, - libs: vec!["z".to_string()], - lib_dirs: vec![PathBuf::from("vendor/lib")], - pkg_config: vec!["openssl".to_string()], - resources: vec![PathBuf::from("resources/common.dat")], - shader_outputs: vec![PathBuf::from("shaders/common.spv")], - backends: Vec::new(), - swift_sources: Vec::new(), - metal_sources: vec![PathBuf::from("shaders/default.metal")], - } - } - - fn backend_config(backend: NativeBackend, available: bool) -> NativeBackendConfig { - NativeBackendConfig { - backend, - available, - unavailable_reason: if available { - None - } else { - Some("not shipped".to_string()) - }, - prebuilt: Some(PathBuf::from(format!("backend/{}.a", backend.as_str()))), - frameworks: vec![format!("{}Framework", backend.as_str())], - libs: vec![format!("{}_sys", backend.as_str())], - lib_dirs: vec![PathBuf::from(format!("vendor/{}/lib", backend.as_str()))], - pkg_config: vec![format!("{}-pkg", backend.as_str())], - shader_sources: vec![PathBuf::from(format!("shaders/{}.src", backend.as_str()))], - shader_outputs: vec![PathBuf::from(format!("shaders/{}.bin", backend.as_str()))], - resources: vec![PathBuf::from(format!("resources/{}", backend.as_str()))], - package: NativeBackendPackageMetadata { - name: Some(format!("demo-{}", backend.as_str())), - version: Some("1.0.0".to_string()), - kind: Some("shader-package".to_string()), - }, - } - } - - #[test] - fn selection_includes_available_backend_link_metadata() { - let mut tc = target_config(); - tc.backends - .push(backend_config(NativeBackend::Vulkan, true)); - tc.backends - .push(backend_config(NativeBackend::D3d12, false)); - - let selection = select_available_backend_link_metadata(&tc); - - assert_eq!(selection.len(), 1); - let backend = &selection[0]; - assert_eq!(backend.backend, NativeBackend::Vulkan); - assert_eq!(backend.prebuilt, Some(PathBuf::from("backend/vulkan.a"))); - assert_eq!(backend.frameworks, vec!["vulkanFramework".to_string()]); - assert_eq!(backend.libs, vec!["vulkan_sys".to_string()]); - assert_eq!(backend.lib_dirs, vec![PathBuf::from("vendor/vulkan/lib")]); - assert_eq!(backend.pkg_config, vec!["vulkan-pkg".to_string()]); - } - - #[test] - fn unavailable_target_selects_no_backend_link_metadata() { - let mut tc = target_config(); - tc.available = false; - tc.backends - .push(backend_config(NativeBackend::Vulkan, true)); - - let selection = select_available_backend_link_metadata(&tc); - - assert!(selection.is_empty()); - } -} - -/// Walk up from the entry `.ts` to the directory holding `perry.toml`. -/// Mirrors `widget_build::project_root_for` — kept local so the link -/// module doesn't reach across sibling modules for one path lookup. -fn find_project_root_for(input: &Path) -> Option { - let mut dir = input.canonicalize().ok()?; - for _ in 0..8 { - dir = dir.parent()?.to_path_buf(); - if dir.join("perry.toml").exists() { - return Some(dir); - } - } - None -} - -/// Resolve the vendored optional-framework search directory for a native -/// library that gates an SDK on `frameworks_env` (issue #1303 — e.g. -/// `@perryts/google-auth`'s `PERRY_GOOGLE_SIGN_IN_FRAMEWORK_DIR`). -/// -/// Precedence (matches the issue's contract): -/// 1. The `frameworks_env` env var, when set in the process environment -/// — today's local `perry compile` behavior. Returned verbatim. -/// 2. `perry.toml [google_auth].framework_dir`, resolved relative to the -/// project root → absolute. This is what survives the `perry publish` -/// worker round-trip: the dev's shell env doesn't transfer and an -/// absolute local path wouldn't exist on the worker, but perry.toml + -/// the project-relative dir are both uploaded with `--project`, so the -/// worker's `perry compile` re-resolves the same dir. -/// -/// Returns `None` when neither source yields a path. Callers still check -/// `is_dir()` before linking, so a stale/misspelled path skips silently -/// (the wrapper's `#if canImport(...)` fallback keeps the link valid). -fn resolve_optional_framework_dir(env_name: &str, args_input: &Path) -> Option { - // 1. Explicit env var wins (today's behavior). - if let Some(dir) = std::env::var_os(env_name) { - if !dir.is_empty() { - return Some(PathBuf::from(dir)); - } - } - // 2. Project-relative `perry.toml [google_auth].framework_dir`. - let project_root = find_project_root_for(args_input)?; - let content = fs::read_to_string(project_root.join("perry.toml")).ok()?; - let doc = content.parse::().ok()?; - let rel = doc - .get("google_auth")? - .as_table()? - .get("framework_dir")? - .as_str()?; - Some(project_root.join(rel)) -} - -/// Construct the platform-specific linker command, append every required -/// argument (object files, libraries, frameworks, system libs, native libs, -/// geisterhand libs), invoke it, and bail on non-zero status. -/// -/// Caller must have already handled the dylib output path; this function -/// only covers executable link. `args_input` is the user-supplied entry -/// `.ts` path (used for objcopy entry-stem matching on watchOS / visionOS / -/// iOS game-loop renames). -pub(super) fn build_and_run_link( - args_input: &Path, - ctx: &CompilationContext, - target: Option<&str>, - obj_paths: &[PathBuf], - obj_fingerprints: &[Option], - compiled_features: &[String], - runtime_lib: &Path, - stdlib_lib: &Option, - // #466 Phase 4 step 2: well-known native binding archives. Added - // to the link line right after `stdlib_lib`. The matching - // perry-stdlib feature was already stripped during the auto- - // optimize rebuild, so the resulting link contains exactly one - // copy of each `_js__*` symbol — no duplicates. - well_known_libs: &[PathBuf], - // No-auto / auto-fallback keeps the full prebuilt stdlib, so the - // matching perry-stdlib feature was not stripped. Put wrappers first - // in that shape so wrapper-only handles keep using their own surface - // symbols instead of the bundled stdlib copies. - prefer_well_known_before_stdlib: bool, - // Issue #76 — `libperry_wasm_host.a` (wasmi-backed WebAssembly host - // runtime). Only `Some(...)` when the user passed `--enable-wasm-runtime` - // and the archive was located. Appended to the link command after the - // stdlib block so the linker resolves `perry_wasm_host_*` - // symbols referenced by `js_webassembly_*` shims in `perry-runtime`. - wasm_host_lib: &Option, - exe_path: &Path, - format: OutputFormat, - // `--debug-symbols`: keep symbols / emit a PDB so RUST_BACKTRACE - // panics in the compiled app symbolize. Windows-active today. - debug_symbols: bool, -) -> Result { - // #498 - supply-chain gate. Before any prebuilt archive hits the - // linker, hash it and compare against `perry.lock`. First build - // writes the lockfile; subsequent builds verify. Mismatch fails - // with an actionable diagnostic (`perry lock --update `). - // `PERRY_LOCK_FROZEN=1` upgrades the verify to CI mode (refuses - // to extend the lock); `PERRY_LOCK_UPDATE=` deliberately - // bumps the named package's hashes. Wired here so every backend - // (LLVM / WASM / ArkTS / HarmonyOS / Glance / SwiftUI / JS) - // inherits the gate from one chokepoint. - super::run_lock_verify_for_compile(ctx, target)?; - - let is_ios = matches!(target, Some("ios-simulator") | Some("ios")); - let is_visionos = matches!(target, Some("visionos-simulator") | Some("visionos")); - // Wear OS links exactly like Android (same triple, NDK, cdylib + TLS model). - let is_android = matches!(target, Some("android") | Some("wearos")); - let is_harmonyos = matches!(target, Some("harmonyos") | Some("harmonyos-simulator")); - let is_linux = matches!(target, Some(t) if t.starts_with("linux")) - || (target.is_none() && cfg!(target_os = "linux")); - // Fully-static musl Linux target (#4826) — a sub-case of is_linux. The - // link command itself is built in select_linker_command (musl driver + - // `-static`); here it only changes which system libs we request. - let is_musl = matches!( - target, - Some("linux-musl") | Some("linux-x86_64-musl") | Some("linux-aarch64-musl") - ); - - // The musl target is meant for headless/serverless binaries (Lambda, - // scratch, distroless). The GTK4 UI backend (perry/ui) links the system - // GTK/glib/webkit stack, which is only shipped for glibc and cannot be - // statically linked into a musl binary. Fail fast with an actionable - // message rather than emitting cryptic undefined-symbol errors (#4826). - if is_musl && ctx.needs_ui { - anyhow::bail!( - "perry/ui is not supported with the static musl Linux target \ - (--libc musl / [linux] libc = \"musl\"): the GTK4 UI backend \ - requires dynamic glibc. Build the GUI app with the default \ - (glibc) Linux target, or drop perry/ui for a headless musl build." - ); - } - let is_windows = matches!(target, Some("windows") | Some("windows-winui")) - || (target.is_none() && cfg!(target_os = "windows")); - let is_cross_windows = is_windows && !cfg!(target_os = "windows"); - let is_cross_ios = is_ios && !cfg!(target_os = "macos"); - let is_cross_visionos = is_visionos && !cfg!(target_os = "macos"); - let is_cross_macos = matches!(target, Some("macos")) && !cfg!(target_os = "macos"); - let is_watchos = matches!(target, Some("watchos") | Some("watchos-simulator")); - let is_tvos = matches!(target, Some("tvos") | Some("tvos-simulator")); - let is_cross_tvos = is_tvos && !cfg!(target_os = "macos"); - - let mut cmd = select_linker_command( - args_input, - ctx, - target, - obj_paths, - compiled_features, - is_ios, - is_visionos, - is_android, - is_harmonyos, - is_linux, - is_windows, - is_cross_windows, - is_cross_ios, - is_cross_visionos, - is_cross_macos, - is_watchos, - is_tvos, - is_cross_tvos, - )?; - - // When ios-game-loop is enabled, rename _main to _perry_user_main in the - // entry object file so the perry runtime's main() (from ios_game_loop.rs) - // becomes the process entry point. It spawns _perry_user_main on a game thread. - if (is_ios || is_tvos || is_visionos) && compiled_features.iter().any(|f| f == "ios-game-loop") - { - // Resolve an objcopy: rust-objcopy / llvm-objcopy from the host Rust - // toolchain (macOS), then llvm-objcopy on Linux builders, then PATH. - let objcopy = std::env::var("HOME").ok() - .map(|h| PathBuf::from(h).join(".rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin/rust-objcopy")) - .filter(|p| p.exists()) - .or_else(|| std::env::var("HOME").ok() - .map(|h| PathBuf::from(h).join(".rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin/llvm-objcopy")) - .filter(|p| p.exists())) - .or_else(|| ["/usr/lib/llvm-18/bin/llvm-objcopy", "/usr/bin/llvm-objcopy-18", "/usr/bin/llvm-objcopy"] - .iter().map(PathBuf::from).find(|p| p.exists())) - .unwrap_or_else(|| PathBuf::from("rust-objcopy")); - // Rename _main -> __perry_user_main so the perry runtime's main() - // (ios_game_loop.rs) becomes the process entry point and spawns the - // user's main on a game thread. The entry object can't be located by - // filename — with the object cache on it's named by content hash, not - // "main_ts" — so apply the rename to every user object. objcopy - // --redefine-sym is a no-op on objects that don't define _main, so this - // only ever rewrites the single entry object regardless of its name. - for obj in obj_paths.iter() { - let _ = Command::new(&objcopy) - .args(["--redefine-sym", "_main=__perry_user_main"]) - .arg(obj) - .status(); - } - } - - for obj_path in obj_paths { - cmd.arg(obj_path); - } - - // HarmonyOS: pick up native C objects that build.rs scripts emitted - // alongside the Rust artifacts. Rust's staticlib normally bundles these - // into libperry_runtime.a, but on our macOS→OHOS cross-build the - // `libmimalloc.a` wrapper ends up as a zero-member BSD-format archive - // (BSD ar's `__.SYMDEF SORTED` layout — macOS-host `ar` creates it, - // llvm-ar can't read it back), and rustc's "bundle native libs into - // the staticlib" path silently skips it. Without us forwarding the - // loose .o files to the final link, `libentry.so` ends up with - // `mi_malloc_aligned` marked UND, and the OHOS dynamic linker rejects - // dlopen with "symbol not found" at EntryAbility.onCreate time. - // - // We walk `target//release/build/*/out/` and collect every - // loose .o. This is coarser than Rust's per-crate link-lib directive - // walking — it picks up .o files from any transitive C dep, not just - // mimalloc — but that's a feature: the set is tiny in practice - // (mimalloc is the only C dep in perry-runtime's closure today) and - // any that turn out unreferenced are dead-stripped via --gc-sections. - if is_harmonyos { - let triple = super::rust_target_triple(target).unwrap_or("aarch64-unknown-linux-ohos"); - let build_roots: Vec = { - let mut roots: Vec = Vec::new(); - // auto_rebuild emits into a perry-auto- dir; the workspace's - // own target/ is a fallback for non-auto flows. - if let Ok(entries) = std::fs::read_dir("target") { - for entry in entries.flatten() { - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - if name_str.starts_with("perry-auto-") || name_str == triple { - roots.push(entry.path()); - } - } - } - // When invoked from outside the workspace, auto_rebuild still - // lands under the perry source tree's target/. Add that. - if let Some(ws_root) = super::find_perry_workspace_root() { - let ws_target = ws_root.join("target"); - if let Ok(entries) = std::fs::read_dir(&ws_target) { - for entry in entries.flatten() { - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - if name_str.starts_with("perry-auto-") { - roots.push(entry.path()); - } - } - } - } - roots - }; - let mut native_objs: Vec = Vec::new(); - for root in &build_roots { - let build_dir = root.join(triple).join("release").join("build"); - let entries = match std::fs::read_dir(&build_dir) { - Ok(e) => e, - Err(_) => continue, - }; - for crate_build in entries.flatten() { - let out_dir = crate_build.path().join("out"); - // Walk the out/ dir recursively (cc-rs can nest into source- - // mirror subdirs like c_src/mimalloc/v2/src/). - if let Ok(walker) = walkdir::WalkDir::new(&out_dir) - .into_iter() - .collect::, _>>() - { - for entry in walker { - if entry.file_type().is_file() - && entry.path().extension().and_then(|e| e.to_str()) == Some("o") - { - native_objs.push(entry.path().to_path_buf()); - } - } - } - } - } - if !native_objs.is_empty() && matches!(format, crate::OutputFormat::Text) { - println!( - " harmonyos: linking {} build.rs native object(s)", - native_objs.len() - ); - } - for obj in native_objs { - cmd.arg(obj); - } - } - - // Dead code stripping — safe because compile_init() emits func_addr - // calls for every class method/getter during vtable registration. These - // serve as linker roots that keep dynamically-dispatched methods alive. - if !is_windows { - if is_android || is_linux || is_harmonyos { - cmd.arg("-Wl,--gc-sections"); - } else if is_cross_ios || is_cross_visionos || is_cross_macos || is_cross_tvos { - // ld64.lld called directly — no -Wl, prefix needed - cmd.arg("-dead_strip"); - } else if is_watchos || is_visionos { - cmd.arg("-Xlinker").arg("-dead_strip"); - } else { - // Native macOS/iOS via clang driver - cmd.arg("-Wl,-dead_strip"); - } - } else { - // MSVC link.exe / lld-link equivalents: - // /OPT:REF — drop unreferenced functions/data (= --gc-sections) - // /OPT:ICF — fold identical COMDATs (= --icf=safe) - // These are documented as defaults under /RELEASE, but Perry doesn't - // pass /RELEASE so the linker falls back to /OPT:NOREF, pulling in the - // entire perry-stdlib archive even when only a fraction is used. - cmd.arg("/OPT:REF"); - if debug_symbols { - // `/DEBUG` makes lld-link emit a PDB next to the .exe from the - // debug info already present in the input objects/libs. Without - // it, perry binaries have no symbol table and a RUST_BACKTRACE - // panic is an unreadable list of `` — there is no other - // way to diagnose a runtime crash in a compiled Windows app. - // Skip /OPT:ICF here: COMDAT folding collapses distinct - // identical-bodied functions to one symbol, which would make the - // very backtrace this flag exists to produce ambiguous. - cmd.arg("/DEBUG"); - } else { - cmd.arg("/OPT:ICF"); - } - } - - // Link libraries - stdlib bundles perry-runtime; runtime provides base FFI symbols. - // Note: libperry_stdlib.a may omit some runtime symbols (js_register_class_method, - // js_register_class_getter, etc.) due to Rust DCE on rlib dependencies. We always - // link libperry_runtime.a as a fallback to fill these gaps. On macOS/Linux/ELF the - // linker uses first-definition-wins for archives, so no duplicate symbol errors arise. - // When UI lib is also linked, it bundles its own copy of perry-runtime. - // For Android (ELF), skip the extra runtime when UI provides it. - // On Windows (MSVC), always link the runtime — the UI lib's rlib dependency on - // perry-runtime may not include all symbols (e.g., perry_init_guard_check_and_set). - // watchOS: swiftc treats duplicate symbols as errors (not warnings like clang), - // so skip the standalone runtime when the UI lib already bundles it. - // Note: even when bitcode_linked is true, we still link the .a archives. - // The merged .o contains the crate code but NOT the Rust standard library - // symbols (alloc, std::thread_local, etc.). The .a archive provides those - // as a fallback — the linker only pulls object files from the .a that - // resolve still-undefined symbols (first-definition-wins on macOS). - let skip_runtime = (is_android || is_watchos || is_visionos) - && (ctx.needs_ui || is_watchos) - && find_ui_library(target).is_some(); - let well_known_libs: Vec = if prefer_well_known_before_stdlib { - well_known_libs - .iter() - .map(|wk| { - strip_duplicate_objects_from_well_known_lib(wk).unwrap_or_else(|_| wk.clone()) - }) - .collect() - } else { - well_known_libs.to_vec() - }; - if !skip_runtime { - if ctx.needs_stdlib || is_windows { - // On Windows/MSVC, always try to link stdlib because codegen unconditionally - // declares all stdlib extern functions, creating import references that MSVC - // won't dead-strip. On macOS/Linux, the linker ignores unreferenced archives. - if let Some(ref stdlib) = stdlib_lib { - // Windows: link the standalone perry_runtime.lib FIRST so - // its symbols win lld-link's /FORCE:MULTIPLE "first - // definition wins" rule over the perry-runtime copies - // *bundled* inside perry_stdlib.lib and the - // /WHOLEARCHIVE'd perry_ui_windows.lib. Auto-optimize - // refreshes perry-runtime + perry-stdlib but NOT - // perry-ui-windows, so the UI lib's bundled runtime is - // perpetually stale; the /WHOLEARCHIVE force-includes its - // js_* symbols, and without this the stale copy shadows a - // genuine runtime fix (e.g. the js_shadow_frame_pop bounds - // guard, #880) — the crash it fixes still fires because the - // guarded function never gets linked. The standalone - // runtime_lib is the canonical / auto-optimize-fresh - // source; making it authoritative on Windows matches every - // other platform (all of which already link runtime_lib). - if is_windows { - cmd.arg(runtime_lib); - } - if prefer_well_known_before_stdlib { - for wk in &well_known_libs { - cmd.arg(wk); - } - } - // Tier-3 (tvOS/watchOS) std-duplication dedup; no-op elsewhere. - cmd.arg(&dedup_stdlib_for_tier3(target, stdlib)); - // #466 Phase 4 step 2: well-known bindings normally join the - // link line right after perry-stdlib so they cover the exact - // `_js_*` symbol gap that was just opened by stripping the - // corresponding feature from the perry-stdlib rebuild. - // - // In no-auto/fallback mode the full prebuilt stdlib may still - // contain method-value bridge objects that reference wrapper - // symbols (for example external net Socket helpers). Archives - // are scanned left-to-right, so repeat the well-known libs - // after stdlib as well: the first occurrence lets wrapper - // definitions win over duplicate bundled stdlib functions, - // and the second resolves stdlib bridge references. - for wk in &well_known_libs { - cmd.arg(wk); - } - // Also link runtime for symbols DCE'd from stdlib's bundled - // perry-runtime; on tier-3 it's first stripped of stdlib's objects. - if !is_android && !is_windows { - cmd.arg(&dedup_runtime_for_tier3(target, runtime_lib, stdlib)); - } - } else { - if ctx.needs_stdlib { - eprintln!( - "Warning: stdlib required but {} not found, using runtime-only", - if is_windows { - "perry_stdlib.lib" - } else { - "libperry_stdlib.a" - } - ); - } - cmd.arg(runtime_lib); - } - } else { - // Runtime-only linking — no stdlib needed - cmd.arg(runtime_lib); - } - } else if ctx.needs_stdlib { - // Android + UI: runtime is provided by UI lib, but stdlib must still be linked - // separately (UI lib does not bundle perry-stdlib). - if let Some(ref stdlib) = stdlib_lib { - if prefer_well_known_before_stdlib { - for wk in &well_known_libs { - cmd.arg(wk); - } - } - cmd.arg(stdlib); - // #466 Phase 4 step 2: see the parallel comment in the - // non-Android branch above. - for wk in &well_known_libs { - cmd.arg(wk); - } - } else { - eprintln!("Warning: stdlib required but libperry_stdlib.a not found"); - } - } - - // Issue #76 — wasmi host runtime, opt-in via `--enable-wasm-runtime`. - // Append after stdlib so the linker can resolve `perry_wasm_host_*` - // symbols referenced by the always-present `js_webassembly_*` FFIs in - // perry-runtime. - if let Some(ref wasm_host) = wasm_host_lib { - cmd.arg(wasm_host); - } - - if is_windows { - cmd.arg(format!("/OUT:{}", exe_path.display())); - } else { - cmd.arg("-o").arg(exe_path).arg("-lc"); - } - - // For plugin hosts, export symbols so dlopen'd plugins can resolve them. - // Plugins are dylibs loaded via dlopen — they need to resolve: - // 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)); - } - } - } - // 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"); - } - } - - if is_watchos { - // watchOS frameworks (swiftc auto-links Swift stdlib on the non-game-loop path) - let is_watchos_game_loop = compiled_features.iter().any(|f| f == "watchos-game-loop"); - let is_watchos_swift_app = compiled_features.iter().any(|f| f == "watchos-swift-app"); - if !is_watchos_game_loop { - cmd.arg("-framework").arg("SwiftUI"); - } - cmd.arg("-framework") - .arg("WatchKit") - .arg("-framework") - .arg("Foundation") - .arg("-framework") - .arg("CoreFoundation") - .arg("-framework") - .arg("Security") - .arg("-lSystem") - .arg("-lresolv"); - if is_watchos_game_loop { - // QuartzCore for CAMetalLayer-backed rendering (Metal.framework is NOT - // in the watchOS SDK — the native lib must dlopen it or supply its own - // path to the device's Metal dylib). -lobjc for the dynamic - // WKApplicationDelegate class registered from watchos_game_loop.rs. - cmd.arg("-framework").arg("QuartzCore").arg("-lobjc"); - } - if is_watchos_swift_app { - // SceneKit for SceneView-backed 3D rendering from the native lib's - // `@main struct App: App`. The lib may additionally use Canvas (2D, - // already covered by SwiftUI) or SpriteKit (opt-in via the - // manifest's `frameworks` list). - cmd.arg("-framework").arg("SceneKit"); - } - } else if is_ios { - // iOS frameworks - cmd.arg("-framework") - .arg("UIKit") - .arg("-framework") - .arg("Foundation") - .arg("-framework") - .arg("WebKit") // perry/ui WebView (#658) — WKWebView. - .arg("-framework") - .arg("CoreGraphics") - .arg("-framework") - .arg("Security") - .arg("-framework") - .arg("CoreFoundation") - .arg("-framework") - .arg("SystemConfiguration") - .arg("-framework") - .arg("QuartzCore") - .arg("-framework") - .arg("AVFAudio") // AVAudioEngine for audio capture - .arg("-framework") - .arg("AVFoundation") // Camera capture (AVCaptureSession) - .arg("-framework") - .arg("CoreMedia") // CMSampleBuffer - .arg("-framework") - .arg("CoreVideo") // CVPixelBuffer - .arg("-framework") - .arg("UserNotifications") // UNUserNotificationCenter (perry/system notificationSend) - .arg("-framework") - .arg("CoreLocation") // CLCircularRegion for UNLocationNotificationTrigger (#96) - .arg("-framework") - .arg("MediaPlayer") // perry/media — Now Playing + Remote Command Center - .arg("-framework") - .arg("MapKit") // perry/ui MapView (#517) — MKMapView - .arg("-framework") - .arg("PDFKit") // perry/ui PdfView (#516) — PDFView - .arg("-framework") - .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) - .arg("-framework") - .arg("Network") // perry/system network reachability (#582) - .arg("-liconv") - .arg("-lresolv") - .arg("-lobjc") - .arg("-lSystem"); - } else if is_visionos { - cmd.arg("-framework") - .arg("SwiftUI") - .arg("-framework") - .arg("UIKit") - .arg("-framework") - .arg("Foundation") - .arg("-framework") - .arg("CoreGraphics") - .arg("-framework") - .arg("Security") - .arg("-framework") - .arg("CoreFoundation") - .arg("-framework") - .arg("SystemConfiguration") - .arg("-framework") - .arg("QuartzCore") - .arg("-framework") - .arg("AVFAudio") - .arg("-framework") - .arg("AVFoundation") - .arg("-framework") - .arg("CoreMedia") - .arg("-framework") - .arg("CoreVideo") - .arg("-framework") - .arg("MediaPlayer") // perry/media — Now Playing + Remote Command Center - .arg("-framework") - .arg("MapKit") // perry/ui MapView (#517) — MKMapView (visionOS) - .arg("-framework") - .arg("PDFKit") // perry/ui PdfView (#516) — PDFView (visionOS) - .arg("-framework") - .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) - .arg("-liconv") - .arg("-lresolv") - .arg("-lobjc") - .arg("-lSystem"); - } else if is_tvos { - // tvOS frameworks (UIKit-based, like iOS) - cmd.arg("-framework") - .arg("UIKit") - .arg("-framework") - .arg("Foundation") - .arg("-framework") - .arg("CoreGraphics") - .arg("-framework") - .arg("Security") - .arg("-framework") - .arg("CoreFoundation") - .arg("-framework") - .arg("SystemConfiguration") - .arg("-framework") - .arg("QuartzCore") - .arg("-framework") - .arg("AVFoundation") - .arg("-framework") - .arg("GameController") - .arg("-framework") - .arg("Metal") - .arg("-framework") - .arg("MapKit") // perry/ui MapView (#517) — MKMapView (tvOS) - .arg("-framework") - .arg("MediaPlayer") // perry/media — Now Playing + Siri Remote - .arg("-framework") - .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) - .arg("-liconv") - .arg("-lresolv") - .arg("-lobjc") - .arg("-lSystem"); - } else if is_harmonyos { - // OpenHarmony system libraries. musl folds m/pthread/dl into libc.a so - // the -l flags are no-ops on the toolchain side; we emit them anyway - // because cargo's static archives reference them and the OHOS dynamic - // linker resolves them at load time. - cmd.arg("-Wl,--allow-multiple-definition") - .arg("-lm") - .arg("-lpthread") - .arg("-ldl"); - // `libace_napi.z.so` provides napi_module_register + napi_create_* - // (consumed by perry-runtime/src/ohos_napi.rs). OHOS naming convention - // is `.z.so` — the `-l` flag strips `lib` and `.so` but NOT the - // middle `.z`, so `-lace_napi.z` is the deliberate spelling. - cmd.arg("-lace_napi.z"); - // `libhilog_ndk.z.so` provides OH_LOG_Print, used by Perry's - // `js_console_log_*` family on harmonyos to route compiled-TS - // console output to hilog (so Perry-emitted log lines surface - // in DevEco/hdc the same way ArkTS console.log does), and by the - // arkts_callbacks bridge for diagnostic register/invoke traces. - cmd.arg("-lhilog_ndk.z"); - // `libtime_service_ndk.so` provides OH_TimeService_GetTimeZone, - // referenced by the `iana-time-zone` crate (pulled in transitively - // by `chrono` etc.) when it detects an OHOS target. The OHOS - // dynamic loader rejects libentry.so at app launch if this isn't - // listed in DT_NEEDED. Note: no `.z.` in the soname, unlike the - // ace_napi / hilog_ndk libs above. - cmd.arg("-ltime_service_ndk"); - } else if is_android { - // Android system libraries - cmd.arg("-Wl,--allow-multiple-definition") - .arg("-lm") - .arg("-ldl") - .arg("-llog"); - - // Stub for JNI_GetCreatedJavaVMs: the jni-sys crate declares this extern - // symbol, but Android has no libjvm.so and libnativehelper.so is only - // available at API 31+. Perry gets the JavaVM from JNI_OnLoad and never - // calls this function, so compile a no-op C stub to satisfy the linker. - let stub_dir = std::env::temp_dir().join(format!("perry_jni_stub_{}", std::process::id())); - std::fs::create_dir_all(&stub_dir).ok(); - let stub_c = stub_dir.join("jni_stub.c"); - let stub_o = stub_dir.join("jni_stub.o"); - std::fs::write( - &stub_c, - concat!( - "typedef int jint;\n", - "typedef jint jsize;\n", - "jint JNI_GetCreatedJavaVMs(void **vm_buf, jsize buf_len, jsize *n_vms) {\n", - " if (n_vms) *n_vms = 0;\n", - " return 0;\n", - "}\n", - ), - ) - .ok(); - let ndk_home = std::env::var("ANDROID_NDK_HOME").unwrap_or_default(); - // #1508: see platform_cmd.rs — same host-tag bug. - let host_tag = if cfg!(target_os = "macos") { - "darwin-x86_64" - } else if cfg!(target_os = "windows") { - "windows-x86_64" - } else { - "linux-x86_64" - }; - let ndk_clang = format!( - "{}/toolchains/llvm/prebuilt/{}/bin/aarch64-linux-android24-clang{}", - ndk_home, - host_tag, - if cfg!(target_os = "windows") { - ".cmd" - } else { - "" - } - ); - let stub_ok = Command::new(&ndk_clang) - .args(["-c", "-fPIC", "-target", "aarch64-linux-android24"]) - .arg("-o") - .arg(&stub_o) - .arg(&stub_c) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if stub_ok { - cmd.arg(&stub_o); - } - } else if is_linux { - // Linux system libraries (cross-compile target) - // Allow multiple definitions: stdlib bundles perry-runtime symbols, - // and we also link perry-runtime directly for symbols DCE'd from stdlib. - // macOS Mach-O uses first-definition-wins natively; ELF linkers need this flag. - cmd.arg("-Wl,--allow-multiple-definition") - .arg("-lm") - .arg("-lpthread") - .arg("-ldl"); - - // -lssl/-lcrypto are vestigial — Perry's stdlib is rustls-only (no - // system OpenSSL). On glibc they happen to be present so the link - // tolerates them, but a static musl sysroot has no libssl.a/libcrypto.a - // and the link would fail. Skip them for musl (#4826). - if ctx.needs_stdlib && !is_musl { - cmd.arg("-lssl").arg("-lcrypto"); - } - } else if is_windows { - windows_link::add_system_libs(&mut cmd); - windows_link::embed_app_manifest(&mut cmd, ctx.needs_ui); - } else { - // macOS frameworks for runtime (sysinfo, etc.) and V8. - // Gate on `!is_harmonyos` so the macOS host doesn't leak its - // frameworks into ELF cross-compile targets that fall through this - // `else` branch — `cfg!(target_os = "macos")` is true whenever we're - // running ON macOS, regardless of the actual target. - if (cfg!(target_os = "macos") || is_cross_macos) && !is_harmonyos { - cmd.arg("-framework") - .arg("Security") - .arg("-framework") - .arg("CoreFoundation") - .arg("-framework") - .arg("SystemConfiguration") - .arg("-liconv") - .arg("-lresolv") - .arg("-lobjc"); - } - - // On Linux (native, not cross-compiling to macOS), link against system libraries - if cfg!(target_os = "linux") && !is_cross_macos { - cmd.arg("-lm").arg("-lpthread").arg("-ldl"); - - if ctx.needs_stdlib { - cmd.arg("-lssl").arg("-lcrypto"); - } - } - } - - // Issue #607 — watchOS targets always link the UI lib regardless of - // `ctx.needs_ui`. The watchOS Swift template (`PerryWatchApp.swift`) - // unconditionally references four `@_silgen_name`'d Rust symbols - // (`perry_watchos_tree_version` / `perry_watchos_toggle_changed` / - // `perry_watchos_toast_seq` / `perry_watchos_toast_dismiss`) that - // live in `libperry_ui_watchos.a`. A console-only TS program has - // `needs_ui = false`, so the UI lib was previously not added to the - // link line — leaving those four symbols undefined and the link - // failing. Forcing the UI lib for watchOS adds ~MBs but unblocks - // `console.log("ok")`-only programs from compiling. - let force_ui = is_watchos; - if ctx.needs_ui || force_ui { - // When geisterhand is enabled, prefer the geisterhand-enabled UI lib - // (it contains widget registration calls that the normal lib doesn't have) - let ui_lib_option = if ctx.needs_geisterhand { - find_geisterhand_ui(target).or_else(|| find_ui_library(target)) - } else { - find_ui_library(target) - }; - if let Some(ui_lib) = ui_lib_option { - // The UI staticlib bundles perry_runtime + Rust std. When perry-stdlib - // is also linked (which bundles the same), duplicate symbols cause - // crashes (conflicting static state initialization). Strip duplicates - // on Apple platforms. On Windows/Android, skip strip-dedup because - // perry_runtime objects contain monomorphizations needed by UI code, - // and --allow-multiple-definition (ELF) / /FORCE:MULTIPLE (COFF) - // handles duplicate symbols safely. On Android, skip_runtime=true - // means the UI lib is the sole provider of perry-runtime symbols. - let ui_lib = if is_windows || is_android || is_visionos { - ui_lib - } else { - match strip_duplicate_objects_from_lib(&ui_lib) { - Ok(trimmed) => trimmed, - Err(e) => { - eprintln!("[strip-dedup] skipped for UI lib (non-fatal): {e}"); - ui_lib - } - } - }; - if is_windows { - // lld-link scans archives left-to-right once. The UI lib is - // linked before user code objects, so UI symbols aren't yet - // undefined when the lib is scanned. /WHOLEARCHIVE forces all - // objects from the archive to be included unconditionally. - cmd.arg(format!("/WHOLEARCHIVE:{}", ui_lib.display())); - } else { - cmd.arg(&ui_lib); - } - - if is_watchos { - // SwiftUI/WatchKit already linked above - } else if is_ios || is_visionos || is_tvos { - // UIKit already linked above - } else if is_android { - // Allow multiple definitions from perry-runtime in both UI lib and native libs - cmd.arg("-Wl,--allow-multiple-definition"); - } else if is_linux { - // Allow multiple definitions from perry-runtime in both stdlib and UI lib - cmd.arg("-Wl,--allow-multiple-definition"); - // libperry_ui_gtk4.a's glib::source::trampoline_local - // closures call perry-stdlib's js_stdlib_process_pending / - // js_promise_run_microtasks. When ctx.needs_stdlib is false - // (bare UI program), stdlib isn't linked via the earlier - // path. Force-link it here with --whole-archive so every - // object is pulled unconditionally. --allow-multiple-definition - // above lets it coexist with the runtime stub at - // perry-runtime/src/stdlib_stubs.rs. The async-runtime - // feature is force-enabled for UI builds (see - // build_optimized_libs), so the real js_stdlib_process_pending - // is guaranteed present in libperry_stdlib.a. - let linux_stdlib_for_ui = - stdlib_lib.clone().or_else(|| find_stdlib_library(target)); - if let Some(ref stdlib) = linux_stdlib_for_ui { - cmd.arg("-Wl,--whole-archive") - .arg(stdlib) - .arg("-Wl,--no-whole-archive"); - } - // GTK4 libraries via pkg-config. The fallback fires in two - // distinct cases: pkg-config not installed (spawn fails), OR - // installed but `gtk4.pc` not on the search path (exit != 0 - // — happens e.g. on Ubuntu hosts where libgtk-4-dev is split - // across packages, or when PKG_CONFIG_PATH is locked down). - // Pre-fix the second case silently emitted no GTK link flags - // and the link bombed with hundreds of `g_object_unref` / - // `gtk_widget_*` undefined references (#181). - let mut got_gtk_libs = false; - let pc_out = Command::new("pkg-config").args(["--libs", "gtk4"]).output(); - if let Ok(ref output) = pc_out { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - got_gtk_libs = true; - } - } - if !got_gtk_libs { - // Mirrors what `pkg-config --libs gtk4` returns on a - // standard libgtk-4-dev install. Pre-fix only listed the - // glib/gio core, which left pango/cairo/gdk_pixbuf - // undefined. - eprintln!( - "Warning: `pkg-config --libs gtk4` did not return GTK4 \ - linker flags ({}). Falling back to a hardcoded GTK4 \ - link set — install `libgtk-4-dev` (Debian/Ubuntu) or \ - `gtk4-devel` (Fedora/RHEL) and ensure pkg-config can \ - find `gtk4.pc` to silence this warning.", - match &pc_out { - Err(e) => format!("pkg-config not runnable: {e}"), - Ok(o) if !o.status.success() => format!( - "pkg-config exited {}: {}", - o.status.code().unwrap_or(-1), - String::from_utf8_lossy(&o.stderr).trim() - ), - Ok(_) => "no output".to_string(), - } - ); - for lib in [ - "-lgtk-4", - "-lgio-2.0", - "-lgobject-2.0", - "-lglib-2.0", - "-lpangocairo-1.0", - "-lpango-1.0", - "-lharfbuzz", - "-lgdk_pixbuf-2.0", - "-lcairo-gobject", - "-lcairo", - "-lgraphene-1.0", - ] { - cmd.arg(lib); - } - } - // PulseAudio for audio capture (only needed with UI) - cmd.arg("-lpulse-simple").arg("-lpulse"); - // GStreamer libs — pulled in by perry-ui-gtk4's gstreamer-rs - // dep (added in v0.5.440 for the perry/media playbin backend). - // GTK4's pkg-config doesn't transitively reference the - // gstreamer-1.0 sonames, so the `-lgstreamer-1.0` (and the - // base/app/video/audio sublibs that gstreamer-rs's playbin - // path touches) have to land on the link line explicitly or ld - // fails with `undefined reference to gst_message_parse_buffering` - // + `DSO missing from command line` (#423). Same pkg-config → - // hardcoded-fallback shape as the GTK4 block above. - let mut got_gst_libs = false; - let gst_pc_out = Command::new("pkg-config") - .args([ - "--libs", - "gstreamer-1.0", - "gstreamer-base-1.0", - "gstreamer-app-1.0", - "gstreamer-video-1.0", - "gstreamer-audio-1.0", - ]) - .output(); - if let Ok(ref output) = gst_pc_out { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - got_gst_libs = true; - } - } - if !got_gst_libs { - eprintln!( - "Warning: `pkg-config --libs gstreamer-1.0 ...` did not \ - return GStreamer linker flags ({}). Falling back to a \ - hardcoded GStreamer link set — install \ - `libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev` \ - (Debian/Ubuntu) or `gstreamer1-devel \ - gstreamer1-plugins-base-devel` (Fedora/RHEL) to silence \ - this warning.", - match &gst_pc_out { - Err(e) => format!("pkg-config not runnable: {e}"), - Ok(o) if !o.status.success() => format!( - "pkg-config exited {}: {}", - o.status.code().unwrap_or(-1), - String::from_utf8_lossy(&o.stderr).trim() - ), - Ok(_) => "no output".to_string(), - } - ); - for lib in [ - "-lgstreamer-1.0", - "-lgstbase-1.0", - "-lgstapp-1.0", - "-lgstvideo-1.0", - "-lgstaudio-1.0", - ] { - cmd.arg(lib); - } - } - // libshumate — GNOME's GTK4 vector-tile map widget for the - // perry/ui MapView (#517). Same pkg-config → hardcoded - // fallback shape as GTK4 / GStreamer above. - let mut got_shumate_libs = false; - let shumate_pc_out = Command::new("pkg-config") - .args(["--libs", "shumate-1.0"]) - .output(); - if let Ok(ref output) = shumate_pc_out { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - got_shumate_libs = true; - } - } - if !got_shumate_libs { - eprintln!( - "Warning: `pkg-config --libs shumate-1.0` did not return \ - libshumate linker flags ({}). Falling back to \ - `-lshumate-1.0` — install `libshumate-dev` \ - (Debian/Ubuntu) or `libshumate-devel` (Fedora/RHEL) to \ - silence this warning.", - match &shumate_pc_out { - Err(e) => format!("pkg-config not runnable: {e}"), - Ok(o) if !o.status.success() => format!( - "pkg-config exited {}: {}", - o.status.code().unwrap_or(-1), - String::from_utf8_lossy(&o.stderr).trim() - ), - Ok(_) => "no output".to_string(), - } - ); - cmd.arg("-lshumate-1.0"); - } - // WebKitGTK 6.0 + libsoup-3.0 — perry/ui WebView (#658, v0.5.864). - // perry-ui-gtk4's webkit6/soup3 deps reference symbols like - // `soup_check_version` from libsoup-3.0 transitively; without - // explicit `-lsoup-3.0` ld errors with `DSO missing from - // command line`. Same pkg-config → hardcoded-fallback shape - // as GTK4 / GStreamer / shumate above. - let mut got_webkit_libs = false; - let webkit_pc_out = Command::new("pkg-config") - .args(["--libs", "webkitgtk-6.0", "libsoup-3.0"]) - .output(); - if let Ok(ref output) = webkit_pc_out { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - got_webkit_libs = true; - } - } - if !got_webkit_libs { - eprintln!( - "Warning: `pkg-config --libs webkitgtk-6.0 libsoup-3.0` \ - did not return WebKitGTK linker flags ({}). Falling \ - back to a hardcoded set — install `libwebkitgtk-6.0-dev` \ - (Debian/Ubuntu) which pulls libsoup-3.0-dev + \ - libjavascriptcoregtk-6.0-dev to silence this warning.", - match &webkit_pc_out { - Err(e) => format!("pkg-config not runnable: {e}"), - Ok(o) if !o.status.success() => format!( - "pkg-config exited {}: {}", - o.status.code().unwrap_or(-1), - String::from_utf8_lossy(&o.stderr).trim() - ), - Ok(_) => "no output".to_string(), - } - ); - for lib in ["-lwebkitgtk-6.0", "-ljavascriptcoregtk-6.0", "-lsoup-3.0"] { - cmd.arg(lib); - } - } - } else if is_windows { - // Win32 system libs already linked above - } else { - if cfg!(target_os = "macos") || is_cross_macos { - cmd.arg("-framework").arg("AppKit"); - // perry/ui WebView (#658) — WKWebView / WKWebViewConfiguration. - cmd.arg("-framework").arg("WebKit"); - cmd.arg("-framework").arg("CoreGraphics"); - cmd.arg("-framework").arg("QuartzCore"); - cmd.arg("-framework").arg("AVFoundation"); - cmd.arg("-framework").arg("Metal"); - cmd.arg("-framework").arg("IOKit"); - cmd.arg("-framework").arg("DiskArbitration"); // needed by CoreGraphics - // perry/media — AVPlayer is in AVFoundation (already linked - // above). CoreMedia provides CMTime + CMTimeGetSeconds / - // CMTimeMakeWithSeconds used for seek + position. MediaPlayer - // provides MPNowPlayingInfoCenter / MPRemoteCommandCenter / - // MPMediaItemArtwork (lock screen + Touch Bar + Now Playing). - cmd.arg("-framework").arg("CoreMedia"); - cmd.arg("-framework").arg("MediaPlayer"); - // perry/ui MapView (#517) — MKMapView lives in MapKit. - cmd.arg("-framework").arg("MapKit"); - // perry/ui PdfView (#516) — PDFView lives in PDFKit, which - // also exposes the PDFDocument / PDFPage classes used for - // page-count + page-navigation queries. - cmd.arg("-framework").arg("PDFKit"); - // perry/system network reachability (#582) — NWPathMonitor. - cmd.arg("-framework").arg("Network"); - } - } - - match format { - OutputFormat::Text => { - println!("Linking perry/ui (native UI) from {}", ui_lib.display()) - } - OutputFormat::Json => {} - } - } else { - let (lib_name, build_cmd) = if is_watchos { - ( - "libperry_ui_watchos.a", - "cargo +nightly build -Z build-std=std,panic_abort --release -p perry-ui-watchos --target aarch64-apple-watchos (or --target aarch64-apple-watchos-sim for the simulator)", - ) - } else if is_tvos { - ( - "libperry_ui_tvos.a", - "cargo build --release -p perry-ui-tvos --target aarch64-apple-tvos", - ) - } else if is_visionos { - ("libperry_ui_visionos.a", "cargo build --release -p perry-ui-visionos --target aarch64-apple-visionos-sim") - } else if is_ios { - ( - "libperry_ui_ios.a", - "cargo build --release -p perry-ui-ios --target aarch64-apple-ios-sim", - ) - } else if is_android { - ( - "libperry_ui_android.a", - // #1529 — TLS model must be global-dynamic for the dlopen'd cdylib. - // `tls-model` is `-Z`-gated on the toolchains we ship against, so - // RUSTC_BOOTSTRAP=1 lets the gated flag through on a stable rustc. - "RUSTC_BOOTSTRAP=1 RUSTFLAGS=\"-Z tls-model=global-dynamic\" cargo build --release -p perry-ui-android --target aarch64-linux-android", - ) - } else if is_linux { - ( - "libperry_ui_gtk4.a", - "cargo build --release -p perry-ui-gtk4 --target x86_64-unknown-linux-gnu", - ) - } else if matches!(target, Some("windows-winui")) { - ( - "perry_ui_windows_winui.lib", - "cargo build --release -p perry-ui-windows-winui --target x86_64-pc-windows-msvc", - ) - } else if is_windows { - ( - "perry_ui_windows.lib", - "cargo build --release -p perry-ui-windows --target x86_64-pc-windows-msvc", - ) - } else { - ( - "libperry_ui_macos.a", - "cargo build --release -p perry-ui-macos", - ) - }; - return Err(anyhow!( - "perry/ui imported but {} not found. Build with: {}", - lib_name, - build_cmd - )); - } - } - - // Link geisterhand libraries if enabled - if ctx.needs_geisterhand { - // Auto-build geisterhand libraries if any are missing - let gh_missing = find_geisterhand_library(target).is_none() - || find_geisterhand_runtime(target).is_none() - || (ctx.needs_stdlib && find_geisterhand_stdlib(target).is_none()) - || (ctx.needs_ui && find_geisterhand_ui(target).is_none()); - if gh_missing { - build_geisterhand_libs(target, format)?; - } - - if let Some(gh_lib) = find_geisterhand_library(target) { - cmd.arg(&gh_lib); - // Link geisterhand-enabled runtime (has the registry + pump functions) - if let Some(gh_runtime) = find_geisterhand_runtime(target) { - cmd.arg(&gh_runtime); - // ELF linkers need --allow-multiple-definition; macOS Mach-O uses first-wins natively - if is_linux || is_android { - cmd.arg("-Wl,--allow-multiple-definition"); - } - } - // On Windows, re-link the stdlib after geisterhand to resolve - // forward references to geisterhand registry functions. - // lld-link scans archives left-to-right once, so the stdlib - // must appear after the geisterhand lib that references it. - // On Windows, force-include geisterhand registry symbols from stdlib. - // lld-link scans archives left-to-right once, so the stdlib's - // geisterhand objects are skipped on first scan (no references yet). - // /INCLUDE forces the linker to pull in the specific symbols. - if is_windows { - cmd.arg("/INCLUDE:perry_geisterhand_queue_action"); - cmd.arg("/INCLUDE:perry_geisterhand_queue_action1"); - cmd.arg("/INCLUDE:perry_geisterhand_queue_state_set"); - cmd.arg("/INCLUDE:perry_geisterhand_request_screenshot"); - cmd.arg("/INCLUDE:perry_geisterhand_register"); - cmd.arg("/INCLUDE:perry_geisterhand_pump"); - cmd.arg("/INCLUDE:perry_geisterhand_start"); - cmd.arg("/INCLUDE:perry_geisterhand_free_string"); - cmd.arg("/INCLUDE:perry_geisterhand_get_closure"); - cmd.arg("/INCLUDE:perry_geisterhand_get_registry_json"); - // Allow duplicate symbols from re-linked stdlib objects - cmd.arg("/FORCE:MULTIPLE"); - } - match format { - OutputFormat::Text => println!("Linking geisterhand (in-process fuzzer)"), - OutputFormat::Json => {} - } - } else { - return Err(anyhow!( - "Failed to build geisterhand libraries. Check that Perry source crates are available." - )); - } - } - - // Build and link external native libraries from perry.nativeLibrary manifests. - // Swift sources are deduplicated across the loop — modules sharing the same - // package.json all see the same swift_sources entries, but each file should - // be compiled + linked once. Without this, swift's mangled symbols for - // structs/classes duplicate N times. - let mut seen_swift_sources: std::collections::HashSet = - std::collections::HashSet::new(); - for native_lib in &ctx.native_libraries { - if let Some(ref target_config) = native_lib.target_config { - if !target_config.available { - if let (OutputFormat::Text, Some(reason)) = - (format, target_config.unavailable_reason.as_deref()) - { - println!( - "Skipping native library {} for this target: {}", - native_lib.module, reason - ); - } - continue; - } - - match format { - OutputFormat::Text => { - println!("Building native library: {} ...", native_lib.module) - } - OutputFormat::Json => {} - } - - // Issue #860 — prebuilt-distribution shortcut. When the - // wrapper's manifest specified a `prebuilt:` path that - // resolved to an on-disk static library, skip the cargo - // build entirely and link the prebuilt archive directly. - // `frameworks` / `libs` / `pkgConfig` / `lib_dirs` are - // still honored below — those are linker flags the host - // toolchain needs regardless of where the `.a` came from. - if let Some(prebuilt) = target_config.prebuilt.as_ref() { - if !prebuilt.exists() { - return Err(anyhow!( - "Prebuilt native library declared by {} not found at {}. \ - If this package is distributed via npm `optionalDependencies` \ - (esbuild/sharp pattern), make sure the per-platform subpackage \ - is installed for the current host/target.", - native_lib.module, - prebuilt.display() - )); - } - cmd.arg(prebuilt); - match format { - OutputFormat::Text => { - println!("Linking prebuilt native library: {}", prebuilt.display()) - } - OutputFormat::Json => {} - } - } else { - // Build the Rust crate - let cargo_toml = target_config.crate_path.join("Cargo.toml"); - if cargo_toml.exists() { - // Tier 3 targets (tvOS, watchOS) need nightly + build-std - let is_tier3 = matches!( - target, - Some("tvos") - | Some("tvos-simulator") - | Some("watchos") - | Some("watchos-simulator") - ); - - // #505: optionally wrap the cargo invocation in - // `sandbox-exec` on macOS to deny network and - // restrict FS writes during `build.rs` execution. - // Off by default for backwards compat; opted into - // via `PERRY_SANDBOX_BUILDRS=1`. Packages listed - // in `perry.allowUnsandboxedBuild` are exempt. - let mut cargo_cmd = - super::sandbox_buildrs::wrap_cargo_command(ctx, &native_lib.module); - if is_tier3 { - cargo_cmd.arg("+nightly"); - } - cargo_cmd - .arg("build") - .arg("--release") - .arg("--manifest-path") - .arg(&cargo_toml); - - // perry.toml `[native-library.""]` feature - // forwarding — see native_features.rs. - native_features::apply_native_library_override( - &mut cargo_cmd, - &ctx.project_root, - &native_lib.module, - matches!(format, OutputFormat::Text), - ); - - if let Some(triple) = rust_target_triple(target) { - cargo_cmd.arg("--target").arg(triple); - } - - if is_tier3 { - // Match perry-runtime's std build flags exactly so the std - // rlibs are bit-identical and dedupe at link time. Without - // this, native libs pull in a parallel std with different - // metadata hashes and the final Swift-driven link fails - // with hundreds of duplicate-symbol errors. - cargo_cmd.arg("-Zbuild-std=std,panic_abort"); - } - - // For Android, ensure 16 KB page size alignment (required by Google Play), - // and force the global-dynamic TLS model (#1529): the native lib's TLS - // relocations are baked into the dlopen'd `libperry_app.so`, so an - // Initial-Executable model (rustc's android default) crashes at load. - if is_android { - let tls_flag = super::optimized_libs::android_global_dynamic_tls_rustflag( - &mut cargo_cmd, - ); - cargo_cmd.env( - "CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS", - format!("-C link-arg=-Wl,-z,max-page-size=16384 {tls_flag}"), - ); - } - - // For HarmonyOS, point cargo at the OHOS SDK's clang + sysroot - // so cc-rs and rustc's linker invocation actually use the - // cross-toolchain instead of falling back to the host `cc`. - if is_harmonyos { - if let Some(sdk) = super::library_search::find_harmonyos_sdk() { - for (k, v) in super::library_search::harmonyos_cross_env(&sdk, target) { - cargo_cmd.env(k, v); - } - } - } - // #1508: For Android, do the same with the NDK so cc-rs can - // compile native C deps (libsqlite3-sys / libmimalloc-sys - // / etc.) using the NDK clang. Without this, cc-rs falls - // back to the host `cc` and fails with - // `failed to find tool "clang.exe"` on Windows (and - // architecturally-mismatched objects on Unix). - if is_android { - if let Some(ndk) = std::env::var_os("ANDROID_NDK_HOME") { - for (k, v) in super::library_search::android_cross_env( - std::path::Path::new(&ndk), - target, - ) { - cargo_cmd.env(k, v); - } - } - } - - // #1303 — when the wrapper crate's `build.rs` gates an - // optional vendored SDK on an env var (`frameworks_env`) - // and the dev declared a project-relative `framework_dir` - // in `perry.toml [google_auth]`, export the resolved - // absolute path so `build.rs` opts the real SDK in. This - // fires on the local machine AND on the `perry publish` - // worker (where the dev's shell env doesn't transfer, but - // the uploaded perry.toml + dir do). If the env var is - // already set we re-export the same value — idempotent. - if let Some(env_name) = target_config.frameworks_env.as_deref() { - if let Some(dir) = resolve_optional_framework_dir(env_name, args_input) { - if dir.is_dir() { - cargo_cmd.env(env_name, &dir); - } - } - } - - let cargo_status = cargo_cmd.status()?; - if !cargo_status.success() { - return Err(anyhow!( - "Failed to build native library crate for {}: {}", - native_lib.module, - target_config.crate_path.display() - )); - } - } - - // Find and link the static library - let lib_name = &target_config.lib_name; - if !lib_name.is_empty() { - // Search in the crate's target directory first, then standard paths. - // Refs #564: probe both `target/release/` and - // `target//release/` for native builds — cargo - // writes to the triple-prefixed dir when a default target is - // pinned via `[build] target` / `CARGO_BUILD_TARGET` / - // `rust-toolchain.toml`. - let crate_target_dir = target_config.crate_path.join("target"); - let lib_path = super::library_search::locate_native_lib_artifact( - &crate_target_dir, - target, - lib_name, - ); - - if let Some(lib) = lib_path { - let lib = dedup_native_lib_for_tier3(target, lib_name, lib); - // For shared libraries (.so) on Android, use -L/-l so the linker - // records just the soname (not the full build path) in DT_NEEDED. - if is_android && lib_name.ends_with(".so") { - if let Some(dir) = lib.parent() { - cmd.arg(format!("-L{}", dir.display())); - } - // Strip "lib" prefix and ".so" suffix for -l flag - let stem = lib_name.strip_prefix("lib").unwrap_or(lib_name); - let stem = stem.strip_suffix(".so").unwrap_or(stem); - cmd.arg(format!("-l{}", stem)); - } else { - // When building a plugin host on macOS, force-load plugin-related native - // libraries so their symbols are available for dlopen'd plugin dylibs. - let force_load = cfg!(target_os = "macos") - && ctx.needs_plugins - && native_lib.module.contains("plugin"); - if force_load { - cmd.arg(format!("-Wl,-force_load,{}", lib.display())); - } else if is_windows && lib.extension().map_or(false, |e| e == "lib") { - // On Windows, link native staticlibs directly — - // /FORCE:MULTIPLE handles duplicate symbols. - cmd.arg(&lib); - } else { - cmd.arg(&lib); - } - } - match format { - OutputFormat::Text => { - println!("Linking native library: {}", lib.display()) - } - OutputFormat::Json => {} - } - } else { - return Err(anyhow!( - "Native library {} not found after building {} crate", - lib_name, - native_lib.module - )); - } - } - } // closes else of `if let Some(prebuilt) = ...` (issue #860) - - // Add platform frameworks - for framework in &target_config.frameworks { - cmd.arg("-framework").arg(framework); - } - - // Issue #1304 — vendored-SDK frameworks (e.g. GoogleSignIn - // for `@perryts/google-auth`). These live in a directory the - // app dev built/downloaded locally, named by the wrapper's - // `frameworks_env` env var. When that var is set and resolves - // to an existing directory, add it as a framework search path - // (`-F `) and emit one `-framework ` per declared - // `optional_frameworks` entry. When it's unset (or points at - // something that isn't a directory) we skip silently: the - // wrapper's `#if canImport(...)` Swift bridge already compiles - // a no-SDK fallback path, so the binary still links and - // returns a runtime "framework not linked" result rather than - // failing with undefined `GID*` symbols. - // - // Contract is static frameworks only — `-framework` links the - // archive directly with no `.app/Frameworks/` embed + rpath. - // - // #1303 — the search dir resolves from the `frameworks_env` env - // var (local) or, when unset, the project-relative - // `perry.toml [google_auth].framework_dir` (so a `perry publish` - // worker build links the real SDK instead of the stub). - if let Some(env_name) = target_config.frameworks_env.as_deref() { - if !target_config.optional_frameworks.is_empty() { - match resolve_optional_framework_dir(env_name, args_input) { - Some(dir) if dir.is_dir() => { - cmd.arg("-F").arg(&dir); - for framework in &target_config.optional_frameworks { - cmd.arg("-framework").arg(framework); - } - if let OutputFormat::Text = format { - println!( - "Linking {} optional framework(s) for {} ({})", - target_config.optional_frameworks.len(), - native_lib.module, - dir.display() - ); - } - } - Some(dir) => { - if let OutputFormat::Text = format { - println!( - "Skipping optional frameworks for {}: {:?} is not a directory", - native_lib.module, - dir.display() - ); - } - } - None => { - // Neither env var nor framework_dir → silent skip - // (the wrapper's canImport fallback keeps linking). - } - } - } - } - - // Add library search paths. MSVC link.exe takes `/LIBPATH:`; - // every other linker we drive (clang/ld on Apple, gcc/ld on - // Linux/Android/HarmonyOS) understands `-L`. Mirror the - // `target_config.libs` branch immediately below so a - // `targets.windows.libDirs` entry actually resolves the - // `{lib}.lib` lookups instead of being a silent no-op. - for lib_dir in &target_config.lib_dirs { - if is_windows { - cmd.arg(format!("/LIBPATH:{}", lib_dir.display())); - } else { - cmd.arg(format!("-L{}", lib_dir.display())); - } - } - - // Add platform libraries - for lib in &target_config.libs { - if is_windows { - cmd.arg(format!("{}.lib", lib)); - } else { - cmd.arg(format!("-l{}", lib)); - } - } - - // Add pkg-config libraries - for pkg in &target_config.pkg_config { - pkg_config::validate_pkg_config_name(pkg)?; - if let Ok(output) = Command::new("pkg-config").args(["--libs", pkg]).output() { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - } - } - } - - for backend in &target_config.backends { - if !backend.available { - if let (OutputFormat::Text, Some(reason)) = - (format, backend.unavailable_reason.as_deref()) - { - println!( - "Skipping {} backend for {}: {}", - backend.backend.as_str(), - native_lib.module, - reason - ); - } - } - } - - for backend in select_available_backend_link_metadata(target_config) { - if let Some(prebuilt) = backend.prebuilt.as_ref() { - if !prebuilt.exists() { - return Err(anyhow!( - "Prebuilt {} backend library declared by {} not found at {}. \ - Install the matching optional dependency or update \ - perry.nativeLibrary.targets..backends.{}.prebuilt.", - backend.backend.as_str(), - native_lib.module, - prebuilt.display(), - backend.backend.as_str() - )); - } - cmd.arg(prebuilt); - match format { - OutputFormat::Text => println!( - "Linking prebuilt {} backend library: {}", - backend.backend.as_str(), - prebuilt.display() - ), - OutputFormat::Json => {} - } - } - - for framework in &backend.frameworks { - cmd.arg("-framework").arg(framework); - } - for lib_dir in &backend.lib_dirs { - if is_windows { - cmd.arg(format!("/LIBPATH:{}", lib_dir.display())); - } else { - cmd.arg(format!("-L{}", lib_dir.display())); - } - } - for lib in &backend.libs { - if is_windows { - cmd.arg(format!("{}.lib", lib)); - } else { - cmd.arg(format!("-l{}", lib)); - } - } - for pkg in &backend.pkg_config { - pkg_config::validate_pkg_config_name(pkg)?; - if let Ok(output) = Command::new("pkg-config").args(["--libs", pkg]).output() { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - } - } - } - } - - // Compile manifest-declared Swift sources to object files and - // append them to the link line. Used by `--features watchos-swift-app` - // so a native lib can ship its own `@main struct App: App`. - if !target_config.swift_sources.is_empty() { - if !is_watchos { - return Err(anyhow!( - "perry.nativeLibrary.targets..swift_sources is only supported on watchos/watchos-simulator" - )); - } - let swift_sdk = if target == Some("watchos-simulator") { - "watchsimulator" - } else { - "watchos" - }; - // arm64_32 watchOS (Series 4-8 / SE): opt-in, matches the app - // binary's triple in platform_cmd.rs so the native @main lib - // links against the same arch. - let swift_arm64_32 = - target == Some("watchos") && std::env::var("PERRY_WATCHOS_ARM64_32").is_ok(); - let swift_watchos_min = - std::env::var("PERRY_WATCHOS_MIN").unwrap_or_else(|_| "11.0".to_string()); - let swift_triple_owned; - let swift_triple = if target == Some("watchos-simulator") { - "arm64-apple-watchos10.0-simulator" - } else if swift_arm64_32 { - swift_triple_owned = format!("arm64_32-apple-watchos{}", swift_watchos_min); - swift_triple_owned.as_str() - } else { - "arm64-apple-watchos26.0" - }; - let swift_sysroot = String::from_utf8( - Command::new("xcrun") - .args(["--sdk", swift_sdk, "--show-sdk-path"]) - .output()? - .stdout, - )? - .trim() - .to_string(); - let swiftc = String::from_utf8( - Command::new("xcrun") - .args(["--sdk", swift_sdk, "--find", "swiftc"]) - .output()? - .stdout, - )? - .trim() - .to_string(); - - let swift_obj_dir = - std::env::temp_dir().join(format!("perry_swift_{}", std::process::id())); - std::fs::create_dir_all(&swift_obj_dir).ok(); - - for swift_src in &target_config.swift_sources { - if !swift_src.exists() { - return Err(anyhow!( - "Swift source not found: {} (declared in {}'s nativeLibrary.swift_sources)", - swift_src.display(), - native_lib.module - )); - } - let canonical = swift_src - .canonicalize() - .unwrap_or_else(|_| swift_src.clone()); - if !seen_swift_sources.insert(canonical) { - continue; - } - let stem = swift_src - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("swift_src"); - let obj_out = swift_obj_dir.join(format!("{}.o", stem)); - let status = Command::new(&swiftc) - .arg("-target") - .arg(swift_triple) - .arg("-sdk") - .arg(&swift_sysroot) - .arg("-parse-as-library") - .arg("-emit-object") - .arg("-O") - .arg("-o") - .arg(&obj_out) - .arg(swift_src) - .status()?; - if !status.success() { - return Err(anyhow!( - "Failed to compile Swift source: {}", - swift_src.display() - )); - } - cmd.arg(&obj_out); - match format { - OutputFormat::Text => { - println!("Linking Swift object: {}", obj_out.display()) - } - OutputFormat::Json => {} - } - } - } - - // Metal sources are compiled + packed into .app/default.metallib - // after the `.app` bundle is created below. Just validate the target - // here so we fail early with a clear message instead of silently - // dropping shaders on non-Apple-bundle targets. - if !target_config.metal_sources.is_empty() - && !matches!( - target, - Some("ios") - | Some("ios-simulator") - | Some("tvos") - | Some("tvos-simulator") - | Some("watchos") - | Some("watchos-simulator") - | Some("visionos") - | Some("visionos-simulator") - ) - { - return Err(anyhow!( - "perry.nativeLibrary.targets..metal_sources is only supported on ios / ios-simulator / tvos / tvos-simulator / watchos / watchos-simulator / visionos / visionos-simulator" - )); - } - } - } - - // macOS privacy APIs (including camera/microphone requests made by - // WKWebView) consult the process Info.plist for usage-description keys. - // Perry's direct desktop output is a Mach-O executable, not a .app bundle, - // so embed a minimal Info.plist section when linking native macOS UI apps. - // Without this, WKWebView media capture can be denied by the platform even - // when WKUIDelegate grants the web-origin permission. - let is_macos_executable = - (target.is_none() && cfg!(target_os = "macos")) || matches!(target, Some("macos")); - let mut embedded_info_plist_path: Option = None; - if ctx.needs_ui && is_macos_executable { - let exe_stem = exe_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("perry-app"); - let bundle_id = format!( - "dev.perry.{}", - exe_stem - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - ); - let info_plist = format!( - r#" - - - - CFBundleIdentifier - {bundle_id} - CFBundleName - {exe_stem} - CFBundleExecutable - {exe_stem} - CFBundlePackageType - APPL - NSCameraUsageDescription - This app uses the camera for WebView video calls. - NSMicrophoneUsageDescription - This app uses the microphone for WebView video calls. - - -"# - ); - let plist_path = std::env::temp_dir().join(format!( - "perry-embedded-info-{}-{}.plist", - std::process::id(), - exe_stem - )); - fs::write(&plist_path, info_plist)?; - embedded_info_plist_path = Some(plist_path.clone()); - if is_cross_macos { - cmd.arg("-sectcreate") - .arg("__TEXT") - .arg("__info_plist") - .arg(&plist_path); - } else { - cmd.arg(format!( - "-Wl,-sectcreate,__TEXT,__info_plist,{}", - plist_path.display() - )); - } - } - - let link_cache_status = prepare_link_cache_status( - &ctx.cache_root, - target, - &cmd, - obj_paths, - obj_fingerprints, - exe_path, - ); - if !link_cache_status.linked { - if let Some(path) = embedded_info_plist_path { - let _ = fs::remove_file(path); - } - return Ok(link_cache_status); - } - - // Windows hosts cap a spawned process's command line (`CreateProcess` ~32 - // KiB); a link with many object files — ≥~450 local modules (e.g. dense - // barrel `export *` re-exports, or the `@earendil-works/pi-ai` module - // graph) — overflows it and fails with `The filename or extension is too - // long. (os error 206)` before the linker even starts. Route the whole - // argument vector through a linker *response file* (`@file`), which - // `link.exe` / `lld-link` (and `clang`/`cc`) read instead of the command - // line, so the invocation no longer scales with module count. Only the - // native-Windows host is affected (other hosts have a multi-MB `ARG_MAX`); - // `PERRY_FORCE_LINK_RESPONSE_FILE=1` forces the path elsewhere for testing. - let mut response_file_to_clean: Option = None; - if cfg!(target_os = "windows") || std::env::var_os("PERRY_FORCE_LINK_RESPONSE_FILE").is_some() { - // MSVC-style quoting for the native-Windows linker (link.exe/lld-link); - // GNU-style for a clang/cc driver (the forced-test path on macOS/Linux). - let msvc_quoting = cfg!(target_os = "windows") && is_windows; - if let Some((rsp_cmd, rsp_path)) = rewrite_link_with_response_file(&cmd, msvc_quoting) { - cmd = rsp_cmd; - response_file_to_clean = Some(rsp_path); - } - } - - let status_result = cmd.status(); - if let Some(path) = response_file_to_clean { - let _ = fs::remove_file(path); - } - if let Some(path) = embedded_info_plist_path { - let _ = fs::remove_file(path); - } - let status = status_result?; - - if !status.success() { - return Err(anyhow!("Linking failed")); - } - - Ok(link_cache_status) -} - -/// Quote one linker argument for a response file. `msvc` selects `link.exe` / -/// `lld-link` rules (a `"` toggles a quoted run; backslashes are literal Windows -/// path separators, so they are NOT escaped — only an embedded `"` is escaped as -/// `\"`); otherwise GNU/clang rules (inside double quotes `\` and `"` escape, so -/// both are backslash-escaped). An argument with no whitespace/quote needs no -/// quoting in either dialect — the common case (long object/lib paths). -pub(super) fn quote_response_arg(arg: &str, msvc: bool) -> String { - let needs_quote = arg.is_empty() - || arg - .bytes() - .any(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | b'"')); - if !needs_quote { - return arg.to_string(); - } - let mut out = String::with_capacity(arg.len() + 2); - out.push('"'); - for c in arg.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' if !msvc => out.push_str("\\\\"), - _ => out.push(c), - } - } - out.push('"'); - out -} - -/// Render a linker `Command`'s full argument vector as response-file content -/// (one quoted arg per line). Pure + unit-tested; the `\n` separator and -/// per-arg quoting are what `link.exe`/`lld-link`/`clang` accept via `@file`. -pub(super) fn response_file_contents(args: &[String], msvc: bool) -> String { - let mut s = String::new(); - for a in args { - s.push_str("e_response_arg(a, msvc)); - s.push('\n'); - } - s -} - -/// Rewrite a linker invocation to pass its arguments via a response file -/// (` @`) instead of inline, dodging the Windows `CreateProcess` -/// command-line length cap (os error 206) on links with many object files. -/// Preserves program, env overrides, and working directory. The response file -/// goes in the OS temp dir under a per-process/-output unique name (so parallel -/// links don't clobber each other) and the caller deletes it after the link. -/// Returns `None` if there's nothing to gain (no args) or the file can't be -/// written — the caller then keeps the original inline command. -fn rewrite_link_with_response_file(cmd: &Command, msvc: bool) -> Option<(Command, PathBuf)> { - let args: Vec = cmd - .get_args() - .map(|a| a.to_string_lossy().into_owned()) - .collect(); - if args.is_empty() { - return None; - } - let contents = response_file_contents(&args, msvc); - let rsp = std::env::temp_dir().join(format!("perry-link-{}.rsp", std::process::id())); - fs::write(&rsp, contents).ok()?; - - let mut new_cmd = Command::new(cmd.get_program()); - new_cmd.arg(format!("@{}", rsp.display())); - // Preserve env overrides (e.g. MSVC LIB/PATH set by select_linker_command) - // and the working directory the original command was configured with. - for (key, val) in cmd.get_envs() { - match val { - Some(v) => { - new_cmd.env(key, v); - } - None => { - new_cmd.env_remove(key); - } - } - } - if let Some(cwd) = cmd.get_current_dir() { - new_cmd.current_dir(cwd); - } - Some((new_cmd, rsp)) -} - -#[cfg(test)] -mod optional_framework_dir_tests; - -#[cfg(test)] -mod response_file_tests { - use super::{quote_response_arg, response_file_contents}; - - #[test] - fn plain_paths_are_unquoted_in_both_dialects() { - assert_eq!(quote_response_arg("/tmp/a_ts.o", false), "/tmp/a_ts.o"); - assert_eq!( - quote_response_arg(r"C:\build\d0449_ts.o", true), - r"C:\build\d0449_ts.o" - ); - // /OPT:REF etc. — no whitespace, untouched. - assert_eq!(quote_response_arg("/OPT:REF", true), "/OPT:REF"); - } - - #[test] - fn msvc_quotes_spaces_keeps_backslashes_literal() { - // Windows path with a space: quoted, backslashes NOT escaped. - assert_eq!( - quote_response_arg(r"C:\Program Files\x.lib", true), - "\"C:\\Program Files\\x.lib\"" - ); - } - - #[test] - fn gnu_quotes_and_escapes_backslashes() { - assert_eq!(quote_response_arg("/a b/x.o", false), "\"/a b/x.o\""); - assert_eq!( - quote_response_arg(r"/a\b c/x.o", false), - "\"/a\\\\b c/x.o\"" - ); - } - - #[test] - fn embedded_quote_is_escaped() { - assert_eq!(quote_response_arg("a\"b", true), "\"a\\\"b\""); - assert_eq!(quote_response_arg("a\"b", false), "\"a\\\"b\""); - } - - #[test] - fn contents_is_one_arg_per_line() { - let args = vec![ - "/tmp/main.o".to_string(), - "/tmp/mod 1.o".to_string(), - "-lperry".to_string(), - ]; - assert_eq!( - response_file_contents(&args, false), - "/tmp/main.o\n\"/tmp/mod 1.o\"\n-lperry\n" - ); - } -} +//! Build and run the executable link command. +//! +//! Tier 2.1 final extraction (v0.5.342) — moves the per-platform link command +//! construction out of `crates/perry/src/commands/compile.rs::run_with_parse_cache`. +//! Pre-extraction, the link logic was a ~1240-LOC inline block inside the +//! orchestrator, fanning out across macOS / iOS / tvOS / visionOS / watchOS / +//! Android / Linux / Windows / cross-compile permutations. Co-locating it here +//! lets the orchestrator stay focused on parse / lower / codegen / cache / link +//! sequencing instead of churning the same file every time a platform-specific +//! link flag changes. +//! +//! The `dylib` link path stays inline in compile.rs because it returns early +//! with a `CompileResult`. Per-platform `.app` bundling and Android companion +//! `.so` copying also stay in compile.rs — they happen after the link +//! returns and need access to many post-link variables (`exe_path`, +//! `result_bundle_id`, etc.) that don't belong in this module. +//! +//! Wave 4 split (v0.5.x) — the per-platform linker-`Command` selection (~615 +//! LOC of `if is_watchos { swiftc … } else if is_ios { clang … } else if …`) +//! moved into `platform_cmd.rs` so this file stays under the 2k-LOC soft +//! ceiling. The orchestrator below still drives the full link line; only the +//! initial `Command::new()` + sysroot/triple/entry-rewrite prelude +//! lives in the sibling module. + +use anyhow::{anyhow, Result}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::OutputFormat; + +/// 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`). +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", + // Runtime init / guards + "perry_debug_trace_init", + "perry_debug_trace_init_done", + "perry_init_guard_check_and_set", + // Plugin manager FFI surface (plugins call into the host's plugin + // registry — the same `perry_plugin_*` symbols the host's own + // perry/plugin import resolves against). Without these in the host + // .exe's export table, a plugin DLL's `import "perry/plugin"` calls + // fail to resolve at LoadLibrary time. + "perry_plugin_count", + "perry_plugin_discover", + "perry_plugin_emit", + "perry_plugin_emit_event", + "perry_plugin_emit_hook", + "perry_plugin_get_config", + "perry_plugin_init", + "perry_plugin_invoke_tool", + "perry_plugin_list_hooks", + "perry_plugin_list_plugins", + "perry_plugin_list_tools", + "perry_plugin_load", + "perry_plugin_log", + "perry_plugin_off", + "perry_plugin_on", + "perry_plugin_register_hook", + "perry_plugin_register_hook_ex", + "perry_plugin_register_route", + "perry_plugin_register_service", + "perry_plugin_register_tool", + "perry_plugin_set_config", + "perry_plugin_set_metadata", + "perry_plugin_unload", + "perry_plugin_unregister_hook", + "perry_plugin_unregister_route", + "perry_plugin_unregister_service", + "perry_plugin_unregister_tool", +]; + +use super::{ + apple_sdk_version, build_geisterhand_libs, dedup_native_lib_for_tier3, dedup_runtime_for_tier3, + dedup_stdlib_for_tier3, find_geisterhand_library, find_geisterhand_runtime, + find_geisterhand_stdlib, find_geisterhand_ui, find_lld_link, find_llvm_tool, + find_msvc_lib_paths, find_msvc_link_exe, find_perry_windows_sdk, find_stdlib_library, + find_ui_library, find_visionos_swift_runtime, find_watchos_swift_runtime, rust_target_triple, + strip_duplicate_objects_from_lib, strip_duplicate_objects_from_well_known_lib, + windows_pe_subsystem_flag, windows_subsystem_needs_ui, CompilationContext, +}; + +mod link_cache; +mod native_features; +mod pkg_config; +mod platform_cmd; +mod windows_link; + +use link_cache::prepare_link_cache_status; +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 + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NativeBackendLinkMetadata { + backend: super::NativeBackend, + prebuilt: Option, + frameworks: Vec, + libs: Vec, + lib_dirs: Vec, + pkg_config: Vec, +} + +fn select_available_backend_link_metadata( + target_config: &super::TargetNativeConfig, +) -> Vec { + if !target_config.available { + return Vec::new(); + } + + target_config + .backends + .iter() + .filter(|backend| backend.available) + .map(|backend| NativeBackendLinkMetadata { + backend: backend.backend, + prebuilt: backend.prebuilt.clone(), + frameworks: backend.frameworks.clone(), + libs: backend.libs.clone(), + lib_dirs: backend.lib_dirs.clone(), + pkg_config: backend.pkg_config.clone(), + }) + .collect() +} + +#[cfg(test)] +mod native_package_selection_tests { + use super::*; + use crate::commands::compile::{ + NativeBackend, NativeBackendConfig, NativeBackendPackageMetadata, TargetNativeConfig, + }; + + fn target_config() -> TargetNativeConfig { + TargetNativeConfig { + available: true, + unavailable_reason: None, + crate_path: PathBuf::from("crate"), + lib_name: "demo".to_string(), + prebuilt: Some(PathBuf::from("target/libdemo.a")), + frameworks: vec!["Metal".to_string()], + optional_frameworks: Vec::new(), + frameworks_env: None, + libs: vec!["z".to_string()], + lib_dirs: vec![PathBuf::from("vendor/lib")], + pkg_config: vec!["openssl".to_string()], + resources: vec![PathBuf::from("resources/common.dat")], + shader_outputs: vec![PathBuf::from("shaders/common.spv")], + backends: Vec::new(), + swift_sources: Vec::new(), + metal_sources: vec![PathBuf::from("shaders/default.metal")], + } + } + + fn backend_config(backend: NativeBackend, available: bool) -> NativeBackendConfig { + NativeBackendConfig { + backend, + available, + unavailable_reason: if available { + None + } else { + Some("not shipped".to_string()) + }, + prebuilt: Some(PathBuf::from(format!("backend/{}.a", backend.as_str()))), + frameworks: vec![format!("{}Framework", backend.as_str())], + libs: vec![format!("{}_sys", backend.as_str())], + lib_dirs: vec![PathBuf::from(format!("vendor/{}/lib", backend.as_str()))], + pkg_config: vec![format!("{}-pkg", backend.as_str())], + shader_sources: vec![PathBuf::from(format!("shaders/{}.src", backend.as_str()))], + shader_outputs: vec![PathBuf::from(format!("shaders/{}.bin", backend.as_str()))], + resources: vec![PathBuf::from(format!("resources/{}", backend.as_str()))], + package: NativeBackendPackageMetadata { + name: Some(format!("demo-{}", backend.as_str())), + version: Some("1.0.0".to_string()), + kind: Some("shader-package".to_string()), + }, + } + } + + #[test] + fn selection_includes_available_backend_link_metadata() { + let mut tc = target_config(); + tc.backends + .push(backend_config(NativeBackend::Vulkan, true)); + tc.backends + .push(backend_config(NativeBackend::D3d12, false)); + + let selection = select_available_backend_link_metadata(&tc); + + assert_eq!(selection.len(), 1); + let backend = &selection[0]; + assert_eq!(backend.backend, NativeBackend::Vulkan); + assert_eq!(backend.prebuilt, Some(PathBuf::from("backend/vulkan.a"))); + assert_eq!(backend.frameworks, vec!["vulkanFramework".to_string()]); + assert_eq!(backend.libs, vec!["vulkan_sys".to_string()]); + assert_eq!(backend.lib_dirs, vec![PathBuf::from("vendor/vulkan/lib")]); + assert_eq!(backend.pkg_config, vec!["vulkan-pkg".to_string()]); + } + + #[test] + fn unavailable_target_selects_no_backend_link_metadata() { + let mut tc = target_config(); + tc.available = false; + tc.backends + .push(backend_config(NativeBackend::Vulkan, true)); + + let selection = select_available_backend_link_metadata(&tc); + + assert!(selection.is_empty()); + } +} + +/// Walk up from the entry `.ts` to the directory holding `perry.toml`. +/// Mirrors `widget_build::project_root_for` — kept local so the link +/// module doesn't reach across sibling modules for one path lookup. +fn find_project_root_for(input: &Path) -> Option { + let mut dir = input.canonicalize().ok()?; + for _ in 0..8 { + dir = dir.parent()?.to_path_buf(); + if dir.join("perry.toml").exists() { + return Some(dir); + } + } + None +} + +/// Resolve the vendored optional-framework search directory for a native +/// library that gates an SDK on `frameworks_env` (issue #1303 — e.g. +/// `@perryts/google-auth`'s `PERRY_GOOGLE_SIGN_IN_FRAMEWORK_DIR`). +/// +/// Precedence (matches the issue's contract): +/// 1. The `frameworks_env` env var, when set in the process environment +/// — today's local `perry compile` behavior. Returned verbatim. +/// 2. `perry.toml [google_auth].framework_dir`, resolved relative to the +/// project root → absolute. This is what survives the `perry publish` +/// worker round-trip: the dev's shell env doesn't transfer and an +/// absolute local path wouldn't exist on the worker, but perry.toml + +/// the project-relative dir are both uploaded with `--project`, so the +/// worker's `perry compile` re-resolves the same dir. +/// +/// Returns `None` when neither source yields a path. Callers still check +/// `is_dir()` before linking, so a stale/misspelled path skips silently +/// (the wrapper's `#if canImport(...)` fallback keeps the link valid). +fn resolve_optional_framework_dir(env_name: &str, args_input: &Path) -> Option { + // 1. Explicit env var wins (today's behavior). + if let Some(dir) = std::env::var_os(env_name) { + if !dir.is_empty() { + return Some(PathBuf::from(dir)); + } + } + // 2. Project-relative `perry.toml [google_auth].framework_dir`. + let project_root = find_project_root_for(args_input)?; + let content = fs::read_to_string(project_root.join("perry.toml")).ok()?; + let doc = content.parse::().ok()?; + let rel = doc + .get("google_auth")? + .as_table()? + .get("framework_dir")? + .as_str()?; + Some(project_root.join(rel)) +} + +/// Construct the platform-specific linker command, append every required +/// argument (object files, libraries, frameworks, system libs, native libs, +/// geisterhand libs), invoke it, and bail on non-zero status. +/// +/// Caller must have already handled the dylib output path; this function +/// only covers executable link. `args_input` is the user-supplied entry +/// `.ts` path (used for objcopy entry-stem matching on watchOS / visionOS / +/// iOS game-loop renames). +pub(super) fn build_and_run_link( + args_input: &Path, + ctx: &CompilationContext, + target: Option<&str>, + obj_paths: &[PathBuf], + obj_fingerprints: &[Option], + compiled_features: &[String], + runtime_lib: &Path, + stdlib_lib: &Option, + // #466 Phase 4 step 2: well-known native binding archives. Added + // to the link line right after `stdlib_lib`. The matching + // perry-stdlib feature was already stripped during the auto- + // optimize rebuild, so the resulting link contains exactly one + // copy of each `_js__*` symbol — no duplicates. + well_known_libs: &[PathBuf], + // No-auto / auto-fallback keeps the full prebuilt stdlib, so the + // matching perry-stdlib feature was not stripped. Put wrappers first + // in that shape so wrapper-only handles keep using their own surface + // symbols instead of the bundled stdlib copies. + prefer_well_known_before_stdlib: bool, + // Issue #76 — `libperry_wasm_host.a` (wasmi-backed WebAssembly host + // runtime). Only `Some(...)` when the user passed `--enable-wasm-runtime` + // and the archive was located. Appended to the link command after the + // stdlib block so the linker resolves `perry_wasm_host_*` + // symbols referenced by `js_webassembly_*` shims in `perry-runtime`. + wasm_host_lib: &Option, + exe_path: &Path, + format: OutputFormat, + // `--debug-symbols`: keep symbols / emit a PDB so RUST_BACKTRACE + // panics in the compiled app symbolize. Windows-active today. + debug_symbols: bool, +) -> Result { + // #498 - supply-chain gate. Before any prebuilt archive hits the + // linker, hash it and compare against `perry.lock`. First build + // writes the lockfile; subsequent builds verify. Mismatch fails + // with an actionable diagnostic (`perry lock --update `). + // `PERRY_LOCK_FROZEN=1` upgrades the verify to CI mode (refuses + // to extend the lock); `PERRY_LOCK_UPDATE=` deliberately + // bumps the named package's hashes. Wired here so every backend + // (LLVM / WASM / ArkTS / HarmonyOS / Glance / SwiftUI / JS) + // inherits the gate from one chokepoint. + super::run_lock_verify_for_compile(ctx, target)?; + + let is_ios = matches!(target, Some("ios-simulator") | Some("ios")); + let is_visionos = matches!(target, Some("visionos-simulator") | Some("visionos")); + // Wear OS links exactly like Android (same triple, NDK, cdylib + TLS model). + let is_android = matches!(target, Some("android") | Some("wearos")); + let is_harmonyos = matches!(target, Some("harmonyos") | Some("harmonyos-simulator")); + let is_linux = matches!(target, Some(t) if t.starts_with("linux")) + || (target.is_none() && cfg!(target_os = "linux")); + // Fully-static musl Linux target (#4826) — a sub-case of is_linux. The + // link command itself is built in select_linker_command (musl driver + + // `-static`); here it only changes which system libs we request. + let is_musl = matches!( + target, + Some("linux-musl") | Some("linux-x86_64-musl") | Some("linux-aarch64-musl") + ); + + // The musl target is meant for headless/serverless binaries (Lambda, + // scratch, distroless). The GTK4 UI backend (perry/ui) links the system + // GTK/glib/webkit stack, which is only shipped for glibc and cannot be + // statically linked into a musl binary. Fail fast with an actionable + // message rather than emitting cryptic undefined-symbol errors (#4826). + if is_musl && ctx.needs_ui { + anyhow::bail!( + "perry/ui is not supported with the static musl Linux target \ + (--libc musl / [linux] libc = \"musl\"): the GTK4 UI backend \ + requires dynamic glibc. Build the GUI app with the default \ + (glibc) Linux target, or drop perry/ui for a headless musl build." + ); + } + let is_windows = matches!(target, Some("windows") | Some("windows-winui")) + || (target.is_none() && cfg!(target_os = "windows")); + let is_cross_windows = is_windows && !cfg!(target_os = "windows"); + let is_cross_ios = is_ios && !cfg!(target_os = "macos"); + let is_cross_visionos = is_visionos && !cfg!(target_os = "macos"); + let is_cross_macos = matches!(target, Some("macos")) && !cfg!(target_os = "macos"); + let is_watchos = matches!(target, Some("watchos") | Some("watchos-simulator")); + let is_tvos = matches!(target, Some("tvos") | Some("tvos-simulator")); + let is_cross_tvos = is_tvos && !cfg!(target_os = "macos"); + + let mut cmd = select_linker_command( + args_input, + ctx, + target, + obj_paths, + compiled_features, + is_ios, + is_visionos, + is_android, + is_harmonyos, + is_linux, + is_windows, + is_cross_windows, + is_cross_ios, + is_cross_visionos, + is_cross_macos, + is_watchos, + is_tvos, + is_cross_tvos, + )?; + + // When ios-game-loop is enabled, rename _main to _perry_user_main in the + // entry object file so the perry runtime's main() (from ios_game_loop.rs) + // becomes the process entry point. It spawns _perry_user_main on a game thread. + if (is_ios || is_tvos || is_visionos) && compiled_features.iter().any(|f| f == "ios-game-loop") + { + // Resolve an objcopy: rust-objcopy / llvm-objcopy from the host Rust + // toolchain (macOS), then llvm-objcopy on Linux builders, then PATH. + let objcopy = std::env::var("HOME").ok() + .map(|h| PathBuf::from(h).join(".rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin/rust-objcopy")) + .filter(|p| p.exists()) + .or_else(|| std::env::var("HOME").ok() + .map(|h| PathBuf::from(h).join(".rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin/llvm-objcopy")) + .filter(|p| p.exists())) + .or_else(|| ["/usr/lib/llvm-18/bin/llvm-objcopy", "/usr/bin/llvm-objcopy-18", "/usr/bin/llvm-objcopy"] + .iter().map(PathBuf::from).find(|p| p.exists())) + .unwrap_or_else(|| PathBuf::from("rust-objcopy")); + // Rename _main -> __perry_user_main so the perry runtime's main() + // (ios_game_loop.rs) becomes the process entry point and spawns the + // user's main on a game thread. The entry object can't be located by + // filename — with the object cache on it's named by content hash, not + // "main_ts" — so apply the rename to every user object. objcopy + // --redefine-sym is a no-op on objects that don't define _main, so this + // only ever rewrites the single entry object regardless of its name. + for obj in obj_paths.iter() { + let _ = Command::new(&objcopy) + .args(["--redefine-sym", "_main=__perry_user_main"]) + .arg(obj) + .status(); + } + } + + for obj_path in obj_paths { + cmd.arg(obj_path); + } + + // HarmonyOS: pick up native C objects that build.rs scripts emitted + // alongside the Rust artifacts. Rust's staticlib normally bundles these + // into libperry_runtime.a, but on our macOS→OHOS cross-build the + // `libmimalloc.a` wrapper ends up as a zero-member BSD-format archive + // (BSD ar's `__.SYMDEF SORTED` layout — macOS-host `ar` creates it, + // llvm-ar can't read it back), and rustc's "bundle native libs into + // the staticlib" path silently skips it. Without us forwarding the + // loose .o files to the final link, `libentry.so` ends up with + // `mi_malloc_aligned` marked UND, and the OHOS dynamic linker rejects + // dlopen with "symbol not found" at EntryAbility.onCreate time. + // + // We walk `target//release/build/*/out/` and collect every + // loose .o. This is coarser than Rust's per-crate link-lib directive + // walking — it picks up .o files from any transitive C dep, not just + // mimalloc — but that's a feature: the set is tiny in practice + // (mimalloc is the only C dep in perry-runtime's closure today) and + // any that turn out unreferenced are dead-stripped via --gc-sections. + if is_harmonyos { + let triple = super::rust_target_triple(target).unwrap_or("aarch64-unknown-linux-ohos"); + let build_roots: Vec = { + let mut roots: Vec = Vec::new(); + // auto_rebuild emits into a perry-auto- dir; the workspace's + // own target/ is a fallback for non-auto flows. + if let Ok(entries) = std::fs::read_dir("target") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("perry-auto-") || name_str == triple { + roots.push(entry.path()); + } + } + } + // When invoked from outside the workspace, auto_rebuild still + // lands under the perry source tree's target/. Add that. + if let Some(ws_root) = super::find_perry_workspace_root() { + let ws_target = ws_root.join("target"); + if let Ok(entries) = std::fs::read_dir(&ws_target) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("perry-auto-") { + roots.push(entry.path()); + } + } + } + } + roots + }; + let mut native_objs: Vec = Vec::new(); + for root in &build_roots { + let build_dir = root.join(triple).join("release").join("build"); + let entries = match std::fs::read_dir(&build_dir) { + Ok(e) => e, + Err(_) => continue, + }; + for crate_build in entries.flatten() { + let out_dir = crate_build.path().join("out"); + // Walk the out/ dir recursively (cc-rs can nest into source- + // mirror subdirs like c_src/mimalloc/v2/src/). + if let Ok(walker) = walkdir::WalkDir::new(&out_dir) + .into_iter() + .collect::, _>>() + { + for entry in walker { + if entry.file_type().is_file() + && entry.path().extension().and_then(|e| e.to_str()) == Some("o") + { + native_objs.push(entry.path().to_path_buf()); + } + } + } + } + } + if !native_objs.is_empty() && matches!(format, crate::OutputFormat::Text) { + println!( + " harmonyos: linking {} build.rs native object(s)", + native_objs.len() + ); + } + for obj in native_objs { + cmd.arg(obj); + } + } + + // Dead code stripping — safe because compile_init() emits func_addr + // calls for every class method/getter during vtable registration. These + // serve as linker roots that keep dynamically-dispatched methods alive. + if !is_windows { + if is_android || is_linux || is_harmonyos { + cmd.arg("-Wl,--gc-sections"); + } else if is_cross_ios || is_cross_visionos || is_cross_macos || is_cross_tvos { + // ld64.lld called directly — no -Wl, prefix needed + cmd.arg("-dead_strip"); + } else if is_watchos || is_visionos { + cmd.arg("-Xlinker").arg("-dead_strip"); + } else { + // Native macOS/iOS via clang driver + cmd.arg("-Wl,-dead_strip"); + } + } else { + // MSVC link.exe / lld-link equivalents: + // /OPT:REF — drop unreferenced functions/data (= --gc-sections) + // /OPT:ICF — fold identical COMDATs (= --icf=safe) + // These are documented as defaults under /RELEASE, but Perry doesn't + // pass /RELEASE so the linker falls back to /OPT:NOREF, pulling in the + // entire perry-stdlib archive even when only a fraction is used. + if ctx.needs_plugins { + // Plugin hosts need to keep their runtime/plugin-manager exports + // alive so LoadLibrary'd plugin DLLs can resolve them. The /DEF + // file lists exactly which symbols must survive; pair /OPT:REF + // (dead-strip) with /OPT:NOREF in the wrong combination would + // strip our exports before the .def filter sees them. We + // override to NOREF here so dead-strip honors the .def list + // instead of over-eagerly dropping unreferenced-by-host code. + cmd.arg("/OPT:NOREF"); + } else { + cmd.arg("/OPT:REF"); + } + if debug_symbols { + // `/DEBUG` makes lld-link emit a PDB next to the .exe from the + // debug info already present in the input objects/libs. Without + // it, perry binaries have no symbol table and a RUST_BACKTRACE + // panic is an unreadable list of `` — there is no other + // way to diagnose a runtime crash in a compiled Windows app. + // Skip /OPT:ICF here: COMDAT folding collapses distinct + // identical-bodied functions to one symbol, which would make the + // very backtrace this flag exists to produce ambiguous. + cmd.arg("/DEBUG"); + } else { + cmd.arg("/OPT:ICF"); + } + } + + // Link libraries - stdlib bundles perry-runtime; runtime provides base FFI symbols. + // Note: libperry_stdlib.a may omit some runtime symbols (js_register_class_method, + // js_register_class_getter, etc.) due to Rust DCE on rlib dependencies. We always + // link libperry_runtime.a as a fallback to fill these gaps. On macOS/Linux/ELF the + // linker uses first-definition-wins for archives, so no duplicate symbol errors arise. + // When UI lib is also linked, it bundles its own copy of perry-runtime. + // For Android (ELF), skip the extra runtime when UI provides it. + // On Windows (MSVC), always link the runtime — the UI lib's rlib dependency on + // perry-runtime may not include all symbols (e.g., perry_init_guard_check_and_set). + // watchOS: swiftc treats duplicate symbols as errors (not warnings like clang), + // so skip the standalone runtime when the UI lib already bundles it. + // Note: even when bitcode_linked is true, we still link the .a archives. + // The merged .o contains the crate code but NOT the Rust standard library + // symbols (alloc, std::thread_local, etc.). The .a archive provides those + // as a fallback — the linker only pulls object files from the .a that + // resolve still-undefined symbols (first-definition-wins on macOS). + let skip_runtime = (is_android || is_watchos || is_visionos) + && (ctx.needs_ui || is_watchos) + && find_ui_library(target).is_some(); + let well_known_libs: Vec = if prefer_well_known_before_stdlib { + well_known_libs + .iter() + .map(|wk| { + strip_duplicate_objects_from_well_known_lib(wk).unwrap_or_else(|_| wk.clone()) + }) + .collect() + } else { + well_known_libs.to_vec() + }; + if !skip_runtime { + if ctx.needs_stdlib || is_windows { + // On Windows/MSVC, always try to link stdlib because codegen unconditionally + // declares all stdlib extern functions, creating import references that MSVC + // won't dead-strip. On macOS/Linux, the linker ignores unreferenced archives. + if let Some(ref stdlib) = stdlib_lib { + // Windows: link the standalone perry_runtime.lib FIRST so + // its symbols win lld-link's /FORCE:MULTIPLE "first + // definition wins" rule over the perry-runtime copies + // *bundled* inside perry_stdlib.lib and the + // /WHOLEARCHIVE'd perry_ui_windows.lib. Auto-optimize + // refreshes perry-runtime + perry-stdlib but NOT + // perry-ui-windows, so the UI lib's bundled runtime is + // perpetually stale; the /WHOLEARCHIVE force-includes its + // js_* symbols, and without this the stale copy shadows a + // genuine runtime fix (e.g. the js_shadow_frame_pop bounds + // guard, #880) — the crash it fixes still fires because the + // guarded function never gets linked. The standalone + // runtime_lib is the canonical / auto-optimize-fresh + // source; making it authoritative on Windows matches every + // other platform (all of which already link runtime_lib). + if is_windows { + cmd.arg(runtime_lib); + } + if prefer_well_known_before_stdlib { + for wk in &well_known_libs { + cmd.arg(wk); + } + } + // Tier-3 (tvOS/watchOS) std-duplication dedup; no-op elsewhere. + cmd.arg(&dedup_stdlib_for_tier3(target, stdlib)); + // #466 Phase 4 step 2: well-known bindings normally join the + // link line right after perry-stdlib so they cover the exact + // `_js_*` symbol gap that was just opened by stripping the + // corresponding feature from the perry-stdlib rebuild. + // + // In no-auto/fallback mode the full prebuilt stdlib may still + // contain method-value bridge objects that reference wrapper + // symbols (for example external net Socket helpers). Archives + // are scanned left-to-right, so repeat the well-known libs + // after stdlib as well: the first occurrence lets wrapper + // definitions win over duplicate bundled stdlib functions, + // and the second resolves stdlib bridge references. + for wk in &well_known_libs { + cmd.arg(wk); + } + // Also link runtime for symbols DCE'd from stdlib's bundled + // perry-runtime; on tier-3 it's first stripped of stdlib's objects. + if !is_android && !is_windows { + cmd.arg(&dedup_runtime_for_tier3(target, runtime_lib, stdlib)); + } + } else { + if ctx.needs_stdlib { + eprintln!( + "Warning: stdlib required but {} not found, using runtime-only", + if is_windows { + "perry_stdlib.lib" + } else { + "libperry_stdlib.a" + } + ); + } + cmd.arg(runtime_lib); + } + } else { + // Runtime-only linking — no stdlib needed + cmd.arg(runtime_lib); + } + } else if ctx.needs_stdlib { + // Android + UI: runtime is provided by UI lib, but stdlib must still be linked + // separately (UI lib does not bundle perry-stdlib). + if let Some(ref stdlib) = stdlib_lib { + if prefer_well_known_before_stdlib { + for wk in &well_known_libs { + cmd.arg(wk); + } + } + cmd.arg(stdlib); + // #466 Phase 4 step 2: see the parallel comment in the + // non-Android branch above. + for wk in &well_known_libs { + cmd.arg(wk); + } + } else { + eprintln!("Warning: stdlib required but libperry_stdlib.a not found"); + } + } + + // Issue #76 — wasmi host runtime, opt-in via `--enable-wasm-runtime`. + // Append after stdlib so the linker can resolve `perry_wasm_host_*` + // symbols referenced by the always-present `js_webassembly_*` FFIs in + // perry-runtime. + if let Some(ref wasm_host) = wasm_host_lib { + cmd.arg(wasm_host); + } + + if is_windows { + cmd.arg(format!("/OUT:{}", exe_path.display())); + } else { + cmd.arg("-o").arg(exe_path).arg("-lc"); + } + + // For plugin hosts, export symbols so dlopen'd / LoadLibrary'd plugins + // can resolve them. Plugins are dylibs loaded against the host process + // at runtime — they need to bind: + // 1. perry_plugin_* — the runtime's plugin-manager FFI surface + // (called from the plugin's own `import "perry/plugin"` codegen) + // 2. js_*/perry_* — the runtime primitives the compiled plugin + // code reaches through NaN-boxing / object headers + // We use -u to prevent dead_strip from removing these on macOS, + // -rdynamic on Linux, and a `.def` file with /DEF on Windows (the + // MSVC equivalent of `-rdynamic`). The shared `PLUGIN_HOST_SYMBOLS` + // const at module top is the single source of truth. + if ctx.needs_plugins { + #[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 + plugin-manager symbols. -u tells + // the linker "treat as referenced" so dead_strip keeps them. + for sym in PLUGIN_HOST_SYMBOLS { + cmd.arg(format!("-Wl,-u,_{}", sym)); + } + } + #[cfg(target_os = "linux")] + { + cmd.arg("-rdynamic"); + } + if is_windows { + // MSVC's equivalent of `-rdynamic` is a module-definition + // (.def) file passed via `/DEF:`. Writing the file + // is cheap and keeps `link.exe`'s command line short. + // `lld-link` accepts the same flag, so this works for both + // the lightweight (`perry setup windows`) and full Visual + // Studio toolchains. + 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, "LIBRARY {}", 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())); + } + } + + if is_watchos { + // watchOS frameworks (swiftc auto-links Swift stdlib on the non-game-loop path) + let is_watchos_game_loop = compiled_features.iter().any(|f| f == "watchos-game-loop"); + let is_watchos_swift_app = compiled_features.iter().any(|f| f == "watchos-swift-app"); + if !is_watchos_game_loop { + cmd.arg("-framework").arg("SwiftUI"); + } + cmd.arg("-framework") + .arg("WatchKit") + .arg("-framework") + .arg("Foundation") + .arg("-framework") + .arg("CoreFoundation") + .arg("-framework") + .arg("Security") + .arg("-lSystem") + .arg("-lresolv"); + if is_watchos_game_loop { + // QuartzCore for CAMetalLayer-backed rendering (Metal.framework is NOT + // in the watchOS SDK — the native lib must dlopen it or supply its own + // path to the device's Metal dylib). -lobjc for the dynamic + // WKApplicationDelegate class registered from watchos_game_loop.rs. + cmd.arg("-framework").arg("QuartzCore").arg("-lobjc"); + } + if is_watchos_swift_app { + // SceneKit for SceneView-backed 3D rendering from the native lib's + // `@main struct App: App`. The lib may additionally use Canvas (2D, + // already covered by SwiftUI) or SpriteKit (opt-in via the + // manifest's `frameworks` list). + cmd.arg("-framework").arg("SceneKit"); + } + } else if is_ios { + // iOS frameworks + cmd.arg("-framework") + .arg("UIKit") + .arg("-framework") + .arg("Foundation") + .arg("-framework") + .arg("WebKit") // perry/ui WebView (#658) — WKWebView. + .arg("-framework") + .arg("CoreGraphics") + .arg("-framework") + .arg("Security") + .arg("-framework") + .arg("CoreFoundation") + .arg("-framework") + .arg("SystemConfiguration") + .arg("-framework") + .arg("QuartzCore") + .arg("-framework") + .arg("AVFAudio") // AVAudioEngine for audio capture + .arg("-framework") + .arg("AVFoundation") // Camera capture (AVCaptureSession) + .arg("-framework") + .arg("CoreMedia") // CMSampleBuffer + .arg("-framework") + .arg("CoreVideo") // CVPixelBuffer + .arg("-framework") + .arg("UserNotifications") // UNUserNotificationCenter (perry/system notificationSend) + .arg("-framework") + .arg("CoreLocation") // CLCircularRegion for UNLocationNotificationTrigger (#96) + .arg("-framework") + .arg("MediaPlayer") // perry/media — Now Playing + Remote Command Center + .arg("-framework") + .arg("MapKit") // perry/ui MapView (#517) — MKMapView + .arg("-framework") + .arg("PDFKit") // perry/ui PdfView (#516) — PDFView + .arg("-framework") + .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) + .arg("-framework") + .arg("Network") // perry/system network reachability (#582) + .arg("-liconv") + .arg("-lresolv") + .arg("-lobjc") + .arg("-lSystem"); + } else if is_visionos { + cmd.arg("-framework") + .arg("SwiftUI") + .arg("-framework") + .arg("UIKit") + .arg("-framework") + .arg("Foundation") + .arg("-framework") + .arg("CoreGraphics") + .arg("-framework") + .arg("Security") + .arg("-framework") + .arg("CoreFoundation") + .arg("-framework") + .arg("SystemConfiguration") + .arg("-framework") + .arg("QuartzCore") + .arg("-framework") + .arg("AVFAudio") + .arg("-framework") + .arg("AVFoundation") + .arg("-framework") + .arg("CoreMedia") + .arg("-framework") + .arg("CoreVideo") + .arg("-framework") + .arg("MediaPlayer") // perry/media — Now Playing + Remote Command Center + .arg("-framework") + .arg("MapKit") // perry/ui MapView (#517) — MKMapView (visionOS) + .arg("-framework") + .arg("PDFKit") // perry/ui PdfView (#516) — PDFView (visionOS) + .arg("-framework") + .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) + .arg("-liconv") + .arg("-lresolv") + .arg("-lobjc") + .arg("-lSystem"); + } else if is_tvos { + // tvOS frameworks (UIKit-based, like iOS) + cmd.arg("-framework") + .arg("UIKit") + .arg("-framework") + .arg("Foundation") + .arg("-framework") + .arg("CoreGraphics") + .arg("-framework") + .arg("Security") + .arg("-framework") + .arg("CoreFoundation") + .arg("-framework") + .arg("SystemConfiguration") + .arg("-framework") + .arg("QuartzCore") + .arg("-framework") + .arg("AVFoundation") + .arg("-framework") + .arg("GameController") + .arg("-framework") + .arg("Metal") + .arg("-framework") + .arg("MapKit") // perry/ui MapView (#517) — MKMapView (tvOS) + .arg("-framework") + .arg("MediaPlayer") // perry/media — Now Playing + Siri Remote + .arg("-framework") + .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) + .arg("-liconv") + .arg("-lresolv") + .arg("-lobjc") + .arg("-lSystem"); + } else if is_harmonyos { + // OpenHarmony system libraries. musl folds m/pthread/dl into libc.a so + // the -l flags are no-ops on the toolchain side; we emit them anyway + // because cargo's static archives reference them and the OHOS dynamic + // linker resolves them at load time. + cmd.arg("-Wl,--allow-multiple-definition") + .arg("-lm") + .arg("-lpthread") + .arg("-ldl"); + // `libace_napi.z.so` provides napi_module_register + napi_create_* + // (consumed by perry-runtime/src/ohos_napi.rs). OHOS naming convention + // is `.z.so` — the `-l` flag strips `lib` and `.so` but NOT the + // middle `.z`, so `-lace_napi.z` is the deliberate spelling. + cmd.arg("-lace_napi.z"); + // `libhilog_ndk.z.so` provides OH_LOG_Print, used by Perry's + // `js_console_log_*` family on harmonyos to route compiled-TS + // console output to hilog (so Perry-emitted log lines surface + // in DevEco/hdc the same way ArkTS console.log does), and by the + // arkts_callbacks bridge for diagnostic register/invoke traces. + cmd.arg("-lhilog_ndk.z"); + // `libtime_service_ndk.so` provides OH_TimeService_GetTimeZone, + // referenced by the `iana-time-zone` crate (pulled in transitively + // by `chrono` etc.) when it detects an OHOS target. The OHOS + // dynamic loader rejects libentry.so at app launch if this isn't + // listed in DT_NEEDED. Note: no `.z.` in the soname, unlike the + // ace_napi / hilog_ndk libs above. + cmd.arg("-ltime_service_ndk"); + } else if is_android { + // Android system libraries + cmd.arg("-Wl,--allow-multiple-definition") + .arg("-lm") + .arg("-ldl") + .arg("-llog"); + + // Stub for JNI_GetCreatedJavaVMs: the jni-sys crate declares this extern + // symbol, but Android has no libjvm.so and libnativehelper.so is only + // available at API 31+. Perry gets the JavaVM from JNI_OnLoad and never + // calls this function, so compile a no-op C stub to satisfy the linker. + let stub_dir = std::env::temp_dir().join(format!("perry_jni_stub_{}", std::process::id())); + std::fs::create_dir_all(&stub_dir).ok(); + let stub_c = stub_dir.join("jni_stub.c"); + let stub_o = stub_dir.join("jni_stub.o"); + std::fs::write( + &stub_c, + concat!( + "typedef int jint;\n", + "typedef jint jsize;\n", + "jint JNI_GetCreatedJavaVMs(void **vm_buf, jsize buf_len, jsize *n_vms) {\n", + " if (n_vms) *n_vms = 0;\n", + " return 0;\n", + "}\n", + ), + ) + .ok(); + let ndk_home = std::env::var("ANDROID_NDK_HOME").unwrap_or_default(); + // #1508: see platform_cmd.rs — same host-tag bug. + let host_tag = if cfg!(target_os = "macos") { + "darwin-x86_64" + } else if cfg!(target_os = "windows") { + "windows-x86_64" + } else { + "linux-x86_64" + }; + let ndk_clang = format!( + "{}/toolchains/llvm/prebuilt/{}/bin/aarch64-linux-android24-clang{}", + ndk_home, + host_tag, + if cfg!(target_os = "windows") { + ".cmd" + } else { + "" + } + ); + let stub_ok = Command::new(&ndk_clang) + .args(["-c", "-fPIC", "-target", "aarch64-linux-android24"]) + .arg("-o") + .arg(&stub_o) + .arg(&stub_c) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if stub_ok { + cmd.arg(&stub_o); + } + } else if is_linux { + // Linux system libraries (cross-compile target) + // Allow multiple definitions: stdlib bundles perry-runtime symbols, + // and we also link perry-runtime directly for symbols DCE'd from stdlib. + // macOS Mach-O uses first-definition-wins natively; ELF linkers need this flag. + cmd.arg("-Wl,--allow-multiple-definition") + .arg("-lm") + .arg("-lpthread") + .arg("-ldl"); + + // -lssl/-lcrypto are vestigial — Perry's stdlib is rustls-only (no + // system OpenSSL). On glibc they happen to be present so the link + // tolerates them, but a static musl sysroot has no libssl.a/libcrypto.a + // and the link would fail. Skip them for musl (#4826). + if ctx.needs_stdlib && !is_musl { + cmd.arg("-lssl").arg("-lcrypto"); + } + } else if is_windows { + windows_link::add_system_libs(&mut cmd); + windows_link::embed_app_manifest(&mut cmd, ctx.needs_ui); + } else { + // macOS frameworks for runtime (sysinfo, etc.) and V8. + // Gate on `!is_harmonyos` so the macOS host doesn't leak its + // frameworks into ELF cross-compile targets that fall through this + // `else` branch — `cfg!(target_os = "macos")` is true whenever we're + // running ON macOS, regardless of the actual target. + if (cfg!(target_os = "macos") || is_cross_macos) && !is_harmonyos { + cmd.arg("-framework") + .arg("Security") + .arg("-framework") + .arg("CoreFoundation") + .arg("-framework") + .arg("SystemConfiguration") + .arg("-liconv") + .arg("-lresolv") + .arg("-lobjc"); + } + + // On Linux (native, not cross-compiling to macOS), link against system libraries + if cfg!(target_os = "linux") && !is_cross_macos { + cmd.arg("-lm").arg("-lpthread").arg("-ldl"); + + if ctx.needs_stdlib { + cmd.arg("-lssl").arg("-lcrypto"); + } + } + } + + // Issue #607 — watchOS targets always link the UI lib regardless of + // `ctx.needs_ui`. The watchOS Swift template (`PerryWatchApp.swift`) + // unconditionally references four `@_silgen_name`'d Rust symbols + // (`perry_watchos_tree_version` / `perry_watchos_toggle_changed` / + // `perry_watchos_toast_seq` / `perry_watchos_toast_dismiss`) that + // live in `libperry_ui_watchos.a`. A console-only TS program has + // `needs_ui = false`, so the UI lib was previously not added to the + // link line — leaving those four symbols undefined and the link + // failing. Forcing the UI lib for watchOS adds ~MBs but unblocks + // `console.log("ok")`-only programs from compiling. + let force_ui = is_watchos; + if ctx.needs_ui || force_ui { + // When geisterhand is enabled, prefer the geisterhand-enabled UI lib + // (it contains widget registration calls that the normal lib doesn't have) + let ui_lib_option = if ctx.needs_geisterhand { + find_geisterhand_ui(target).or_else(|| find_ui_library(target)) + } else { + find_ui_library(target) + }; + if let Some(ui_lib) = ui_lib_option { + // The UI staticlib bundles perry_runtime + Rust std. When perry-stdlib + // is also linked (which bundles the same), duplicate symbols cause + // crashes (conflicting static state initialization). Strip duplicates + // on Apple platforms. On Windows/Android, skip strip-dedup because + // perry_runtime objects contain monomorphizations needed by UI code, + // and --allow-multiple-definition (ELF) / /FORCE:MULTIPLE (COFF) + // handles duplicate symbols safely. On Android, skip_runtime=true + // means the UI lib is the sole provider of perry-runtime symbols. + let ui_lib = if is_windows || is_android || is_visionos { + ui_lib + } else { + match strip_duplicate_objects_from_lib(&ui_lib) { + Ok(trimmed) => trimmed, + Err(e) => { + eprintln!("[strip-dedup] skipped for UI lib (non-fatal): {e}"); + ui_lib + } + } + }; + if is_windows { + // lld-link scans archives left-to-right once. The UI lib is + // linked before user code objects, so UI symbols aren't yet + // undefined when the lib is scanned. /WHOLEARCHIVE forces all + // objects from the archive to be included unconditionally. + cmd.arg(format!("/WHOLEARCHIVE:{}", ui_lib.display())); + } else { + cmd.arg(&ui_lib); + } + + if is_watchos { + // SwiftUI/WatchKit already linked above + } else if is_ios || is_visionos || is_tvos { + // UIKit already linked above + } else if is_android { + // Allow multiple definitions from perry-runtime in both UI lib and native libs + cmd.arg("-Wl,--allow-multiple-definition"); + } else if is_linux { + // Allow multiple definitions from perry-runtime in both stdlib and UI lib + cmd.arg("-Wl,--allow-multiple-definition"); + // libperry_ui_gtk4.a's glib::source::trampoline_local + // closures call perry-stdlib's js_stdlib_process_pending / + // js_promise_run_microtasks. When ctx.needs_stdlib is false + // (bare UI program), stdlib isn't linked via the earlier + // path. Force-link it here with --whole-archive so every + // object is pulled unconditionally. --allow-multiple-definition + // above lets it coexist with the runtime stub at + // perry-runtime/src/stdlib_stubs.rs. The async-runtime + // feature is force-enabled for UI builds (see + // build_optimized_libs), so the real js_stdlib_process_pending + // is guaranteed present in libperry_stdlib.a. + let linux_stdlib_for_ui = + stdlib_lib.clone().or_else(|| find_stdlib_library(target)); + if let Some(ref stdlib) = linux_stdlib_for_ui { + cmd.arg("-Wl,--whole-archive") + .arg(stdlib) + .arg("-Wl,--no-whole-archive"); + } + // GTK4 libraries via pkg-config. The fallback fires in two + // distinct cases: pkg-config not installed (spawn fails), OR + // installed but `gtk4.pc` not on the search path (exit != 0 + // — happens e.g. on Ubuntu hosts where libgtk-4-dev is split + // across packages, or when PKG_CONFIG_PATH is locked down). + // Pre-fix the second case silently emitted no GTK link flags + // and the link bombed with hundreds of `g_object_unref` / + // `gtk_widget_*` undefined references (#181). + let mut got_gtk_libs = false; + let pc_out = Command::new("pkg-config").args(["--libs", "gtk4"]).output(); + if let Ok(ref output) = pc_out { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + got_gtk_libs = true; + } + } + if !got_gtk_libs { + // Mirrors what `pkg-config --libs gtk4` returns on a + // standard libgtk-4-dev install. Pre-fix only listed the + // glib/gio core, which left pango/cairo/gdk_pixbuf + // undefined. + eprintln!( + "Warning: `pkg-config --libs gtk4` did not return GTK4 \ + linker flags ({}). Falling back to a hardcoded GTK4 \ + link set — install `libgtk-4-dev` (Debian/Ubuntu) or \ + `gtk4-devel` (Fedora/RHEL) and ensure pkg-config can \ + find `gtk4.pc` to silence this warning.", + match &pc_out { + Err(e) => format!("pkg-config not runnable: {e}"), + Ok(o) if !o.status.success() => format!( + "pkg-config exited {}: {}", + o.status.code().unwrap_or(-1), + String::from_utf8_lossy(&o.stderr).trim() + ), + Ok(_) => "no output".to_string(), + } + ); + for lib in [ + "-lgtk-4", + "-lgio-2.0", + "-lgobject-2.0", + "-lglib-2.0", + "-lpangocairo-1.0", + "-lpango-1.0", + "-lharfbuzz", + "-lgdk_pixbuf-2.0", + "-lcairo-gobject", + "-lcairo", + "-lgraphene-1.0", + ] { + cmd.arg(lib); + } + } + // PulseAudio for audio capture (only needed with UI) + cmd.arg("-lpulse-simple").arg("-lpulse"); + // GStreamer libs — pulled in by perry-ui-gtk4's gstreamer-rs + // dep (added in v0.5.440 for the perry/media playbin backend). + // GTK4's pkg-config doesn't transitively reference the + // gstreamer-1.0 sonames, so the `-lgstreamer-1.0` (and the + // base/app/video/audio sublibs that gstreamer-rs's playbin + // path touches) have to land on the link line explicitly or ld + // fails with `undefined reference to gst_message_parse_buffering` + // + `DSO missing from command line` (#423). Same pkg-config → + // hardcoded-fallback shape as the GTK4 block above. + let mut got_gst_libs = false; + let gst_pc_out = Command::new("pkg-config") + .args([ + "--libs", + "gstreamer-1.0", + "gstreamer-base-1.0", + "gstreamer-app-1.0", + "gstreamer-video-1.0", + "gstreamer-audio-1.0", + ]) + .output(); + if let Ok(ref output) = gst_pc_out { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + got_gst_libs = true; + } + } + if !got_gst_libs { + eprintln!( + "Warning: `pkg-config --libs gstreamer-1.0 ...` did not \ + return GStreamer linker flags ({}). Falling back to a \ + hardcoded GStreamer link set — install \ + `libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev` \ + (Debian/Ubuntu) or `gstreamer1-devel \ + gstreamer1-plugins-base-devel` (Fedora/RHEL) to silence \ + this warning.", + match &gst_pc_out { + Err(e) => format!("pkg-config not runnable: {e}"), + Ok(o) if !o.status.success() => format!( + "pkg-config exited {}: {}", + o.status.code().unwrap_or(-1), + String::from_utf8_lossy(&o.stderr).trim() + ), + Ok(_) => "no output".to_string(), + } + ); + for lib in [ + "-lgstreamer-1.0", + "-lgstbase-1.0", + "-lgstapp-1.0", + "-lgstvideo-1.0", + "-lgstaudio-1.0", + ] { + cmd.arg(lib); + } + } + // libshumate — GNOME's GTK4 vector-tile map widget for the + // perry/ui MapView (#517). Same pkg-config → hardcoded + // fallback shape as GTK4 / GStreamer above. + let mut got_shumate_libs = false; + let shumate_pc_out = Command::new("pkg-config") + .args(["--libs", "shumate-1.0"]) + .output(); + if let Ok(ref output) = shumate_pc_out { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + got_shumate_libs = true; + } + } + if !got_shumate_libs { + eprintln!( + "Warning: `pkg-config --libs shumate-1.0` did not return \ + libshumate linker flags ({}). Falling back to \ + `-lshumate-1.0` — install `libshumate-dev` \ + (Debian/Ubuntu) or `libshumate-devel` (Fedora/RHEL) to \ + silence this warning.", + match &shumate_pc_out { + Err(e) => format!("pkg-config not runnable: {e}"), + Ok(o) if !o.status.success() => format!( + "pkg-config exited {}: {}", + o.status.code().unwrap_or(-1), + String::from_utf8_lossy(&o.stderr).trim() + ), + Ok(_) => "no output".to_string(), + } + ); + cmd.arg("-lshumate-1.0"); + } + // WebKitGTK 6.0 + libsoup-3.0 — perry/ui WebView (#658, v0.5.864). + // perry-ui-gtk4's webkit6/soup3 deps reference symbols like + // `soup_check_version` from libsoup-3.0 transitively; without + // explicit `-lsoup-3.0` ld errors with `DSO missing from + // command line`. Same pkg-config → hardcoded-fallback shape + // as GTK4 / GStreamer / shumate above. + let mut got_webkit_libs = false; + let webkit_pc_out = Command::new("pkg-config") + .args(["--libs", "webkitgtk-6.0", "libsoup-3.0"]) + .output(); + if let Ok(ref output) = webkit_pc_out { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + got_webkit_libs = true; + } + } + if !got_webkit_libs { + eprintln!( + "Warning: `pkg-config --libs webkitgtk-6.0 libsoup-3.0` \ + did not return WebKitGTK linker flags ({}). Falling \ + back to a hardcoded set — install `libwebkitgtk-6.0-dev` \ + (Debian/Ubuntu) which pulls libsoup-3.0-dev + \ + libjavascriptcoregtk-6.0-dev to silence this warning.", + match &webkit_pc_out { + Err(e) => format!("pkg-config not runnable: {e}"), + Ok(o) if !o.status.success() => format!( + "pkg-config exited {}: {}", + o.status.code().unwrap_or(-1), + String::from_utf8_lossy(&o.stderr).trim() + ), + Ok(_) => "no output".to_string(), + } + ); + for lib in ["-lwebkitgtk-6.0", "-ljavascriptcoregtk-6.0", "-lsoup-3.0"] { + cmd.arg(lib); + } + } + } else if is_windows { + // Win32 system libs already linked above + } else { + if cfg!(target_os = "macos") || is_cross_macos { + cmd.arg("-framework").arg("AppKit"); + // perry/ui WebView (#658) — WKWebView / WKWebViewConfiguration. + cmd.arg("-framework").arg("WebKit"); + cmd.arg("-framework").arg("CoreGraphics"); + cmd.arg("-framework").arg("QuartzCore"); + cmd.arg("-framework").arg("AVFoundation"); + cmd.arg("-framework").arg("Metal"); + cmd.arg("-framework").arg("IOKit"); + cmd.arg("-framework").arg("DiskArbitration"); // needed by CoreGraphics + // perry/media — AVPlayer is in AVFoundation (already linked + // above). CoreMedia provides CMTime + CMTimeGetSeconds / + // CMTimeMakeWithSeconds used for seek + position. MediaPlayer + // provides MPNowPlayingInfoCenter / MPRemoteCommandCenter / + // MPMediaItemArtwork (lock screen + Touch Bar + Now Playing). + cmd.arg("-framework").arg("CoreMedia"); + cmd.arg("-framework").arg("MediaPlayer"); + // perry/ui MapView (#517) — MKMapView lives in MapKit. + cmd.arg("-framework").arg("MapKit"); + // perry/ui PdfView (#516) — PDFView lives in PDFKit, which + // also exposes the PDFDocument / PDFPage classes used for + // page-count + page-navigation queries. + cmd.arg("-framework").arg("PDFKit"); + // perry/system network reachability (#582) — NWPathMonitor. + cmd.arg("-framework").arg("Network"); + } + } + + match format { + OutputFormat::Text => { + println!("Linking perry/ui (native UI) from {}", ui_lib.display()) + } + OutputFormat::Json => {} + } + } else { + let (lib_name, build_cmd) = if is_watchos { + ( + "libperry_ui_watchos.a", + "cargo +nightly build -Z build-std=std,panic_abort --release -p perry-ui-watchos --target aarch64-apple-watchos (or --target aarch64-apple-watchos-sim for the simulator)", + ) + } else if is_tvos { + ( + "libperry_ui_tvos.a", + "cargo build --release -p perry-ui-tvos --target aarch64-apple-tvos", + ) + } else if is_visionos { + ("libperry_ui_visionos.a", "cargo build --release -p perry-ui-visionos --target aarch64-apple-visionos-sim") + } else if is_ios { + ( + "libperry_ui_ios.a", + "cargo build --release -p perry-ui-ios --target aarch64-apple-ios-sim", + ) + } else if is_android { + ( + "libperry_ui_android.a", + // #1529 — TLS model must be global-dynamic for the dlopen'd cdylib. + // `tls-model` is `-Z`-gated on the toolchains we ship against, so + // RUSTC_BOOTSTRAP=1 lets the gated flag through on a stable rustc. + "RUSTC_BOOTSTRAP=1 RUSTFLAGS=\"-Z tls-model=global-dynamic\" cargo build --release -p perry-ui-android --target aarch64-linux-android", + ) + } else if is_linux { + ( + "libperry_ui_gtk4.a", + "cargo build --release -p perry-ui-gtk4 --target x86_64-unknown-linux-gnu", + ) + } else if matches!(target, Some("windows-winui")) { + ( + "perry_ui_windows_winui.lib", + "cargo build --release -p perry-ui-windows-winui --target x86_64-pc-windows-msvc", + ) + } else if is_windows { + ( + "perry_ui_windows.lib", + "cargo build --release -p perry-ui-windows --target x86_64-pc-windows-msvc", + ) + } else { + ( + "libperry_ui_macos.a", + "cargo build --release -p perry-ui-macos", + ) + }; + return Err(anyhow!( + "perry/ui imported but {} not found. Build with: {}", + lib_name, + build_cmd + )); + } + } + + // Link geisterhand libraries if enabled + if ctx.needs_geisterhand { + // Auto-build geisterhand libraries if any are missing + let gh_missing = find_geisterhand_library(target).is_none() + || find_geisterhand_runtime(target).is_none() + || (ctx.needs_stdlib && find_geisterhand_stdlib(target).is_none()) + || (ctx.needs_ui && find_geisterhand_ui(target).is_none()); + if gh_missing { + build_geisterhand_libs(target, format)?; + } + + if let Some(gh_lib) = find_geisterhand_library(target) { + cmd.arg(&gh_lib); + // Link geisterhand-enabled runtime (has the registry + pump functions) + if let Some(gh_runtime) = find_geisterhand_runtime(target) { + cmd.arg(&gh_runtime); + // ELF linkers need --allow-multiple-definition; macOS Mach-O uses first-wins natively + if is_linux || is_android { + cmd.arg("-Wl,--allow-multiple-definition"); + } + } + // On Windows, re-link the stdlib after geisterhand to resolve + // forward references to geisterhand registry functions. + // lld-link scans archives left-to-right once, so the stdlib + // must appear after the geisterhand lib that references it. + // On Windows, force-include geisterhand registry symbols from stdlib. + // lld-link scans archives left-to-right once, so the stdlib's + // geisterhand objects are skipped on first scan (no references yet). + // /INCLUDE forces the linker to pull in the specific symbols. + if is_windows { + cmd.arg("/INCLUDE:perry_geisterhand_queue_action"); + cmd.arg("/INCLUDE:perry_geisterhand_queue_action1"); + cmd.arg("/INCLUDE:perry_geisterhand_queue_state_set"); + cmd.arg("/INCLUDE:perry_geisterhand_request_screenshot"); + cmd.arg("/INCLUDE:perry_geisterhand_register"); + cmd.arg("/INCLUDE:perry_geisterhand_pump"); + cmd.arg("/INCLUDE:perry_geisterhand_start"); + cmd.arg("/INCLUDE:perry_geisterhand_free_string"); + cmd.arg("/INCLUDE:perry_geisterhand_get_closure"); + cmd.arg("/INCLUDE:perry_geisterhand_get_registry_json"); + // Allow duplicate symbols from re-linked stdlib objects + cmd.arg("/FORCE:MULTIPLE"); + } + match format { + OutputFormat::Text => println!("Linking geisterhand (in-process fuzzer)"), + OutputFormat::Json => {} + } + } else { + return Err(anyhow!( + "Failed to build geisterhand libraries. Check that Perry source crates are available." + )); + } + } + + // Build and link external native libraries from perry.nativeLibrary manifests. + // Swift sources are deduplicated across the loop — modules sharing the same + // package.json all see the same swift_sources entries, but each file should + // be compiled + linked once. Without this, swift's mangled symbols for + // structs/classes duplicate N times. + let mut seen_swift_sources: std::collections::HashSet = + std::collections::HashSet::new(); + for native_lib in &ctx.native_libraries { + if let Some(ref target_config) = native_lib.target_config { + if !target_config.available { + if let (OutputFormat::Text, Some(reason)) = + (format, target_config.unavailable_reason.as_deref()) + { + println!( + "Skipping native library {} for this target: {}", + native_lib.module, reason + ); + } + continue; + } + + match format { + OutputFormat::Text => { + println!("Building native library: {} ...", native_lib.module) + } + OutputFormat::Json => {} + } + + // Issue #860 — prebuilt-distribution shortcut. When the + // wrapper's manifest specified a `prebuilt:` path that + // resolved to an on-disk static library, skip the cargo + // build entirely and link the prebuilt archive directly. + // `frameworks` / `libs` / `pkgConfig` / `lib_dirs` are + // still honored below — those are linker flags the host + // toolchain needs regardless of where the `.a` came from. + if let Some(prebuilt) = target_config.prebuilt.as_ref() { + if !prebuilt.exists() { + return Err(anyhow!( + "Prebuilt native library declared by {} not found at {}. \ + If this package is distributed via npm `optionalDependencies` \ + (esbuild/sharp pattern), make sure the per-platform subpackage \ + is installed for the current host/target.", + native_lib.module, + prebuilt.display() + )); + } + cmd.arg(prebuilt); + match format { + OutputFormat::Text => { + println!("Linking prebuilt native library: {}", prebuilt.display()) + } + OutputFormat::Json => {} + } + } else { + // Build the Rust crate + let cargo_toml = target_config.crate_path.join("Cargo.toml"); + if cargo_toml.exists() { + // Tier 3 targets (tvOS, watchOS) need nightly + build-std + let is_tier3 = matches!( + target, + Some("tvos") + | Some("tvos-simulator") + | Some("watchos") + | Some("watchos-simulator") + ); + + // #505: optionally wrap the cargo invocation in + // `sandbox-exec` on macOS to deny network and + // restrict FS writes during `build.rs` execution. + // Off by default for backwards compat; opted into + // via `PERRY_SANDBOX_BUILDRS=1`. Packages listed + // in `perry.allowUnsandboxedBuild` are exempt. + let mut cargo_cmd = + super::sandbox_buildrs::wrap_cargo_command(ctx, &native_lib.module); + if is_tier3 { + cargo_cmd.arg("+nightly"); + } + cargo_cmd + .arg("build") + .arg("--release") + .arg("--manifest-path") + .arg(&cargo_toml); + + // perry.toml `[native-library.""]` feature + // forwarding — see native_features.rs. + native_features::apply_native_library_override( + &mut cargo_cmd, + &ctx.project_root, + &native_lib.module, + matches!(format, OutputFormat::Text), + ); + + if let Some(triple) = rust_target_triple(target) { + cargo_cmd.arg("--target").arg(triple); + } + + if is_tier3 { + // Match perry-runtime's std build flags exactly so the std + // rlibs are bit-identical and dedupe at link time. Without + // this, native libs pull in a parallel std with different + // metadata hashes and the final Swift-driven link fails + // with hundreds of duplicate-symbol errors. + cargo_cmd.arg("-Zbuild-std=std,panic_abort"); + } + + // For Android, ensure 16 KB page size alignment (required by Google Play), + // and force the global-dynamic TLS model (#1529): the native lib's TLS + // relocations are baked into the dlopen'd `libperry_app.so`, so an + // Initial-Executable model (rustc's android default) crashes at load. + if is_android { + let tls_flag = super::optimized_libs::android_global_dynamic_tls_rustflag( + &mut cargo_cmd, + ); + cargo_cmd.env( + "CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS", + format!("-C link-arg=-Wl,-z,max-page-size=16384 {tls_flag}"), + ); + } + + // For HarmonyOS, point cargo at the OHOS SDK's clang + sysroot + // so cc-rs and rustc's linker invocation actually use the + // cross-toolchain instead of falling back to the host `cc`. + if is_harmonyos { + if let Some(sdk) = super::library_search::find_harmonyos_sdk() { + for (k, v) in super::library_search::harmonyos_cross_env(&sdk, target) { + cargo_cmd.env(k, v); + } + } + } + // #1508: For Android, do the same with the NDK so cc-rs can + // compile native C deps (libsqlite3-sys / libmimalloc-sys + // / etc.) using the NDK clang. Without this, cc-rs falls + // back to the host `cc` and fails with + // `failed to find tool "clang.exe"` on Windows (and + // architecturally-mismatched objects on Unix). + if is_android { + if let Some(ndk) = std::env::var_os("ANDROID_NDK_HOME") { + for (k, v) in super::library_search::android_cross_env( + std::path::Path::new(&ndk), + target, + ) { + cargo_cmd.env(k, v); + } + } + } + + // #1303 — when the wrapper crate's `build.rs` gates an + // optional vendored SDK on an env var (`frameworks_env`) + // and the dev declared a project-relative `framework_dir` + // in `perry.toml [google_auth]`, export the resolved + // absolute path so `build.rs` opts the real SDK in. This + // fires on the local machine AND on the `perry publish` + // worker (where the dev's shell env doesn't transfer, but + // the uploaded perry.toml + dir do). If the env var is + // already set we re-export the same value — idempotent. + if let Some(env_name) = target_config.frameworks_env.as_deref() { + if let Some(dir) = resolve_optional_framework_dir(env_name, args_input) { + if dir.is_dir() { + cargo_cmd.env(env_name, &dir); + } + } + } + + let cargo_status = cargo_cmd.status()?; + if !cargo_status.success() { + return Err(anyhow!( + "Failed to build native library crate for {}: {}", + native_lib.module, + target_config.crate_path.display() + )); + } + } + + // Find and link the static library + let lib_name = &target_config.lib_name; + if !lib_name.is_empty() { + // Search in the crate's target directory first, then standard paths. + // Refs #564: probe both `target/release/` and + // `target//release/` for native builds — cargo + // writes to the triple-prefixed dir when a default target is + // pinned via `[build] target` / `CARGO_BUILD_TARGET` / + // `rust-toolchain.toml`. + let crate_target_dir = target_config.crate_path.join("target"); + let lib_path = super::library_search::locate_native_lib_artifact( + &crate_target_dir, + target, + lib_name, + ); + + if let Some(lib) = lib_path { + let lib = dedup_native_lib_for_tier3(target, lib_name, lib); + // For shared libraries (.so) on Android, use -L/-l so the linker + // records just the soname (not the full build path) in DT_NEEDED. + if is_android && lib_name.ends_with(".so") { + if let Some(dir) = lib.parent() { + cmd.arg(format!("-L{}", dir.display())); + } + // Strip "lib" prefix and ".so" suffix for -l flag + let stem = lib_name.strip_prefix("lib").unwrap_or(lib_name); + let stem = stem.strip_suffix(".so").unwrap_or(stem); + cmd.arg(format!("-l{}", stem)); + } else { + // When building a plugin host on macOS, force-load plugin-related native + // libraries so their symbols are available for dlopen'd plugin dylibs. + let force_load = cfg!(target_os = "macos") + && ctx.needs_plugins + && native_lib.module.contains("plugin"); + if force_load { + cmd.arg(format!("-Wl,-force_load,{}", lib.display())); + } else if is_windows && lib.extension().map_or(false, |e| e == "lib") { + // On Windows, link native staticlibs directly — + // /FORCE:MULTIPLE handles duplicate symbols. + cmd.arg(&lib); + } else { + cmd.arg(&lib); + } + } + match format { + OutputFormat::Text => { + println!("Linking native library: {}", lib.display()) + } + OutputFormat::Json => {} + } + } else { + return Err(anyhow!( + "Native library {} not found after building {} crate", + lib_name, + native_lib.module + )); + } + } + } // closes else of `if let Some(prebuilt) = ...` (issue #860) + + // Add platform frameworks + for framework in &target_config.frameworks { + cmd.arg("-framework").arg(framework); + } + + // Issue #1304 — vendored-SDK frameworks (e.g. GoogleSignIn + // for `@perryts/google-auth`). These live in a directory the + // app dev built/downloaded locally, named by the wrapper's + // `frameworks_env` env var. When that var is set and resolves + // to an existing directory, add it as a framework search path + // (`-F `) and emit one `-framework ` per declared + // `optional_frameworks` entry. When it's unset (or points at + // something that isn't a directory) we skip silently: the + // wrapper's `#if canImport(...)` Swift bridge already compiles + // a no-SDK fallback path, so the binary still links and + // returns a runtime "framework not linked" result rather than + // failing with undefined `GID*` symbols. + // + // Contract is static frameworks only — `-framework` links the + // archive directly with no `.app/Frameworks/` embed + rpath. + // + // #1303 — the search dir resolves from the `frameworks_env` env + // var (local) or, when unset, the project-relative + // `perry.toml [google_auth].framework_dir` (so a `perry publish` + // worker build links the real SDK instead of the stub). + if let Some(env_name) = target_config.frameworks_env.as_deref() { + if !target_config.optional_frameworks.is_empty() { + match resolve_optional_framework_dir(env_name, args_input) { + Some(dir) if dir.is_dir() => { + cmd.arg("-F").arg(&dir); + for framework in &target_config.optional_frameworks { + cmd.arg("-framework").arg(framework); + } + if let OutputFormat::Text = format { + println!( + "Linking {} optional framework(s) for {} ({})", + target_config.optional_frameworks.len(), + native_lib.module, + dir.display() + ); + } + } + Some(dir) => { + if let OutputFormat::Text = format { + println!( + "Skipping optional frameworks for {}: {:?} is not a directory", + native_lib.module, + dir.display() + ); + } + } + None => { + // Neither env var nor framework_dir → silent skip + // (the wrapper's canImport fallback keeps linking). + } + } + } + } + + // Add library search paths. MSVC link.exe takes `/LIBPATH:`; + // every other linker we drive (clang/ld on Apple, gcc/ld on + // Linux/Android/HarmonyOS) understands `-L`. Mirror the + // `target_config.libs` branch immediately below so a + // `targets.windows.libDirs` entry actually resolves the + // `{lib}.lib` lookups instead of being a silent no-op. + for lib_dir in &target_config.lib_dirs { + if is_windows { + cmd.arg(format!("/LIBPATH:{}", lib_dir.display())); + } else { + cmd.arg(format!("-L{}", lib_dir.display())); + } + } + + // Add platform libraries + for lib in &target_config.libs { + if is_windows { + cmd.arg(format!("{}.lib", lib)); + } else { + cmd.arg(format!("-l{}", lib)); + } + } + + // Add pkg-config libraries + for pkg in &target_config.pkg_config { + pkg_config::validate_pkg_config_name(pkg)?; + if let Ok(output) = Command::new("pkg-config").args(["--libs", pkg]).output() { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + } + } + } + + for backend in &target_config.backends { + if !backend.available { + if let (OutputFormat::Text, Some(reason)) = + (format, backend.unavailable_reason.as_deref()) + { + println!( + "Skipping {} backend for {}: {}", + backend.backend.as_str(), + native_lib.module, + reason + ); + } + } + } + + for backend in select_available_backend_link_metadata(target_config) { + if let Some(prebuilt) = backend.prebuilt.as_ref() { + if !prebuilt.exists() { + return Err(anyhow!( + "Prebuilt {} backend library declared by {} not found at {}. \ + Install the matching optional dependency or update \ + perry.nativeLibrary.targets..backends.{}.prebuilt.", + backend.backend.as_str(), + native_lib.module, + prebuilt.display(), + backend.backend.as_str() + )); + } + cmd.arg(prebuilt); + match format { + OutputFormat::Text => println!( + "Linking prebuilt {} backend library: {}", + backend.backend.as_str(), + prebuilt.display() + ), + OutputFormat::Json => {} + } + } + + for framework in &backend.frameworks { + cmd.arg("-framework").arg(framework); + } + for lib_dir in &backend.lib_dirs { + if is_windows { + cmd.arg(format!("/LIBPATH:{}", lib_dir.display())); + } else { + cmd.arg(format!("-L{}", lib_dir.display())); + } + } + for lib in &backend.libs { + if is_windows { + cmd.arg(format!("{}.lib", lib)); + } else { + cmd.arg(format!("-l{}", lib)); + } + } + for pkg in &backend.pkg_config { + pkg_config::validate_pkg_config_name(pkg)?; + if let Ok(output) = Command::new("pkg-config").args(["--libs", pkg]).output() { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + } + } + } + } + + // Compile manifest-declared Swift sources to object files and + // append them to the link line. Used by `--features watchos-swift-app` + // so a native lib can ship its own `@main struct App: App`. + if !target_config.swift_sources.is_empty() { + if !is_watchos { + return Err(anyhow!( + "perry.nativeLibrary.targets..swift_sources is only supported on watchos/watchos-simulator" + )); + } + let swift_sdk = if target == Some("watchos-simulator") { + "watchsimulator" + } else { + "watchos" + }; + // arm64_32 watchOS (Series 4-8 / SE): opt-in, matches the app + // binary's triple in platform_cmd.rs so the native @main lib + // links against the same arch. + let swift_arm64_32 = + target == Some("watchos") && std::env::var("PERRY_WATCHOS_ARM64_32").is_ok(); + let swift_watchos_min = + std::env::var("PERRY_WATCHOS_MIN").unwrap_or_else(|_| "11.0".to_string()); + let swift_triple_owned; + let swift_triple = if target == Some("watchos-simulator") { + "arm64-apple-watchos10.0-simulator" + } else if swift_arm64_32 { + swift_triple_owned = format!("arm64_32-apple-watchos{}", swift_watchos_min); + swift_triple_owned.as_str() + } else { + "arm64-apple-watchos26.0" + }; + let swift_sysroot = String::from_utf8( + Command::new("xcrun") + .args(["--sdk", swift_sdk, "--show-sdk-path"]) + .output()? + .stdout, + )? + .trim() + .to_string(); + let swiftc = String::from_utf8( + Command::new("xcrun") + .args(["--sdk", swift_sdk, "--find", "swiftc"]) + .output()? + .stdout, + )? + .trim() + .to_string(); + + let swift_obj_dir = + std::env::temp_dir().join(format!("perry_swift_{}", std::process::id())); + std::fs::create_dir_all(&swift_obj_dir).ok(); + + for swift_src in &target_config.swift_sources { + if !swift_src.exists() { + return Err(anyhow!( + "Swift source not found: {} (declared in {}'s nativeLibrary.swift_sources)", + swift_src.display(), + native_lib.module + )); + } + let canonical = swift_src + .canonicalize() + .unwrap_or_else(|_| swift_src.clone()); + if !seen_swift_sources.insert(canonical) { + continue; + } + let stem = swift_src + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("swift_src"); + let obj_out = swift_obj_dir.join(format!("{}.o", stem)); + let status = Command::new(&swiftc) + .arg("-target") + .arg(swift_triple) + .arg("-sdk") + .arg(&swift_sysroot) + .arg("-parse-as-library") + .arg("-emit-object") + .arg("-O") + .arg("-o") + .arg(&obj_out) + .arg(swift_src) + .status()?; + if !status.success() { + return Err(anyhow!( + "Failed to compile Swift source: {}", + swift_src.display() + )); + } + cmd.arg(&obj_out); + match format { + OutputFormat::Text => { + println!("Linking Swift object: {}", obj_out.display()) + } + OutputFormat::Json => {} + } + } + } + + // Metal sources are compiled + packed into .app/default.metallib + // after the `.app` bundle is created below. Just validate the target + // here so we fail early with a clear message instead of silently + // dropping shaders on non-Apple-bundle targets. + if !target_config.metal_sources.is_empty() + && !matches!( + target, + Some("ios") + | Some("ios-simulator") + | Some("tvos") + | Some("tvos-simulator") + | Some("watchos") + | Some("watchos-simulator") + | Some("visionos") + | Some("visionos-simulator") + ) + { + return Err(anyhow!( + "perry.nativeLibrary.targets..metal_sources is only supported on ios / ios-simulator / tvos / tvos-simulator / watchos / watchos-simulator / visionos / visionos-simulator" + )); + } + } + } + + // macOS privacy APIs (including camera/microphone requests made by + // WKWebView) consult the process Info.plist for usage-description keys. + // Perry's direct desktop output is a Mach-O executable, not a .app bundle, + // so embed a minimal Info.plist section when linking native macOS UI apps. + // Without this, WKWebView media capture can be denied by the platform even + // when WKUIDelegate grants the web-origin permission. + let is_macos_executable = + (target.is_none() && cfg!(target_os = "macos")) || matches!(target, Some("macos")); + let mut embedded_info_plist_path: Option = None; + if ctx.needs_ui && is_macos_executable { + let exe_stem = exe_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("perry-app"); + let bundle_id = format!( + "dev.perry.{}", + exe_stem + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + ); + let info_plist = format!( + r#" + + + + CFBundleIdentifier + {bundle_id} + CFBundleName + {exe_stem} + CFBundleExecutable + {exe_stem} + CFBundlePackageType + APPL + NSCameraUsageDescription + This app uses the camera for WebView video calls. + NSMicrophoneUsageDescription + This app uses the microphone for WebView video calls. + + +"# + ); + let plist_path = std::env::temp_dir().join(format!( + "perry-embedded-info-{}-{}.plist", + std::process::id(), + exe_stem + )); + fs::write(&plist_path, info_plist)?; + embedded_info_plist_path = Some(plist_path.clone()); + if is_cross_macos { + cmd.arg("-sectcreate") + .arg("__TEXT") + .arg("__info_plist") + .arg(&plist_path); + } else { + cmd.arg(format!( + "-Wl,-sectcreate,__TEXT,__info_plist,{}", + plist_path.display() + )); + } + } + + let link_cache_status = prepare_link_cache_status( + &ctx.cache_root, + target, + &cmd, + obj_paths, + obj_fingerprints, + exe_path, + ); + if !link_cache_status.linked { + if let Some(path) = embedded_info_plist_path { + let _ = fs::remove_file(path); + } + return Ok(link_cache_status); + } + + // Windows hosts cap a spawned process's command line (`CreateProcess` ~32 + // KiB); a link with many object files — ≥~450 local modules (e.g. dense + // barrel `export *` re-exports, or the `@earendil-works/pi-ai` module + // graph) — overflows it and fails with `The filename or extension is too + // long. (os error 206)` before the linker even starts. Route the whole + // argument vector through a linker *response file* (`@file`), which + // `link.exe` / `lld-link` (and `clang`/`cc`) read instead of the command + // line, so the invocation no longer scales with module count. Only the + // native-Windows host is affected (other hosts have a multi-MB `ARG_MAX`); + // `PERRY_FORCE_LINK_RESPONSE_FILE=1` forces the path elsewhere for testing. + let mut response_file_to_clean: Option = None; + if cfg!(target_os = "windows") || std::env::var_os("PERRY_FORCE_LINK_RESPONSE_FILE").is_some() { + // MSVC-style quoting for the native-Windows linker (link.exe/lld-link); + // GNU-style for a clang/cc driver (the forced-test path on macOS/Linux). + let msvc_quoting = cfg!(target_os = "windows") && is_windows; + if let Some((rsp_cmd, rsp_path)) = rewrite_link_with_response_file(&cmd, msvc_quoting) { + cmd = rsp_cmd; + response_file_to_clean = Some(rsp_path); + } + } + + let status_result = cmd.status(); + if let Some(path) = response_file_to_clean { + let _ = fs::remove_file(path); + } + if let Some(path) = embedded_info_plist_path { + let _ = fs::remove_file(path); + } + let status = status_result?; + + if !status.success() { + return Err(anyhow!("Linking failed")); + } + + Ok(link_cache_status) +} + +/// Quote one linker argument for a response file. `msvc` selects `link.exe` / +/// `lld-link` rules (a `"` toggles a quoted run; backslashes are literal Windows +/// path separators, so they are NOT escaped — only an embedded `"` is escaped as +/// `\"`); otherwise GNU/clang rules (inside double quotes `\` and `"` escape, so +/// both are backslash-escaped). An argument with no whitespace/quote needs no +/// quoting in either dialect — the common case (long object/lib paths). +pub(super) fn quote_response_arg(arg: &str, msvc: bool) -> String { + let needs_quote = arg.is_empty() + || arg + .bytes() + .any(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | b'"')); + if !needs_quote { + return arg.to_string(); + } + let mut out = String::with_capacity(arg.len() + 2); + out.push('"'); + for c in arg.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' if !msvc => out.push_str("\\\\"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +/// Render a linker `Command`'s full argument vector as response-file content +/// (one quoted arg per line). Pure + unit-tested; the `\n` separator and +/// per-arg quoting are what `link.exe`/`lld-link`/`clang` accept via `@file`. +pub(super) fn response_file_contents(args: &[String], msvc: bool) -> String { + let mut s = String::new(); + for a in args { + s.push_str("e_response_arg(a, msvc)); + s.push('\n'); + } + s +} + +/// Rewrite a linker invocation to pass its arguments via a response file +/// (` @`) instead of inline, dodging the Windows `CreateProcess` +/// command-line length cap (os error 206) on links with many object files. +/// Preserves program, env overrides, and working directory. The response file +/// goes in the OS temp dir under a per-process/-output unique name (so parallel +/// links don't clobber each other) and the caller deletes it after the link. +/// Returns `None` if there's nothing to gain (no args) or the file can't be +/// written — the caller then keeps the original inline command. +fn rewrite_link_with_response_file(cmd: &Command, msvc: bool) -> Option<(Command, PathBuf)> { + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + if args.is_empty() { + return None; + } + let contents = response_file_contents(&args, msvc); + let rsp = std::env::temp_dir().join(format!("perry-link-{}.rsp", std::process::id())); + fs::write(&rsp, contents).ok()?; + + let mut new_cmd = Command::new(cmd.get_program()); + new_cmd.arg(format!("@{}", rsp.display())); + // Preserve env overrides (e.g. MSVC LIB/PATH set by select_linker_command) + // and the working directory the original command was configured with. + for (key, val) in cmd.get_envs() { + match val { + Some(v) => { + new_cmd.env(key, v); + } + None => { + new_cmd.env_remove(key); + } + } + } + if let Some(cwd) = cmd.get_current_dir() { + new_cmd.current_dir(cwd); + } + Some((new_cmd, rsp)) +} + +#[cfg(test)] +mod optional_framework_dir_tests; + +#[cfg(test)] +mod response_file_tests { + use super::{quote_response_arg, response_file_contents}; + + #[test] + fn plain_paths_are_unquoted_in_both_dialects() { + assert_eq!(quote_response_arg("/tmp/a_ts.o", false), "/tmp/a_ts.o"); + assert_eq!( + quote_response_arg(r"C:\build\d0449_ts.o", true), + r"C:\build\d0449_ts.o" + ); + // /OPT:REF etc. — no whitespace, untouched. + assert_eq!(quote_response_arg("/OPT:REF", true), "/OPT:REF"); + } + + #[test] + fn msvc_quotes_spaces_keeps_backslashes_literal() { + // Windows path with a space: quoted, backslashes NOT escaped. + assert_eq!( + quote_response_arg(r"C:\Program Files\x.lib", true), + "\"C:\\Program Files\\x.lib\"" + ); + } + + #[test] + fn gnu_quotes_and_escapes_backslashes() { + assert_eq!(quote_response_arg("/a b/x.o", false), "\"/a b/x.o\""); + assert_eq!( + quote_response_arg(r"/a\b c/x.o", false), + "\"/a\\\\b c/x.o\"" + ); + } + + #[test] + fn embedded_quote_is_escaped() { + assert_eq!(quote_response_arg("a\"b", true), "\"a\\\"b\""); + assert_eq!(quote_response_arg("a\"b", false), "\"a\\\"b\""); + } + + #[test] + fn contents_is_one_arg_per_line() { + let args = vec![ + "/tmp/main.o".to_string(), + "/tmp/mod 1.o".to_string(), + "-lperry".to_string(), + ]; + assert_eq!( + response_file_contents(&args, false), + "/tmp/main.o\n\"/tmp/mod 1.o\"\n-lperry\n" + ); + } +} 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 From 40b96841dee736f7c26f194993a2d3b8cac88b24 Mon Sep 17 00:00:00 2001 From: Ralph Date: Fri, 19 Jun 2026 02:33:33 -0700 Subject: [PATCH 2/2] style: cargo fmt for Windows plugin support Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-codegen/src/codegen/entry.rs | 5 ++++- .../perry/src/commands/compile/link/build_and_run.rs | 11 ++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index df3dee0ff6..f9be9e11cd 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -982,7 +982,10 @@ 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_activate = hir + .exported_functions + .iter() + .any(|(name, _)| name == "activate"); let has_plugin_deactivate = hir .exported_functions .iter() 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 9a0bf0cfc4..a6beb4e243 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -424,15 +424,16 @@ pub(crate) fn build_and_run_link( // 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() - )); + 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") + 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 {