diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a3e00ac5..d879f41cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,57 @@ +## v0.5.1204 — electron-compat contributor feedback: DX fixes + ext-events warm-cache link fix + +Eight items of feedback from a contributor trying the `feat/electron-compat` +branch end-to-end. Most are packaging/DX; one is a real linker bug. + +- **`perry/ui` + `events` apps failed to link from source with `Undefined + symbols: _js_event_emitter_*`** (the headline bug). Root cause: the + auto-optimize lib resolver has two well-known-binding categories — CPU-only + wrappers (e.g. `events` → `perry-ext-events.a`) resolved immediately, and + tokio-using wrappers resolved after the cargo rebuild. The **warm-cache + early-return path** in `build_optimized_libs` (`optimized_libs.rs`) + reconstructed `well_known_libs` from the tokio-using set **alone**, silently + dropping every CPU-only wrapper from the link line — while the matching stdlib + feature (`bundled-events`) was still stripped from the cached stdlib. The + result: a stdlib that calls `js_event_emitter_*` with no provider on the link + line. Only bit on a **warm** auto-optimize cache (a cold cache took the + fresh-rebuild path, which appends to the same list and linked fine) — which is + exactly the intermittent "can't compile from source as documented" report. + Fix: the cached path now `extend()`s the already-resolved CPU-only + `well_known_libs` with the tokio-resolved ones, mirroring the fresh path + (#466). Reproduced with the `system-explorer` electron example (perry/ui + + EventEmitter) and confirmed the link now includes `libperry_ext_events.a`. +- **`perry run .` rejected a directory** with "Is a directory". `resolve_entry_file` + returned the directory as-is because it `exists()`. Now a directory argument + (and the no-arg default) resolves an entry *inside* it — `perry.toml` `entry`, + then `src/main.ts`, then `main.ts` — so the project-level form works. +- **`perry init` now mirrors `perry.packageAliases` into `tsconfig.json` + `compilerOptions.paths`.** `packageAliases` was compiler-only, so the IDE's tsc + resolved aliased imports differently from what Perry compiles. init now emits + matching `paths` (with `baseUrl`) on create, and merges them into an existing + tsconfig (best-effort; prints the block to paste if the file isn't plain JSON). +- **New `packages/perry-react` (`@perryts/react`)** — a types package for Perry's + React support. It depends on `@types/react` / `@types/react-dom` (so users + don't add them by hand) and augments `react-dom/client` with Perry's + native-window overload `createRoot(null, { title, width, height })` + (`PerryRootOptions`). Validated with real `tsc` under `moduleResolution` + `bundler` (Perry's default) and `node`. NOTE: the augmentation must live in the + file the user loads (`types: ["@perryts/react"]` or a direct import) — TS drops + module augmentations reached only via a re-export from another `.d.ts`, and a + stray `export {}` next to the `declare module` also suppresses it. +- **Root `package.json` now carries `name`/`version`** (+ `private`) so + `npm install` straight from the git repo no longer crashes npm's arborist with + `Cannot read properties of undefined (reading 'spec')`. +- **`packages/electron` install is documented.** npm can't publish a package + named `electron` or install a repo subdirectory, so the README now covers the + two real paths: clone + `npm install /path/to/packages/electron` (keeps the + bare `electron` import working), or the scoped `@perryts/electron` + a + `packageAliases` entry. Added distribution metadata (`files`, `repository`, + `homepage`, `publishConfig`). +- **`webviewAddUserScript`** was reported missing from the npm binary; verified + it is fully implemented and wired on this branch (real `WKUserScript` + injection in perry-ui-macos + `perry-dispatch` table row) — it predates npm + 0.5.1182 and ships with this branch. + ## v0.5.1199 — feat(ui): BloomView live-render plumbing on every backend (#5519) Perry-UI side of #5519 (live `BloomView` rendering on all platforms; the engine diff --git a/CLAUDE.md b/CLAUDE.md index 20ec3e091d..41ed1adf7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1199 +**Current Version:** 0.5.1204 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index e15e0727d6..6ef381412e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5325,7 +5325,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "base64", @@ -5382,14 +5382,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "cc", "libc", @@ -5397,7 +5397,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "log", @@ -5412,7 +5412,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "perry-hir", @@ -5421,7 +5421,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "perry-hir", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "perry-dispatch", @@ -5439,7 +5439,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "perry-hir", @@ -5448,7 +5448,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "base64", @@ -5461,7 +5461,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "perry-hir", @@ -5469,7 +5469,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "async-trait", @@ -5498,14 +5498,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "serde", "serde_json", @@ -5513,7 +5513,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1199" +version = "0.5.1204" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5524,7 +5524,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "clap", @@ -5539,14 +5539,14 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-argon2" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "argon2", "perry-ffi", @@ -5554,7 +5554,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "reqwest", @@ -5563,7 +5563,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "bcrypt", "perry-ffi", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "rusqlite", @@ -5579,7 +5579,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "scraper", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "perry-runtime", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "chrono", "cron 0.16.0", @@ -5605,7 +5605,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "chrono", "perry-ffi", @@ -5613,7 +5613,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "rust_decimal", @@ -5621,7 +5621,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "serde_json", @@ -5629,7 +5629,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "perry-runtime", @@ -5645,14 +5645,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "bytes", "http-body-util", @@ -5669,7 +5669,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "lazy_static", "perry-ffi", @@ -5681,7 +5681,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "bytes", "lazy_static", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "bytes", "h2", @@ -5718,7 +5718,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "lazy_static", "perry-ffi", @@ -5728,7 +5728,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "base64", "jsonwebtoken", @@ -5739,7 +5739,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "lru", "perry-ffi", @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "chrono", "perry-ffi", @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "bson", "futures-util", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "chrono", "perry-ffi", @@ -5777,7 +5777,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "nanoid", "perry-ffi", @@ -5786,7 +5786,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "perry-runtime", @@ -5798,7 +5798,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "lettre", "perry-ffi", @@ -5808,7 +5808,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "printpdf", @@ -5816,7 +5816,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "sqlx", @@ -5825,7 +5825,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "governor", "perry-ffi", @@ -5833,7 +5833,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "fast_image_resize", "image", @@ -5843,14 +5843,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "lazy_static", "perry-ffi", @@ -5859,7 +5859,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "uuid", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ffi", "regex", @@ -5877,7 +5877,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "futures-util", "lazy_static", @@ -5889,7 +5889,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "brotli", "flate2", @@ -5898,7 +5898,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "dashmap", "once_cell", @@ -5907,7 +5907,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "perry-api-manifest", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "perry-diagnostics", @@ -5937,7 +5937,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "base64", @@ -5970,14 +5970,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6069,14 +6069,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "perry-hir", @@ -6086,7 +6086,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -6094,14 +6094,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "base64", "itoa", @@ -6118,7 +6118,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "rand 0.8.6", "serde", @@ -6128,7 +6128,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "base64", "cairo-rs", @@ -6151,7 +6151,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "base64", "block2", @@ -6167,7 +6167,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "base64", "block2", @@ -6182,7 +6182,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1199" +version = "0.5.1204" [[package]] name = "perry-ui-test" @@ -6190,11 +6190,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1199" +version = "0.5.1204" [[package]] name = "perry-ui-tvos" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "base64", "block2", @@ -6210,7 +6210,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "base64", "block2", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "block2", "libc", @@ -6239,7 +6239,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "base64", "libc", @@ -6256,14 +6256,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "base64", "ed25519-dalek", @@ -6277,7 +6277,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1199" +version = "0.5.1204" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index f0ed3f6fb5..8fe06390df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -301,7 +301,7 @@ strip = false codegen-units = 16 [workspace.package] -version = "0.5.1199" +version = "0.5.1204" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index da5531b359..7ae9f2a5ed 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -696,6 +696,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 diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 0c80f2c304..ac542cd25a 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -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)`. diff --git a/crates/perry-dispatch/src/ui_table.rs b/crates/perry-dispatch/src/ui_table.rs index e168a9f4da..6c72cad790 100644 --- a/crates/perry-dispatch/src/ui_table.rs +++ b/crates/perry-dispatch/src/ui_table.rs @@ -655,6 +655,22 @@ pub const PERRY_UI_TABLE: &[MethodRow] = &[ args: &[ArgKind::Widget, ArgKind::Closure], ret: ReturnKind::Void, }, + // Electron-compat IPC bridge (renderer → main). `closure` receives the + // JSON string the page posts via the "perry" WKScriptMessageHandler. + MethodRow { + method: "webviewSetOnMessage", + runtime: "perry_ui_webview_set_on_message", + args: &[ArgKind::Widget, ArgKind::Closure], + ret: ReturnKind::Void, + }, + // Inject a document-start user script (bridge runtime + app preload) before + // the page's own scripts run. Call before webviewLoadUrl. + MethodRow { + method: "webviewAddUserScript", + runtime: "perry_ui_webview_add_user_script", + args: &[ArgKind::Widget, ArgKind::Str], + ret: ReturnKind::Void, + }, MethodRow { method: "webviewLoadUrl", runtime: "perry_ui_webview_load_url", @@ -1787,6 +1803,28 @@ pub const PERRY_UI_TABLE: &[MethodRow] = &[ ret: ReturnKind::I64AsF64, }, // ---- App lifecycle hooks ---- + // Electron-compat: body-less event loop. `appRunLoop(onReady)` blocks; the + // onReady closure resolves `app.whenReady()`. `appQuit()` terminates. + MethodRow { + method: "appRunLoop", + runtime: "perry_ui_app_run_loop", + args: &[ArgKind::Closure], + ret: ReturnKind::Void, + }, + // Non-blocking: registers the top-level UI loop; generated main enters it + // after the user's top-level code (so windows from whenReady().then composite). + 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, + }, MethodRow { method: "onTerminate", runtime: "perry_ui_app_on_terminate", diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 29d2e98cb1..319aabf325 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -108,6 +108,7 @@ pub mod temporal; pub mod text; pub mod timer; pub mod typed_feedback; +pub mod ui_loop; pub mod typedarray; pub mod typedarray_half; pub(crate) mod typedarray_props; diff --git a/crates/perry-runtime/src/ui_loop.rs b/crates/perry-runtime/src/ui_loop.rs new file mode 100644 index 0000000000..39aa3b13d6 --- /dev/null +++ b/crates/perry-runtime/src/ui_loop.rs @@ -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(); + } +} diff --git a/crates/perry-ui-macos/src/app.rs b/crates/perry-ui-macos/src/app.rs index 5dbb36f2ff..ea5838606c 100644 --- a/crates/perry-ui-macos/src/app.rs +++ b/crates/perry-ui-macos/src/app.rs @@ -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> = + 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 = msg_send![ + objc2::class!(NSTimer), + scheduledTimerWithTimeInterval: 0.008f64, + target: &*target, + selector: sel, + userInfo: std::ptr::null::(), + 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 = 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); +} + +/// `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::()]; + } + } +} + /// 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. @@ -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 = msg_send![&**window, contentLayoutGuide]; + let guide_top: Retained = msg_send![&*guide, topAnchor]; + let guide_bottom: Retained = msg_send![&*guide, bottomAnchor]; + let guide_leading: Retained = msg_send![&*guide, leadingAnchor]; + let guide_trailing: Retained = 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 = msg_send![&*top_anchor, constraintEqualToAnchor: &*guide_top]; + let c_bottom: Retained = msg_send![&*bottom_anchor, constraintEqualToAnchor: &*guide_bottom]; + let c_leading: Retained = msg_send![&*leading_anchor, constraintEqualToAnchor: &*guide_leading]; + let c_trailing: Retained = 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]; + } } } }); @@ -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]; + } } }); } diff --git a/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs b/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs index 35185830e6..36c750fe7f 100644 --- a/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs +++ b/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs @@ -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) { diff --git a/crates/perry-ui-macos/src/lib_ffi/interactivity.rs b/crates/perry-ui-macos/src/lib_ffi/interactivity.rs index d1c09bb7f2..fb6899ba00 100644 --- a/crates/perry-ui-macos/src/lib_ffi/interactivity.rs +++ b/crates/perry-ui-macos/src/lib_ffi/interactivity.rs @@ -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) } diff --git a/crates/perry-ui-macos/src/widgets/webview.rs b/crates/perry-ui-macos/src/widgets/webview.rs index ac472370b2..aa4fd4dc4f 100644 --- a/crates/perry-ui-macos/src/widgets/webview.rs +++ b/crates/perry-ui-macos/src/widgets/webview.rs @@ -52,6 +52,10 @@ struct WebViewState { on_should_navigate: f64, on_loaded: f64, on_error: f64, + /// Electron-compat IPC: TS closure invoked with the JSON string the page + /// posts via `window.webkit.messageHandlers.perry.postMessage(...)`. + /// 0.0 means "not registered". (renderer → main half of the bridge). + on_message: f64, /// Hard navigation allowlist. Empty = no restriction. URLs whose host /// matches any entry pass; others are rejected without invoking the user /// closure (security: prevents hijacked OAuth pages from redirecting to @@ -446,6 +450,51 @@ define_class!( ) { self.dispatch_error(error); } + + /// `userContentController:didReceiveScriptMessage:` — the inbound half + /// of the Electron-compat IPC bridge (WKScriptMessageHandler). The page + /// calls `window.webkit.messageHandlers.perry.postMessage(jsonString)`; + /// we hand the JSON string to the registered `on_message` TS closure. + /// The main process (compiled TS) parses it and replies via + /// `webviewEvaluateJs(...)` (the main → renderer half). + #[unsafe(method(userContentController:didReceiveScriptMessage:))] + fn did_receive_script_message( + &self, + _ucc: *mut AnyObject, + message: *mut AnyObject, + ) { + let key = self.ivars().callback_key.get(); + let on_message = WEBVIEW_STATES.with(|s| { + s.borrow().get(&key).map(|st| st.on_message).unwrap_or(0.0) + }); + if on_message == 0.0 { + return; + } + crate::catch_callback_panic( + "webview onMessage", + std::panic::AssertUnwindSafe(|| unsafe { + // The bridge always posts a JSON string, so `body` is an + // NSString. `description` is a safe fallback for anything else. + let body: *mut AnyObject = msg_send![message, body]; + let s = if !body.is_null() { + let descr: *mut AnyObject = msg_send![body, description]; + if !descr.is_null() { + let ns: &NSString = &*(descr as *const NSString); + ns.to_string() + } else { + String::new() + } + } else { + String::new() + }; + let nb = nanbox_str(&s); + let closure_ptr = js_nanbox_get_pointer(on_message) as *const u8; + if !closure_ptr.is_null() { + js_closure_call1(closure_ptr, nb); + } + }), + ); + } } ); @@ -515,7 +564,14 @@ pub fn create(url_ptr: *const u8, width: f64, height: f64, ephemeral_hint: f64) ), ); - // 1. WKWebViewConfiguration. v2-B: pass the ephemeral hint up + // 1. Delegate first — it doubles as the WKScriptMessageHandler, so it + // must exist before we build the configuration's content controller. + // The ivar key is the delegate's own address (stable for life). + let delegate = PerryWebViewDelegate::new(); + let key = Retained::as_ptr(&delegate) as usize; + delegate.ivars().callback_key.set(key); + + // 2. WKWebViewConfiguration. v2-B: pass the ephemeral hint up // front so the websiteDataStore is correct from the first // navigation onward. Default (1.0 = ephemeral) maps to // nonPersistentDataStore; 0.0 → defaultDataStore. @@ -530,24 +586,48 @@ pub fn create(url_ptr: *const u8, width: f64, height: f64, ephemeral_hint: f64) }; let _: () = msg_send![cfg, setWebsiteDataStore: store]; - // 2. WKWebView. + // 2b. WKUserContentController — the Electron-compat IPC bridge. + // Register the delegate as the "perry" script message handler so + // `window.webkit.messageHandlers.perry.postMessage(json)` in the + // page lands in `did_receive_script_message`. User scripts (the + // bridge runtime + the app's preload) are injected later via + // `add_user_script`. The content controller retains its handler + // strongly, so the delegate stays alive for IPC even though + // WKWebView only holds the nav/UI delegate weakly. + let ucc_cls = AnyClass::get(c"WKUserContentController") + .expect("WKUserContentController not found — link WebKit.framework"); + let ucc: *mut AnyObject = msg_send![ucc_cls, new]; + let handler_name = NSString::from_str("perry"); + let _: () = msg_send![ucc, addScriptMessageHandler: &*delegate, name: &*handler_name]; + let _: () = msg_send![cfg, setUserContentController: ucc]; + + // 2c. Allow a file:// document to load file:// subresources (its own + // + + diff --git a/packages/electron/examples/system-explorer/renderer/renderer.js b/packages/electron/examples/system-explorer/renderer/renderer.js new file mode 100644 index 0000000000..b1ed374a37 --- /dev/null +++ b/packages/electron/examples/system-explorer/renderer/renderer.js @@ -0,0 +1,105 @@ +// Renderer — plain web JS calling the contextBridge-exposed `window.api`, +// which routes over the Perry IPC bridge to the native main process. +const api = window.api; + +function fmtBytes(n) { + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; + return (n / (1024 * 1024)).toFixed(1) + " MB"; +} + +// ---- System info (invoke/handle round trip) ---- +async function loadSystem() { + const info = await api.getSystemInfo(); + const rows = [ + ["Platform", info.platform + " / " + info.arch], + ["Hostname", info.hostname], + ["CPU", info.cpuModel], + ["Cores", String(info.cpus)], + ["Memory", info.freeMemMB + " / " + info.totalMemMB + " MB free"], + ["Uptime", Math.round(info.uptimeSec / 60) + " min"], + ["Release", info.release], + ]; + document.getElementById("sysinfo").innerHTML = rows + .map((r) => '
' + r[0] + "" + r[1] + "
") + .join(""); + api.log("system info rendered: " + info.hostname); +} + +// ---- File listing + preview ---- +let lastDir = ""; +async function loadFiles() { + const res = await api.listDir(""); + lastDir = res.dir; + document.getElementById("crumbs").textContent = res.dir; + const html = res.entries + .map(function (e) { + const ic = e.isDir ? "📁" : "📄"; + const sz = e.isDir ? "" : '' + fmtBytes(e.sizeBytes) + ""; + return ( + '
' + + ic + + '' + + e.name + + "" + + sz + + "
" + ); + }) + .join(""); + const filesEl = document.getElementById("files"); + filesEl.innerHTML = html; + filesEl.querySelectorAll(".file").forEach(function (el) { + el.addEventListener("click", async function () { + if (el.getAttribute("data-dir") === "1") return; + const name = el.getAttribute("data-name"); + const fileRes = await api.readFile(lastDir + "/" + name); + const pre = document.getElementById("preview"); + pre.textContent = fileRes.ok ? fileRes.text : "⚠️ " + fileRes.error; + }); + }); +} + +// ---- Notes (persisted) ---- +let notes = []; +async function loadNotes() { + notes = await api.loadNotes(); + renderNotes(); +} +function renderNotes() { + document.getElementById("noteList").innerHTML = notes + .map(function (n) { + return '
' + escapeHtml(n) + "
"; + }) + .join(""); +} +function escapeHtml(s) { + return String(s).replace(/[&<>]/g, function (c) { + return c === "&" ? "&" : c === "<" ? "<" : ">"; + }); +} + +document.getElementById("addNote").addEventListener("click", async function () { + const input = document.getElementById("noteInput"); + const v = input.value.trim(); + if (!v) return; + notes.unshift(v); + input.value = ""; + renderNotes(); + const r = await api.saveNotes(notes); + api.log("saved " + r.count + " notes"); +}); + +// ---- Live clock push (main → renderer) ---- +api.onClockTick(function (iso) { + const d = new Date(iso); + document.getElementById("clock").textContent = d.toLocaleTimeString(); +}); + +loadSystem(); +loadFiles(); +loadNotes(); diff --git a/packages/electron/package.json b/packages/electron/package.json new file mode 100644 index 0000000000..d5615bea82 --- /dev/null +++ b/packages/electron/package.json @@ -0,0 +1,28 @@ +{ + "name": "electron", + "version": "0.1.0", + "description": "Electron-compatible app shell for Perry — run existing Electron apps natively (Tauri-model internals, Electron API surface)", + "main": "src/index.ts", + "types": "src/index.ts", + "perry": { + "nativeModule": true + }, + "files": [ + "src", + "bridge", + "README.md", + "DESIGN.md" + ], + "keywords": ["perry", "electron", "desktop", "webview", "ipc"], + "repository": { + "type": "git", + "url": "https://github.com/PerryTS/perry.git", + "directory": "packages/electron" + }, + "homepage": "https://github.com/PerryTS/perry/tree/main/packages/electron#readme", + "bugs": "https://github.com/PerryTS/perry/issues", + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts new file mode 100644 index 0000000000..d0c37cfa32 --- /dev/null +++ b/packages/electron/src/index.ts @@ -0,0 +1,602 @@ +// electron — Electron-compatible app shell for Perry. +// +// Existing Electron app code (`import { app, BrowserWindow, ipcMain } from +// 'electron'`) runs unmodified. App logic compiles natively; the view runs in +// the OS-native webview. Internals are the Tauri model (system webview, single +// native process); the public surface is Electron's. +// +// Main process (this file, compiled native): app, BrowserWindow, ipcMain, Menu, +// dialog, webContents. Renderer/preload (ipcRenderer, contextBridge) is the JS +// in ./preload-runtime.ts injected into each webview. + +import { EventEmitter } from "events"; +import { + Window, + WebView, + webviewLoadUrl, + webviewEvaluateJs, + webviewSetOnMessage, + webviewSetOnLoaded, + webviewAddUserScript, + appRequestLoop, + appQuit, +} from "perry/ui"; +import * as os from "os"; +import * as path from "path"; +import * as fs from "fs"; +import * as childProcess from "child_process"; +import { PRELOAD_RUNTIME } from "./preload-runtime"; + +// --------------------------------------------------------------------------- +// Small helpers for marshalling values across the webview boundary. +// --------------------------------------------------------------------------- + +function jsStringLiteral(s: string): string { + // Produce a JS source string literal for embedding in an evaluateJs payload. + return JSON.stringify(s); +} + +// Build `window.__perryResolve(id, ok, payload)` source. `payload` is the +// result re-encoded as a JS string literal of its JSON (double-encoded), which +// the renderer JSON.parses. undefined results pass the bare `undefined` token. +function resolveJs(id: number, ok: boolean, value: unknown): string { + let arg3: string; + if (value === undefined) { + arg3 = "undefined"; + } else { + arg3 = jsStringLiteral(JSON.stringify(value)); + } + return "window.__perryResolve(" + id + "," + (ok ? "true" : "false") + "," + arg3 + ")"; +} + +function deliverJs(channel: string, args: unknown[]): string { + const argsJson = JSON.stringify(args); + return "window.__perryDeliver(" + jsStringLiteral(channel) + "," + jsStringLiteral(argsJson) + ")"; +} + +// --------------------------------------------------------------------------- +// ipcMain +// --------------------------------------------------------------------------- + +type IpcInvokeHandler = (event: IpcMainInvokeEvent, ...args: any[]) => any; + +interface IpcMainInvokeEvent { + sender: WebContents; + frameId: number; + processId: number; +} + +interface IpcMainEvent { + sender: WebContents; + reply: (channel: string, ...args: any[]) => void; + frameId: number; + processId: number; + returnValue?: any; +} + +class IpcMain extends EventEmitter { + // channel -> invoke handler (for ipcRenderer.invoke / ipcMain.handle). + // Initialized in the constructor (not as a field initializer): Perry does not + // reliably run field initializers on EventEmitter subclasses. + private handlers: { [channel: string]: IpcInvokeHandler }; + + constructor() { + super(); + this.handlers = {}; + } + + handle(channel: string, listener: IpcInvokeHandler): void { + this.handlers[channel] = listener; + } + handleOnce(channel: string, listener: IpcInvokeHandler): void { + const self = this; + this.handlers[channel] = function (event: IpcMainInvokeEvent, ...args: any[]) { + delete self.handlers[channel]; + return listener(event, ...args); + }; + } + removeHandler(channel: string): void { + delete this.handlers[channel]; + } + + // Internal: dispatch a parsed message coming from a webview. + _dispatch(win: BrowserWindow, msg: { kind: string; id: number; channel: string; args: any[] }): void { + const wc = win.webContents; + if (msg.kind === "console") { + // Renderer console / errors forwarded over the bridge. + const text = msg.args && msg.args.length > 0 ? String(msg.args[0]) : ""; + if (msg.channel === "error") console.error("[renderer-console] " + text); + else console.log("[renderer-console] " + text); + return; + } + if (msg.kind === "invoke") { + const handler = this.handlers[msg.channel]; + const invokeEvent: IpcMainInvokeEvent = { sender: wc, frameId: 0, processId: 0 }; + if (!handler) { + wc._eval(resolveJs(msg.id, false, { message: "No handler registered for '" + msg.channel + "'" })); + return; + } + try { + const result = handler(invokeEvent, ...msg.args); + Promise.resolve(result).then( + (value) => wc._eval(resolveJs(msg.id, true, value)), + (err) => wc._eval(resolveJs(msg.id, false, { message: errMessage(err) })) + ); + } catch (err) { + wc._eval(resolveJs(msg.id, false, { message: errMessage(err) })); + } + } else if (msg.kind === "send") { + const event: IpcMainEvent = { + sender: wc, + reply: (channel: string, ...args: any[]) => wc.send(channel, ...args), + frameId: 0, + processId: 0, + }; + this.emit(msg.channel, event, ...msg.args); + } + } +} + +function errMessage(err: unknown): string { + if (err && typeof err === "object" && "message" in (err as any)) return String((err as any).message); + return String(err); +} + +export const ipcMain = new IpcMain(); + +// --------------------------------------------------------------------------- +// WebContents — the per-window bridge to its webview. +// --------------------------------------------------------------------------- + +class WebContents extends EventEmitter { + // The perry/ui WebView widget handle for this window. + _wv: any; + id: number; + + constructor(id: number) { + super(); + this._wv = 0; + this.id = id; + } + + _eval(js: string): void { + if (this._wv) { + webviewEvaluateJs(this._wv, js, (_result: string) => {}); + } + } + + // main -> renderer push (ipcRenderer.on receives it) + send(channel: string, ...args: any[]): void { + this._eval(deliverJs(channel, args)); + } + + executeJavaScript(code: string): Promise { + return new Promise((resolve) => { + if (!this._wv) { + resolve(undefined); + return; + } + webviewEvaluateJs(this._wv, code, (result: string) => resolve(result)); + }); + } + + openDevTools(): void { + /* no-op: system webview devtools are opened via the OS inspector */ + } + closeDevTools(): void {} + setWindowOpenHandler(_handler: (details: any) => any): void {} + get session(): any { + return { clearStorageData: () => Promise.resolve() }; + } +} + +// --------------------------------------------------------------------------- +// BrowserWindow +// --------------------------------------------------------------------------- + +interface BrowserWindowOptions { + width?: number; + height?: number; + title?: string; + show?: boolean; + webPreferences?: { preload?: string; [k: string]: any }; + [k: string]: any; +} + +let nextWebContentsId = 1; +const openWindows: BrowserWindow[] = []; + +// Keeps the app-ready closure passed to appRequestLoop alive from module init +// until the native loop fires it after main top-level (GC root). +let rootedOnReady: (() => void) | null = null; + +class BrowserWindow extends EventEmitter { + // perry/ui window operations, captured as closures over the LOCAL `win` in + // the constructor. This is required: perry/ui instance-method dispatch + // (setBody/show/…) only fires on a local flow-tracked from the `Window()` + // call — calling on a property receiver (`this.win.show()`) compiles to a + // generic no-op, so the window would never composite or respond. A closure + // capturing the local preserves the dispatch. + private _show: () => void; + private _hide: () => void; + private _close: () => void; + private _setSize: (w: number, h: number) => void; + webContents: WebContents; + private destroyed: boolean; + private preloadPath: string | undefined; + + constructor(options?: BrowserWindowOptions) { + super(); + this.destroyed = false; + const opts = options || {}; + const width = opts.width || 800; + const height = opts.height || 600; + const title = opts.title || ""; + this.preloadPath = opts.webPreferences && opts.webPreferences.preload; + + const win = Window(title, width, height); + this._show = () => win.show(); + this._hide = () => win.hide(); + this._close = () => win.close(); + this._setSize = (w: number, h: number) => win.setSize(w, h); + this.webContents = new WebContents(nextWebContentsId++); + + // Create the webview that fills the window. Start blank; load via + // loadFile/loadURL. Persistent data store (ephemeral=0) so apps keep state. + const wv = WebView({ url: "about:blank", width, height }); + this.webContents._wv = wv; + + // Inject the IPC bridge runtime + the app's preload (document-start), + // before any page loads. Order matters: bridge first so `require('electron')` + // and `contextBridge` exist when the app's preload runs. + webviewAddUserScript(wv, PRELOAD_RUNTIME); + if (this.preloadPath) { + const preloadSrc = tryReadFile(this.preloadPath); + if (preloadSrc) webviewAddUserScript(wv, preloadSrc); + } + + // Fire webContents 'did-finish-load' / 'dom-ready' when the page loads, so + // apps that push to the renderer after load (a very common pattern) work. + const self = this; + // NOTE: wires webContents 'did-finish-load'/'dom-ready' from the webview's + // onLoaded delegate. The native onLoaded currently doesn't fire for file:// + // loads (tracked gap) so apps that push to the renderer on load don't yet + // get the event; renderer→main and main→renderer IPC otherwise work. + webviewSetOnLoaded(wv, (_url: string) => { + self.webContents.emit("did-finish-load"); + self.webContents.emit("dom-ready"); + }); + + // Route inbound IPC from this window's renderer to ipcMain. + webviewSetOnMessage(wv, (json: string) => { + let msg: any; + try { + msg = JSON.parse(json); + } catch (e) { + return; + } + ipcMain._dispatch(self, msg); + }); + + win.setBody(wv); + if (opts.show !== false) { + win.show(); + } + openWindows.push(this); + } + + loadURL(url: string): Promise { + webviewLoadUrl(this.webContents._wv, url); + return Promise.resolve(); + } + + loadFile(filePath: string, _options?: any): Promise { + // Resolve relative to cwd; Electron resolves relative to the app dir. + const abs = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); + webviewLoadUrl(this.webContents._wv, "file://" + abs); + return Promise.resolve(); + } + + show(): void { + this._show(); + } + hide(): void { + this._hide(); + } + close(): void { + if (this.destroyed) return; + this.emit("close"); + this._close(); + this.destroyed = true; + const idx = openWindows.indexOf(this); + if (idx >= 0) openWindows.splice(idx, 1); + this.emit("closed"); + if (openWindows.length === 0) { + app.emit("window-all-closed"); + } + } + destroy(): void { + this.close(); + } + isDestroyed(): boolean { + return this.destroyed; + } + setSize(width: number, height: number): void { + this._setSize(width, height); + } + setTitle(_title: string): void { + /* perry/ui window title is set at create; runtime retitle is a follow-up */ + } + focus(): void { + this._show(); + } + + static getAllWindows(): BrowserWindow[] { + return openWindows.slice(); + } + static getFocusedWindow(): BrowserWindow | null { + return openWindows.length > 0 ? openWindows[openWindows.length - 1] : null; + } +} + +function tryReadFile(p: string): string | null { + try { + const abs = path.isAbsolute(p) ? p : path.join(process.cwd(), p); + return fs.readFileSync(abs, "utf8"); + } catch (e) { + console.error("[electron] failed to read preload '" + p + "': " + errMessage(e)); + return null; + } +} + +// --------------------------------------------------------------------------- +// app +// --------------------------------------------------------------------------- + +class App extends EventEmitter { + private ready: boolean; + private readyResolvers: Array<() => void>; + private loopScheduled: boolean; + private appName: string; + dock: { hide: () => void; show: () => void; setIcon: (icon: any) => void }; + + constructor() { + super(); + this.ready = false; + this.readyResolvers = []; + this.loopScheduled = false; + this.appName = "Perry App"; + this.dock = { hide: () => {}, show: () => {}, setIcon: (_icon: any) => {} }; + // Register the native UI event loop. This does NOT block — the generated + // `main` enters the loop at the top level AFTER the user's `main` top-level + // code runs (registering ipcMain/whenReady handlers). Entering the loop at + // the top level (rather than from a microtask) is what lets windows created + // in `whenReady().then(...)` actually composite on screen. + this.startLoop(); + } + + private startLoop(): void { + if (this.loopScheduled) return; + this.loopScheduled = true; + const self = this; + // onReady fires when the loop takes over (top level), resolving whenReady(); + // the `.then(createWindow)` chain then runs on the first pump tick. Held in + // a module-level slot (rootedOnReady) so GC can't collect it before then. + rootedOnReady = () => { + self.ready = true; + self.emit("ready"); + const resolvers = self.readyResolvers; + self.readyResolvers = []; + for (let i = 0; i < resolvers.length; i++) resolvers[i](); + }; + appRequestLoop(rootedOnReady); + } + + whenReady(): Promise { + if (this.ready) return Promise.resolve(); + const self = this; + return new Promise((resolve) => { + self.readyResolvers.push(resolve); + }); + } + + isReady(): boolean { + return this.ready; + } + + quit(): void { + this.emit("before-quit"); + this.emit("will-quit"); + appQuit(); + } + exit(_code?: number): void { + appQuit(); + } + + getName(): string { + return this.appName; + } + setName(name: string): void { + this.appName = name; + } + getVersion(): string { + return "0.0.0"; + } + getAppPath(): string { + return process.cwd(); + } + getPath(name: string): string { + const home = os.homedir(); + switch (name) { + case "home": + return home; + case "appData": + return path.join(home, "Library", "Application Support"); + case "userData": + return path.join(home, "Library", "Application Support", this.appName); + case "temp": + return os.tmpdir(); + case "desktop": + return path.join(home, "Desktop"); + case "documents": + return path.join(home, "Documents"); + case "downloads": + return path.join(home, "Downloads"); + case "exe": + return process.execPath || process.cwd(); + default: + return home; + } + } + requestSingleInstanceLock(): boolean { + return true; + } + setActivationPolicy(_policy: string): void {} +} + +export const app = new App(); + +// --------------------------------------------------------------------------- +// Menu / MenuItem (v1: template is accepted; native menubar wiring is a +// follow-up. setApplicationMenu(null) hides — accepted as a no-op so apps run.) +// --------------------------------------------------------------------------- + +class MenuItem { + label: string; + click?: () => void; + submenu?: any; + role?: string; + type?: string; + accelerator?: string; + constructor(opts: any) { + this.label = opts.label || ""; + this.click = opts.click; + this.submenu = opts.submenu; + this.role = opts.role; + this.type = opts.type; + this.accelerator = opts.accelerator; + } +} + +class Menu { + items: MenuItem[]; + constructor() { + this.items = []; + } + append(item: MenuItem): void { + this.items.push(item); + } + popup(_options?: any): void {} + static buildFromTemplate(template: any[]): Menu { + const menu = new Menu(); + for (let i = 0; i < template.length; i++) { + menu.append(new MenuItem(template[i])); + } + return menu; + } + static setApplicationMenu(_menu: Menu | null): void { + /* v1: native menubar already provides standard Cmd+Q etc. */ + } + static getApplicationMenu(): Menu | null { + return null; + } +} + +export { Menu, MenuItem, BrowserWindow, WebContents }; + +// --------------------------------------------------------------------------- +// dialog (v1: returns canceled; native NSOpenPanel/NSSavePanel wiring is a +// follow-up. Apps that gate on `canceled` keep running.) +// --------------------------------------------------------------------------- + +export const dialog = { + showOpenDialog(_window?: any, _options?: any): Promise<{ canceled: boolean; filePaths: string[] }> { + console.warn("[electron] dialog.showOpenDialog is a v1 stub (returns canceled)"); + return Promise.resolve({ canceled: true, filePaths: [] }); + }, + showSaveDialog(_window?: any, _options?: any): Promise<{ canceled: boolean; filePath: string }> { + console.warn("[electron] dialog.showSaveDialog is a v1 stub (returns canceled)"); + return Promise.resolve({ canceled: true, filePath: "" }); + }, + showMessageBox(_window?: any, _options?: any): Promise<{ response: number; checkboxChecked: boolean }> { + return Promise.resolve({ response: 0, checkboxChecked: false }); + }, + showErrorBox(title: string, content: string): void { + console.error("[electron] " + title + ": " + content); + }, +}; + +// --------------------------------------------------------------------------- +// shell +// --------------------------------------------------------------------------- + +export const shell = { + openExternal(url: string): Promise { + try { + childProcess.spawn("open", [url]); + } catch (e) { + console.error("[electron] shell.openExternal failed: " + errMessage(e)); + } + return Promise.resolve(); + }, + openPath(p: string): Promise { + try { + childProcess.spawn("open", [p]); + } catch (e) {} + return Promise.resolve(""); + }, + showItemInFolder(_p: string): void {}, + beep(): void {}, +}; + +// --------------------------------------------------------------------------- +// ipcRenderer / contextBridge — renderer-only in Electron. Provided here so a +// main-process `import { ipcRenderer } from 'electron'` doesn't crash; the real +// implementations run in the renderer (./preload-runtime.ts). +// --------------------------------------------------------------------------- + +export const ipcRenderer = { + invoke(_channel: string, ..._args: any[]): Promise { + return Promise.reject(new Error("ipcRenderer is only available in the renderer process")); + }, + send(_channel: string, ..._args: any[]): void {}, + on(_channel: string, _listener: any): any { + return ipcRenderer; + }, + once(_channel: string, _listener: any): any { + return ipcRenderer; + }, + removeListener(_channel: string, _listener: any): any { + return ipcRenderer; + }, + removeAllListeners(_channel?: string): any { + return ipcRenderer; + }, +}; + +export const contextBridge = { + exposeInMainWorld(_key: string, _api: any): void { + /* renderer-only; see preload-runtime.ts */ + }, +}; + +export const nativeTheme = { + shouldUseDarkColors: false, + themeSource: "system", + on: (_event: string, _cb: any) => {}, +}; + +// Default export mirrors `const electron = require('electron')`. +export default { + app, + BrowserWindow, + ipcMain, + ipcRenderer, + contextBridge, + Menu, + MenuItem, + dialog, + shell, + nativeTheme, + WebContents, +}; diff --git a/packages/electron/src/preload-runtime.ts b/packages/electron/src/preload-runtime.ts new file mode 100644 index 0000000000..08133b3c6a --- /dev/null +++ b/packages/electron/src/preload-runtime.ts @@ -0,0 +1,206 @@ +// AUTO-WRAPPED from bridge/preload-runtime.js — do not edit here; edit the .js and re-wrap. +// The renderer-side Electron-compat runtime, injected into every webview as a +// document-start user script (before the app preload) via webviewAddUserScript. +export const PRELOAD_RUNTIME: string = `// Renderer-side Electron-compat runtime. +// +// Injected into every webview as a document-start WKUserScript (before the app's own +// preload). Provides \`require('electron')\` → { ipcRenderer, contextBridge } and the +// transport to the native (Perry-compiled) main process. +// +// Transport: +// renderer -> main : window.webkit.messageHandlers.perry.postMessage(jsonString) +// (WebView2: window.chrome.webview.postMessage; GTK: same shape) +// main -> renderer : native evals window.__perryDeliver / __perryResolve into the page +(function () { + "use strict"; + if (window.__perryBridgeInstalled) return; + window.__perryBridgeInstalled = true; + + function postToNative(msg) { + var json = JSON.stringify(msg); + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.perry) { + window.webkit.messageHandlers.perry.postMessage(json); // WKWebView (macOS/iOS) + } else if (window.chrome && window.chrome.webview) { + window.chrome.webview.postMessage(json); // WebView2 (Windows) + } else if (window.__perryNativePost) { + window.__perryNativePost(json); // GTK / fallback + } + // (no native channel → silently drop; console forwarding below would recurse) + } + + // Forward renderer console + uncaught errors to the native main process, so + // they land in the terminal (Electron pipes the renderer console to stdout via + // devtools; we do it over the bridge). Also the author's diagnostic window. + function fwd(level, args) { + try { + var parts = []; + for (var i = 0; i < args.length; i++) { + var a = args[i]; + parts.push(typeof a === "string" ? a : safeStringify(a)); + } + postToNative({ kind: "console", id: 0, channel: level, args: [parts.join(" ")] }); + } catch (e) {} + } + function safeStringify(v) { + try { return JSON.stringify(v); } catch (e) { return String(v); } + } + var realConsole = window.console || {}; + ["log", "info", "warn", "error", "debug"].forEach(function (level) { + var orig = realConsole[level] ? realConsole[level].bind(realConsole) : function () {}; + realConsole[level] = function () { + fwd(level, arguments); + orig.apply(null, arguments); + }; + }); + window.addEventListener("error", function (e) { + fwd("error", ["Uncaught " + (e.message || "error") + (e.filename ? " @ " + e.filename + ":" + e.lineno : "")]); + }); + window.addEventListener("unhandledrejection", function (e) { + fwd("error", ["Unhandled rejection: " + (e.reason && e.reason.message ? e.reason.message : safeStringify(e.reason))]); + }); + + var nextInvokeId = 1; + var pending = Object.create(null); // id -> {resolve, reject} + var listeners = Object.create(null); // channel -> [fn,...] + + // ---- main -> renderer entry points (called by native via evaluateJs) ---- + window.__perryResolve = function (id, ok, payloadJson) { + var p = pending[id]; + if (!p) return; + delete pending[id]; + var value = payloadJson === undefined ? undefined : JSON.parse(payloadJson); + if (ok) p.resolve(value); + else p.reject(Object.assign(new Error(value && value.message ? value.message : "ipc error"), value || {})); + }; + window.__perryDeliver = function (channel, payloadJson) { + var fns = listeners[channel]; + if (!fns || !fns.length) return; + var args = payloadJson === undefined ? [] : JSON.parse(payloadJson); + var event = { sender: ipcRenderer, senderId: 0 }; + for (var i = 0; i < fns.length; i++) { + try { fns[i].apply(null, [event].concat(args)); } + catch (e) { console.error("[perry-electron] listener for '" + channel + "' threw:", e); } + } + }; + + // ---- ipcRenderer ---- + var ipcRenderer = { + invoke: function (channel) { + var args = Array.prototype.slice.call(arguments, 1); + var id = nextInvokeId++; + return new Promise(function (resolve, reject) { + pending[id] = { resolve: resolve, reject: reject }; + postToNative({ kind: "invoke", id: id, channel: channel, args: args }); + }); + }, + send: function (channel) { + var args = Array.prototype.slice.call(arguments, 1); + postToNative({ kind: "send", id: 0, channel: channel, args: args }); + }, + sendSync: function () { + throw new Error("[perry-electron] ipcRenderer.sendSync is not supported; use invoke()"); + }, + on: function (channel, fn) { + (listeners[channel] || (listeners[channel] = [])).push(fn); + return ipcRenderer; + }, + once: function (channel, fn) { + var wrap = function () { ipcRenderer.removeListener(channel, wrap); return fn.apply(this, arguments); }; + return ipcRenderer.on(channel, wrap); + }, + removeListener: function (channel, fn) { + var fns = listeners[channel]; + if (fns) listeners[channel] = fns.filter(function (f) { return f !== fn; }); + return ipcRenderer; + }, + removeAllListeners: function (channel) { + if (channel === undefined) listeners = Object.create(null); + else delete listeners[channel]; + return ipcRenderer; + }, + }; + + // ---- contextBridge ---- + var contextBridge = { + exposeInMainWorld: function (key, api) { + // configurable:true so a renderer's top-level \`const x = window.x\` doesn't + // throw "duplicate variable that shadows a global property" in JSC. (Real + // Electron isolates the preload in a separate world; we share one world, + // so the exposed name must not be a non-configurable global binding.) + try { + Object.defineProperty(window, key, { + value: Object.freeze(deepBind(api)), + enumerable: true, + writable: false, + configurable: true, + }); + } catch (e) { + window[key] = deepBind(api); // some apps re-expose; be lenient + } + }, + exposeInIsolatedWorld: function (_worldId, key, api) { contextBridge.exposeInMainWorld(key, api); }, + }; + function deepBind(api) { + if (typeof api === "function") return api; + if (api && typeof api === "object") { + var out = Array.isArray(api) ? [] : {}; + for (var k in api) out[k] = deepBind(api[k]); + return out; + } + return api; + } + + // ---- require() for the renderer ---- + // Provides require('electron'), and a CommonJS loader for LOCAL files + // (require('./foo.js')) so old nodeIntegration-style apps that load their + // scripts/jQuery via require keep working — we sync-fetch the file and eval it + // as a CommonJS module. (Node *builtins* are not available in the webview.) + var electron = { ipcRenderer: ipcRenderer, contextBridge: contextBridge, webFrame: { setZoomFactor: function () {}, setZoomLevel: function () {} } }; + var moduleCache = Object.create(null); + + function loadSync(url) { + try { + var xhr = new XMLHttpRequest(); + xhr.open("GET", url, false); // sync — required for CommonJS require() semantics + xhr.send(); + if (xhr.status === 0 || (xhr.status >= 200 && xhr.status < 300)) return xhr.responseText; + } catch (e) {} + return null; + } + function resolveUrl(name, base) { + try { return new URL(name, base).href; } catch (e) { return name; } + } + function makeRequire(baseUrl) { + return function (name) { + if (name === "electron") return electron; + if (name.charAt(0) === "." || name.charAt(0) === "/") { + var url = resolveUrl(name, baseUrl); + var src = loadSync(url); + if (src === null && url.slice(-3) !== ".js") { url = url + ".js"; src = loadSync(url); } + if (src === null) throw new Error("[perry-electron] require: cannot load '" + name + "'"); + if (moduleCache[url]) return moduleCache[url].exports; + var mod = { exports: {} }; + moduleCache[url] = mod; + var dir = url.replace(/\\/[^/]*$/, "/"); + try { + var fn = new Function("module", "exports", "require", "__filename", "__dirname", src); + fn(mod, mod.exports, makeRequire(dir), url, dir); + } catch (e) { + delete moduleCache[url]; + throw e; + } + return mod.exports; + } + throw new Error("[perry-electron] require('" + name + "') is not available in the renderer (no Node builtins in the system webview)"); + }; + } + window.require = makeRequire(window.location ? window.location.href : ""); + + window.electron = electron; // also expose directly for apps using window.electron + window.__perryIpcRenderer = ipcRenderer; + + // Diagnostic heartbeat so the main process can confirm the bridge installed + // at document-start and the message channel is live. + postToNative({ kind: "console", id: 0, channel: "debug", args: ["[perry-bridge] installed; channel=" + (!!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.perry)) ] }); +})(); +`; diff --git a/packages/perry-react/LICENSE b/packages/perry-react/LICENSE new file mode 100644 index 0000000000..c60e2b25ee --- /dev/null +++ b/packages/perry-react/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Perry Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/perry-react/README.md b/packages/perry-react/README.md new file mode 100644 index 0000000000..ded29372f3 --- /dev/null +++ b/packages/perry-react/README.md @@ -0,0 +1,81 @@ +# @perryts/react + +TypeScript types for [Perry](https://github.com/PerryTS/perry)'s React support. + +Perry compiles React/JSX to **native widgets** — standard React components +render to a native macOS/iOS/Android app, with no DOM and no browser. The +runtime lives in [`perry-react`](https://github.com/PerryTS/react) and +[`perry-react-dom`](https://github.com/PerryTS/react-dom); **this package is the +type layer** for it. + +It does two things: + +1. **Bundles the React types.** It depends on `@types/react` and + `@types/react-dom`, so a single `npm i -D @perryts/react` gives you working + React types — no need to remember the `@types/*` packages separately. + +2. **Types Perry's `createRoot` extension.** `@types/react-dom` only knows the + DOM-container form `createRoot(domNode)`. Perry has no DOM, so it extends + `createRoot` to take `null` plus native window options. This package augments + `react-dom/client` with that overload, so the call below type-checks instead + of erroring on the `null` container / extra argument. + +## Install + +```bash +npm install -D @perryts/react +# react / react-dom themselves are peer deps (the Perry runtime provides them): +npm install react react-dom +``` + +## Use + +Add it to your tsconfig so the `createRoot` augmentation is always in scope: + +```jsonc +// tsconfig.json +{ + "compilerOptions": { + "jsx": "react-jsx", + "types": ["@perryts/react"] + } +} +``` + +…or import it once anywhere in your project: + +```tsx +import '@perryts/react'; +import { useState } from 'react'; +import { createRoot } from 'react-dom/client'; + +function Counter() { + const [n, setN] = useState(0); + return ; +} + +// Perry overload: null container + native window options — now fully typed. +createRoot(null, { title: 'Counter', width: 300, height: 200 }).render(); +``` + +### `PerryRootOptions` + +The second argument to `createRoot(null, …)`. `title` / `width` / `height` are +the documented core; the rest mirror the common `perry/ui` window knobs and are +optional: + +```ts +import type { PerryRootOptions } from '@perryts/react'; + +const opts: PerryRootOptions = { + title: 'My App', + width: 900, + height: 640, + minWidth: 400, + resizable: true, +}; +``` + +## License + +MIT diff --git a/packages/perry-react/index.d.ts b/packages/perry-react/index.d.ts new file mode 100644 index 0000000000..5cde8f3c8a --- /dev/null +++ b/packages/perry-react/index.d.ts @@ -0,0 +1,76 @@ +/** + * `@perryts/react` — TypeScript types for Perry's React support. + * + * Two jobs: + * + * 1. **Bundle the React types.** This package depends on `@types/react` and + * `@types/react-dom`, so installing `@perryts/react` is enough — you don't + * have to remember to add the `@types/*` packages by hand. + * + * 2. **Type Perry's `createRoot` extension.** This file augments + * `react-dom/client` with the native-window overload + * `createRoot(null, { title, width, height })`. + * + * Activate it by adding the package to your tsconfig `types` (recommended, so + * the augmentation is always in scope): + * + * ```jsonc + * // tsconfig.json + * { "compilerOptions": { "jsx": "react-jsx", "types": ["@perryts/react"] } } + * ``` + * + * …or import it once anywhere in your project: + * + * ```ts + * import '@perryts/react'; + * import { createRoot } from 'react-dom/client'; + * createRoot(null, { title: 'Counter', width: 300, height: 200 }).render(); + * ``` + * + * NOTE: the `createRoot` augmentation only takes effect when the file that + * holds it is loaded directly (via `types: [...]`, a direct `import`, or a + * `/// `). TypeScript drops module augmentations reached only + * indirectly through a re-export from another `.d.ts`, so the augmentation + * lives here in the package entry rather than in a re-exported sub-module. + */ + +/// +/// + +import type { Root } from 'react-dom/client'; + +/** + * Options for the native window that hosts a Perry React root, passed as the + * second argument to `createRoot(null, options)`. + * + * `title` / `width` / `height` are the documented core; the rest mirror the + * common `perry/ui` window knobs and are all optional. + */ +export interface PerryRootOptions { + /** Window title shown in the title bar. */ + title?: string; + /** Initial content width, in points. */ + width?: number; + /** Initial content height, in points. */ + height?: number; + /** Minimum content width the user can resize to, in points. */ + minWidth?: number; + /** Minimum content height the user can resize to, in points. */ + minHeight?: number; + /** Whether the user can resize the window. Defaults to `true`. */ + resizable?: boolean; + /** Initial window x position, in screen points. Centered if omitted. */ + x?: number; + /** Initial window y position, in screen points. Centered if omitted. */ + y?: number; +} + +declare module 'react-dom/client' { + /** + * Perry native-window overload: pass `null` (no DOM container) plus the + * native window options. Standard `react-dom`'s DOM-container overloads stay + * available — this only adds the `null` form. Returns the same `Root` so + * `.render()` / `.unmount()` work as usual. + */ + export function createRoot(container: null | undefined, options: PerryRootOptions): Root; +} diff --git a/packages/perry-react/package.json b/packages/perry-react/package.json new file mode 100644 index 0000000000..78849e5cf7 --- /dev/null +++ b/packages/perry-react/package.json @@ -0,0 +1,55 @@ +{ + "name": "@perryts/react", + "version": "0.1.0", + "description": "TypeScript types for Perry's React support — bundles @types/react / @types/react-dom and augments react-dom/client's createRoot with Perry's native-window overload (createRoot(null, { title, width, height })).", + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts" + }, + "./package.json": "./package.json" + }, + "files": [ + "index.d.ts", + "README.md", + "LICENSE" + ], + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "keywords": [ + "perry", + "react", + "react-dom", + "createRoot", + "native", + "types", + "typescript" + ], + "author": "Perry Contributors", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/PerryTS/perry.git", + "directory": "packages/perry-react" + }, + "homepage": "https://github.com/PerryTS/perry/tree/main/packages/perry-react#readme", + "bugs": { + "url": "https://github.com/PerryTS/perry/issues" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react": { "optional": true }, + "react-dom": { "optional": true } + }, + "dependencies": { + "@types/react": "^18.3.0 || ^19.0.0", + "@types/react-dom": "^18.3.0 || ^19.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/perry-react/tsconfig.json b/packages/perry-react/tsconfig.json new file mode 100644 index 0000000000..3d53dba4cd --- /dev/null +++ b/packages/perry-react/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["index.d.ts"] +} diff --git a/types/perry/ui/index.d.ts b/types/perry/ui/index.d.ts index 8ee0aab517..3ad20da95d 100644 --- a/types/perry/ui/index.d.ts +++ b/types/perry/ui/index.d.ts @@ -706,6 +706,17 @@ export function webviewCanGoBack(handle: Widget): number; export function webviewEvaluateJs(handle: Widget, js: string, callback: (result: string) => void): void; /** Wipe the WebView's cookies / local storage / IndexedDB. Use after auth. */ export function webviewClearCookies(handle: Widget): void; +/** + * Electron-compat IPC (renderer → main). Register a closure invoked with the + * JSON string the page posts via `window.webkit.messageHandlers.perry.postMessage(...)`. + * Reply with `webviewEvaluateJs(...)` (main → renderer). Backs `ipcMain`. + */ +export function webviewSetOnMessage(handle: Widget, onMessage: (json: string) => void): void; +/** + * Inject a document-start user script into all frames (the IPC bridge runtime + + * the app's preload). Call before `webviewLoadUrl` so it applies to that load. + */ +export function webviewAddUserScript(handle: Widget, source: string): void; /** VStack with built-in edge insets. */ export function VStackWithInsets(spacing: number, top: number, left: number, bottom: number, right: number): Widget; @@ -1786,6 +1797,24 @@ export function registerGlobalHotkey( */ export function onTerminate(callback: () => void): void; +/** + * Electron-compat body-less event loop. Sets up NSApplication + the timer pump, + * invokes `onReady` (which resolves `app.whenReady()`), then blocks. Windows are + * created dynamically afterward via `Window(...)`. Backs `electron`'s app loop. + */ +export function appRunLoop(onReady: () => void): void; + +/** Electron-compat `app.quit()` — terminate the application. */ +export function appQuit(): void; + +/** + * Electron-compat: register the top-level UI event loop without blocking. The + * generated `main` enters it (via the runtime's loop-takeover hook) AFTER the + * user's top-level code runs, so windows created from `app.whenReady().then(...)` + * composite correctly. Prefer this over `appRunLoop` for the `app` shell. + */ +export function appRequestLoop(onReady: () => void): void; + /** * Register a callback to run when the app becomes the frontmost app * (initial launch, dock click, cmd-tab). Runs once per activation. Use to