Skip to content
Draft
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10,426 changes: 7,655 additions & 2,771 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -445,3 +445,10 @@ perry-codegen-wasm = { path = "crates/perry-codegen-wasm" }
perry-ui-testkit = { path = "crates/perry-ui-testkit" }
perry-audio-miniaudio = { path = "crates/perry-audio-miniaudio" }
perry-updater = { path = "crates/perry-updater" }

# Experimental: fork of servo-script migrated from ml-kem 0.2 -> 0.3.2 so its
# WebCrypto ML-KEM coexists with perry's ml-kem 0.3.2 (pkcs8). Enables the
# in-process Servo webview (servo-webview feature). Source + migration diff:
# https://github.com/PerryTS/servo-script-mlkem (see docs/servo/README.md).
[patch.crates-io]
servo-script = { git = "https://github.com/PerryTS/servo-script-mlkem", rev = "e4dc40a54366ac327845c8dd5013a3fb068d19b5" }
10 changes: 10 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,16 @@ pub(super) fn compile_module_entry(
let _ = ctx.block().call(I32, "js_interval_timer_tick", &[]);
}
ctx.block().call_void("js_run_stdlib_pump", &[]);

// Top-level UI loop takeover. If a windowed app requested a
// native UI event loop (e.g. the Electron-compat `app` shell),
// hand control to it HERE — at the true top level of `main`,
// before the async event loop. This is what lets a window
// created from `app.whenReady().then(...)` actually composite;
// entering `[NSApp run]` from a microtask leaves windows
// off-screen. No-op when no UI loop is registered.
ctx.block().call_void("js_ui_loop_take_over", &[]);

ctx.block().br(&header_label);

// loop_header: check if there's any reason to keep running
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/runtime_decls/strings_part2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,10 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) {
// perry-runtime as a thin function-pointer trampoline so it's
// safe to call even when perry-stdlib is not linked (no-op).
module.declare_function("js_run_stdlib_pump", VOID, &[]);
// Top-level UI event-loop takeover (Electron-compat / windowed apps). If a
// native UI backend registered a blocking loop, this blocks here at the true
// top level; otherwise it's a no-op and the async event loop proceeds.
module.declare_function("js_ui_loop_take_over", VOID, &[]);
module.declare_function("js_sleep_ms", VOID, &[DOUBLE]);
// Issue #84: condvar-backed wait for the event loop / await busy-wait.
// Replaces fixed-quantum `js_sleep_ms(10.0)` / `js_sleep_ms(1.0)`.
Expand Down
31 changes: 31 additions & 0 deletions crates/perry-dispatch/src/ui_table/part_b.rs
Original file line number Diff line number Diff line change
Expand Up @@ -986,4 +986,35 @@ pub(crate) const PERRY_UI_TABLE_PART_B: &[MethodRow] = &[
args: &[ArgKind::Widget, ArgKind::Closure],
ret: ReturnKind::Void,
},
// ---- Electron-compat shim (#5797): renderer→main IPC + body-less loop ----
MethodRow {
method: "webviewSetOnMessage",
runtime: "perry_ui_webview_set_on_message",
args: &[ArgKind::Widget, ArgKind::Closure],
ret: ReturnKind::Void,
},
MethodRow {
method: "webviewAddUserScript",
runtime: "perry_ui_webview_add_user_script",
args: &[ArgKind::Widget, ArgKind::Str],
ret: ReturnKind::Void,
},
MethodRow {
method: "appRunLoop",
runtime: "perry_ui_app_run_loop",
args: &[ArgKind::Closure],
ret: ReturnKind::Void,
},
MethodRow {
method: "appRequestLoop",
runtime: "perry_ui_app_request_loop",
args: &[ArgKind::Closure],
ret: ReturnKind::Void,
},
MethodRow {
method: "appQuit",
runtime: "perry_ui_app_quit",
args: &[],
ret: ReturnKind::Void,
},
];
1 change: 1 addition & 0 deletions crates/perry-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ pub mod typedarray;
pub mod typedarray_half;
pub(crate) mod typedarray_props;
pub mod typedarray_view;
pub mod ui_loop;
pub mod url;
pub mod v8;
pub mod validators;
Expand Down
42 changes: 42 additions & 0 deletions crates/perry-runtime/src/ui_loop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! Top-level UI event-loop takeover.
//!
//! A native UI backend (perry-ui-macos, …) can register a blocking event-loop
//! function. Perry's generated `main` calls [`js_ui_loop_take_over`] once, at
//! the true top level (right before the normal async event loop), so the UI
//! backend's `[NSApp run]` becomes the outermost loop on the main thread.
//!
//! Why this matters: if the UI loop is entered from a microtask / the post-main
//! drain instead (e.g. `Promise.resolve().then(() => appRunLoop())`), the macOS
//! window server never composites the app's windows (they exist but report
//! `onscreen=None`). Entering it at the top level — the same context as the
//! classic blocking `App({...})` call — fixes that. Used by the Electron-compat
//! `app.whenReady()` shell, which can't itself block at the top level because it
//! must first let the user's `main` register `ipcMain`/`whenReady` handlers.

use std::sync::atomic::{AtomicPtr, Ordering};

static UI_LOOP_FN: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut());

/// Register a blocking UI event-loop entry point. The UI backend calls this
/// (indirectly, when the program requests a windowed loop) so that
/// [`js_ui_loop_take_over`] can hand control to it. Passing a null-equivalent
/// is not expected; registration is one-shot for the process lifetime.
#[no_mangle]
pub extern "C" fn perry_runtime_register_ui_loop(f: extern "C" fn()) {
UI_LOOP_FN.store(f as *mut (), Ordering::SeqCst);
}

/// Called once by generated `main` at the top level, just before the normal
/// async event loop. If a UI loop was registered (a windowed app requested it),
/// this blocks in that loop for the lifetime of the app (it returns only if the
/// UI loop itself returns, which a real app's `[NSApp run]` does not — it exits
/// via `terminate:`). If nothing registered a UI loop, this is a no-op and the
/// normal event loop proceeds.
#[no_mangle]
pub extern "C" fn js_ui_loop_take_over() {
let p = UI_LOOP_FN.load(Ordering::SeqCst);
if !p.is_null() {
let f: extern "C" fn() = unsafe { std::mem::transmute(p) };
f();
}
}
4 changes: 4 additions & 0 deletions crates/perry-ui-macos/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ crate-type = ["rlib", "staticlib"]
default = []
plugins = []
geisterhand = []
servo-webview = ["dep:servo", "dep:url", "dep:dpi"]

[dependencies]
perry-ui = { path = "../perry-ui" }
servo = { version = "0.1", optional = true }
url = { version = "2", optional = true }
dpi = { version = "0.1", optional = true }
perry-ui-testkit = { workspace = true }
base64 = "0.22"
block2 = "0.6"
Expand Down
181 changes: 177 additions & 4 deletions crates/perry-ui-macos/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,131 @@ pub fn app_run(_app_handle: i64) {
app.run();
}

/// Body-less event loop for the Electron-compat shell (`app.whenReady()`).
///
/// Unlike `app_run`, this does not expect any pre-built `App({...})` window.
/// It sets up `NSApplication`, the menu bar, the app delegate and the ~8ms
/// timer pump, then — once everything is in place — invokes the `on_ready` TS
/// closure (which resolves the shim's `app.whenReady()` promise) and blocks in
/// `NSApplication.run()`. The `.then(createWindow)` chain runs on the first pump
/// tick, so `new BrowserWindow()` / `Window()` create real windows while the
/// loop is live. Windows are created dynamically via the multi-window FFI
/// (`window_create` / `window_set_body` / `window_show`).
pub fn app_run_loop(on_ready: f64) {
crate::crash_log::install_crash_hooks();
register_cross_platform_text_handlers();

let mtm = MainThreadMarker::new().expect("perry/ui must run on the main thread");
let app = NSApplication::sharedApplication(mtm);

let policy = PENDING_ACTIVATION_POLICY.with(|p| p.borrow().clone());
let activation_policy = match policy.as_deref() {
Some("accessory") => NSApplicationActivationPolicy::Accessory,
Some("background") => NSApplicationActivationPolicy::Prohibited,
_ => NSApplicationActivationPolicy::Regular,
};
app.setActivationPolicy(activation_policy);

PENDING_ICON_PATH.with(|p| {
if let Some(path) = p.borrow().as_ref() {
unsafe {
let ns_path = NSString::from_str(path);
let image: Option<Retained<NSImage>> =
msg_send![NSImage::alloc(), initWithContentsOfFile: &*ns_path];
if let Some(img) = image {
let _: () = msg_send![&*app, setApplicationIconImage: &*img];
}
}
}
});

setup_menu_bar(&app, mtm);
flush_pending_shortcuts(mtm);

unsafe {
let delegate = PerryAppDelegate::new();
let _: () = msg_send![&*app, setDelegate: &*delegate];
crate::deeplinks::install_apple_event_handler(&*delegate as *const _ as *const AnyObject);
std::mem::forget(delegate);
}

#[allow(deprecated)]
app.activateIgnoringOtherApps(true);

// ~8ms (120Hz) timer pump drives setTimeout/setInterval/microtasks/stdlib.
unsafe {
let target = PerryPumpTarget::new();
let sel = Sel::register(c"pump:");
let _: Retained<AnyObject> = msg_send![
objc2::class!(NSTimer),
scheduledTimerWithTimeInterval: 0.008f64,
target: &*target,
selector: sel,
userInfo: std::ptr::null::<AnyObject>(),
repeats: true
];
std::mem::forget(target);
}

install_test_mode_exit_timer();

// Resolve `app.whenReady()`. This only enqueues the `.then` microtask; it
// runs on the first pump tick once `app.run()` is spinning below.
if on_ready != 0.0 {
extern "C" {
fn js_nanbox_get_pointer(value: f64) -> i64;
fn js_closure_call0(closure: *const u8) -> f64;
}
crate::catch_callback_panic(
"app whenReady",
std::panic::AssertUnwindSafe(|| unsafe {
let ptr = js_nanbox_get_pointer(on_ready) as *const u8;
if !ptr.is_null() {
js_closure_call0(ptr);
}
}),
);
}

app.run();
}

thread_local! {
/// `on_ready` closure for the deferred top-level UI loop (Electron-compat).
static PENDING_APP_LOOP_ON_READY: std::cell::Cell<f64> = std::cell::Cell::new(0.0);
}

/// Request the Electron-compat UI event loop be entered at the TOP LEVEL after
/// `main` (not from a microtask). The shim calls this during module init; it
/// stores `on_ready` and registers `ui_loop_entry` with the runtime so the
/// generated `main`'s `js_ui_loop_take_over()` hands control to `app_run_loop`
/// at the top level. Entering `[NSApp run]` from a microtask leaves windows
/// off-screen, so this top-level entry is required for windows to composite.
pub fn request_app_loop(on_ready: f64) {
extern "C" {
fn perry_runtime_register_ui_loop(f: extern "C" fn());
}
PENDING_APP_LOOP_ON_READY.with(|c| c.set(on_ready));
unsafe {
perry_runtime_register_ui_loop(ui_loop_entry);
}
}

extern "C" fn ui_loop_entry() {
let on_ready = PENDING_APP_LOOP_ON_READY.with(|c| c.get());
app_run_loop(on_ready);
Comment on lines +713 to +736

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Root the deferred on_ready callback instead of storing it only in TLS.

PENDING_APP_LOOP_ON_READY keeps the JS closure only in a thread_local Cell<f64>. That value is not a stack local or a registered GC root, so a GC between request_app_loop and ui_loop_entry can reclaim the closure before app_run_loop reaches js_closure_call0. There is already such a gap in crates/perry-codegen/src/codegen/entry.rs Lines 745-764: generated main still runs the initial microtask/timer drains and js_run_stdlib_pump before js_ui_loop_take_over(). Please move this callback into a GC-rooted slot / runtime handle instead of TLS. Based on learnings, values are only pinned across allocations while they remain directly reachable from a Rust stack local; once they live only in a heap/TLS slot, they need explicit rooting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-ui-macos/src/app.rs` around lines 696 - 719, The deferred
`on_ready` callback is only stored in `PENDING_APP_LOOP_ON_READY`, so it can be
collected before `ui_loop_entry` calls `app_run_loop`. Update `request_app_loop`
and `ui_loop_entry` in `app.rs` to move the callback into a
GC-rooted/runtime-managed handle instead of a `thread_local Cell<f64>`, and
ensure it stays pinned until `js_closure_call0` runs. Use the existing
`perry_runtime_register_ui_loop` / `app_run_loop` flow as the place to wire the
rooted handle through.

Source: Learnings

}

/// `app.quit()` — terminate the application (Electron-compat).
pub fn app_quit() {
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
unsafe {
let _: () = msg_send![&*app, terminate: std::ptr::null::<AnyObject>()];
}
}
}

/// If `PERRY_UI_TEST_MODE=1`, schedule an NSTimer that captures a screenshot
/// (when `PERRY_UI_SCREENSHOT_PATH` is set) and exits the process cleanly.
/// This lets doc-example programs be verified in CI without a human.
Expand Down Expand Up @@ -1588,14 +1713,43 @@ pub fn window_create(title_ptr: *const u8, width: f64, height: f64) -> i64 {
}
}

/// Set the root widget of a window.
/// Set the root widget of a window, pinned to fill it.
///
/// Unlike the bare `setContentView`, this pins the body to the window's
/// `contentLayoutGuide` with Auto Layout so a full-window webview (an Electron
/// `BrowserWindow` whose entire content is the page) fills and resizes with the
/// window. Mirrors the pinning `app_set_body` does for the main window.
pub fn window_set_body(window_handle: i64, widget_handle: i64) {
WINDOWS.with(|w| {
let windows = w.borrow();
let idx = (window_handle - 1) as usize;
if idx < windows.len() {
if let Some(view) = crate::widgets::get_widget(widget_handle) {
windows[idx].window.setContentView(Some(&view));
let window = &windows[idx].window;
window.setContentView(Some(&view));
unsafe {
let _: () = objc2::msg_send![&*view, setTranslatesAutoresizingMaskIntoConstraints: false];
let guide: Retained<AnyObject> = msg_send![&**window, contentLayoutGuide];
let guide_top: Retained<AnyObject> = msg_send![&*guide, topAnchor];
let guide_bottom: Retained<AnyObject> = msg_send![&*guide, bottomAnchor];
let guide_leading: Retained<AnyObject> = msg_send![&*guide, leadingAnchor];
let guide_trailing: Retained<AnyObject> = msg_send![&*guide, trailingAnchor];

let top_anchor = view.topAnchor();
let bottom_anchor = view.bottomAnchor();
let leading_anchor = view.leadingAnchor();
let trailing_anchor = view.trailingAnchor();

let c_top: Retained<AnyObject> = msg_send![&*top_anchor, constraintEqualToAnchor: &*guide_top];
let c_bottom: Retained<AnyObject> = msg_send![&*bottom_anchor, constraintEqualToAnchor: &*guide_bottom];
let c_leading: Retained<AnyObject> = msg_send![&*leading_anchor, constraintEqualToAnchor: &*guide_leading];
let c_trailing: Retained<AnyObject> = msg_send![&*trailing_anchor, constraintEqualToAnchor: &*guide_trailing];

let _: () = msg_send![&*c_top, setActive: true];
let _: () = msg_send![&*c_bottom, setActive: true];
let _: () = msg_send![&*c_leading, setActive: true];
let _: () = msg_send![&*c_trailing, setActive: true];
}
}
}
});
Expand All @@ -1607,8 +1761,27 @@ pub fn window_show(window_handle: i64) {
let windows = w.borrow();
let idx = (window_handle - 1) as usize;
if idx < windows.len() {
windows[idx].window.center();
windows[idx].window.makeKeyAndOrderFront(None);
let window = &windows[idx].window;
// Activate the app first. In the Electron-compat loop, windows are
// created dynamically from a microtask *during* app_run_loop's
// `[NSApp run]` (via whenReady().then(createWindow)), after the
// initial activate(). Without re-activating + orderFrontRegardless
// the window is created but never composited on-screen
// (CGWindow `onscreen=None`) and `makeKeyAndOrderFront` alone is a
// no-op for a non-active app.
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
#[allow(deprecated)]
app.activateIgnoringOtherApps(true);
}
window.center();
window.makeKeyAndOrderFront(None);
unsafe {
// Force the window on-screen regardless of app-active state —
// this is the piece that actually composites a window created
// mid-run-loop.
let _: () = msg_send![&**window, orderFrontRegardless];
}
}
});
}
Expand Down
20 changes: 20 additions & 0 deletions crates/perry-ui-macos/src/lib_ffi/core_widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ pub extern "C" fn perry_ui_app_run(app_handle: i64) {
app::app_run(app_handle);
}

/// Electron-compat body-less event loop. Fires `on_ready` (resolves
/// `app.whenReady()`) then blocks. Windows are created dynamically afterward.
#[no_mangle]
pub extern "C" fn perry_ui_app_run_loop(on_ready: f64) {
app::app_run_loop(on_ready);
}

/// `app.quit()` — terminate the application.
#[no_mangle]
pub extern "C" fn perry_ui_app_quit() {
app::app_quit();
}

/// Request the top-level Electron-compat UI loop (non-blocking; the generated
/// `main` enters it via `js_ui_loop_take_over` after the user's top-level code).
#[no_mangle]
pub extern "C" fn perry_ui_app_request_loop(on_ready: f64) {
app::request_app_loop(on_ready);
}

/// Set the application dock icon from a file path.
#[no_mangle]
pub extern "C" fn perry_ui_app_set_icon(path_ptr: i64) {
Expand Down
8 changes: 8 additions & 0 deletions crates/perry-ui-macos/src/lib_ffi/interactivity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,14 @@ pub extern "C" fn perry_ui_webview_set_on_error(handle: i64, closure: f64) {
widgets::webview::set_on_error(handle, closure)
}
#[no_mangle]
pub extern "C" fn perry_ui_webview_set_on_message(handle: i64, closure: f64) {
widgets::webview::set_on_message(handle, closure)
}
#[no_mangle]
pub extern "C" fn perry_ui_webview_add_user_script(handle: i64, src_ptr: i64) {
widgets::webview::add_user_script(handle, src_ptr as *const u8)
}
#[no_mangle]
pub extern "C" fn perry_ui_webview_load_url(handle: i64, url_ptr: i64) {
widgets::webview::load_url(handle, url_ptr as *const u8)
}
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-ui-macos/src/widgets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ pub mod rich_text;
pub mod rich_tooltip;
pub mod scrollview;
pub mod securefield;
/// Experimental Servo web-engine backend (off by default; `PERRY_WEBVIEW=servo`).
#[cfg(feature = "servo-webview")]
pub mod servo_webview;
pub mod sheet;
pub mod slider;
pub mod spacer;
Expand Down
Loading
Loading