Skip to content

Commit 02a8fdf

Browse files
committed
feat(geisterhand): #185 Phase D step 1 — live inspector UI at localhost:7676 (v0.5.349)
Land the read-only inspector UI as the foundation for Phase D devtools. - inspector_ui/index.html: dependency-free single-page vanilla-JS app embedded via include_str!; left-column tree view from /widgets?tree=true, right-column per-widget detail (frame, value, raw JSON), 1.5s auto-refresh with pause/resume, fire-onClick action button. - server.rs: 3 new route arms (/, /inspector, /index.html) serve INSPECTOR_HTML with text/html content-type. No new server-side endpoints needed — read-only view uses the existing /widgets?tree=true, /value/:h, /click/:h JSON surface. - codegen.rs: entry-module main() prelude emits perry_geisterhand_start(port) when needs_geisterhand. The call site has a critical secondary purpose: pinning the geisterhand server module against macOS lazy-load -dead_strip. Without it the linker silently eliminates INSPECTOR_HTML as unreferenced rodata. Verified end-to-end: `perry compile X.ts -o /tmp/X --enable-geisterhand` produces a 1.3 MB binary; launching prints `[geisterhand] listening on http://127.0.0.1:7676`; `curl localhost:7676/` returns the inspector page (200 OK, text/html); `curl localhost:7676/widgets?tree=true` returns a JSON array. Phase D steps 2 (live style edit via POST /style/:h) and 3 (cross-platform screenshot capture) follow as separate PRs.
1 parent 0c4333d commit 02a8fdf

6 files changed

Lines changed: 427 additions & 31 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
88

99
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.
1010

11-
**Current Version:** 0.5.348
11+
**Current Version:** 0.5.349
1212

1313
## TypeScript Parity Status
1414

@@ -149,6 +149,7 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re
149149

150150
Keep entries to 1-2 lines max. Full details in CHANGELOG.md.
151151

152+
- **v0.5.349** — Issue #185 Phase D step 1: live inspector UI served at `http://localhost:7676/` by the embedded geisterhand HTTP server. Three pieces: (1) `crates/perry-ui-geisterhand/src/inspector_ui/index.html` — single-page, dependency-free vanilla-JS app (~280 lines) embedded via `include_str!` so it ships inside the binary with zero external assets; renders a left-column tree view of registered widgets (kind, label, meta), right-column per-widget detail (frame, hidden/enabled, live `/value/:h`, raw JSON), auto-refresh every 1.5s with a pause/resume control, and a "fire onClick" button hitting the existing `POST /click/:h`. Color scheme is dark-mode-monospace (matches devtools convention). (2) `crates/perry-ui-geisterhand/src/server.rs` — three new route arms `(Get, "/")`, `(Get, "/inspector")`, `(Get, "/index.html")` all serve `INSPECTOR_HTML` with a new `html_header()` helper. No new server-side endpoints needed for the read-only view — uses the existing `/widgets?tree=true`, `/value/:h`, `/click/:h` JSON surface. (3) `crates/perry-codegen/src/codegen.rs` — entry-module main() prelude now emits a `perry_geisterhand_start(port)` call when `cross_module.needs_geisterhand` is true (auto-flow from `--enable-geisterhand` / `--geisterhand-port`). The call site has a critical secondary purpose beyond just starting the server: it pins the geisterhand server module against macOS's lazy-load `-dead_strip`, so without it the linker silently eliminates `INSPECTOR_HTML` (the `include_str!`-embedded page) as unreferenced rodata and hitting `/` returns "not found". Diagnosed live: pre-fix `nm /tmp/visual_test_macos | grep geisterhand` showed zero symbols and `strings .. | grep "Perry Inspector"` was empty; post-fix the listening message prints, the symbol is linked, and `curl localhost:7676/` returns the full HTML. New fields `needs_geisterhand` / `geisterhand_port` on `CrossModuleCtx` (threaded from `CompileOptions`); the function declaration must be emitted BEFORE `main` claims `&mut llmod` (Rust borrow checker rejects calling `llmod.declare_function` while `main` holds the mutable borrow), so the declare/call are split. Verified end-to-end: `perry compile inspector_test.ts -o /tmp/it --enable-geisterhand` produces a 1.3 MB binary; launching prints `[geisterhand] listening on http://127.0.0.1:7676`; `curl localhost:7676/` returns the inspector page; `curl localhost:7676/widgets?tree=true` returns a JSON array. The widget registry is empty in the default macOS build because `perry-ui-macos` ships without `--features geisterhand` enabled and the `register` calls in widget creation are gated behind that cfg — separate pre-existing limitation, not introduced here. Phase D step 2 (live edit via a new `POST /style/:h` endpoint that pushes a StyleProps object through the same per-key setters Phase C uses) and step 3 (cross-platform screenshot capture parity beyond the existing watchOS path) follow as separate PRs.
152153
- **v0.5.348** — Docs for the v0.5.347-merged `perry/updater` subsystem (PR #224). New `docs/src/updater/overview.md` covers the trust model (Ed25519 over the SHA-256 digest, not the file bytes), manifest schema, install + sentinel-rollback flow, low-level `perry/updater` ambients, and the `@perry/updater` wrapper (`checkForUpdate` / `initUpdater` / `markHealthy`); new `Auto-Update` section in SUMMARY.md slotted between Internationalization and System APIs. New `docs/examples/updater/snippets.ts` (`run: false` — updater fetches a manifest, replaces the running binary, and detached-relaunches, none of which work under the doc-tests sandbox; compile-link still gates API drift on the `perry/updater` ambient and the `@perry/updater` wrapper) with 9 ANCHOR blocks (`imports-low-level` / `imports-high-level` / `compare-versions` / `verify-file` / `install-and-relaunch` / `rollback` / `paths` / `sentinel-manual` / `high-level-check` / `high-level-init` / `manifest-shape`) extracted by the new doc page. Per-OS sentinel path table and platform-triple table (`darwin-aarch64` / `windows-x86_64` / `linux-x86_64` etc.) documented so manifest authors can mirror the canonical Rust-style form the wrapper picks against. Codegen / runtime / `@perry/updater` source unchanged from v0.5.347 — pure docs follow-up.
153154
- **v0.5.347** — Closes #210 (4 of 5): wire Windows styling stubs for `text.decoration` + `widget.opacity` + `widget.border_color` + `widget.border_width`. **Decoration** (`crates/perry-ui-windows/src/widgets/text.rs::set_decoration`) — new `apply_decoration` reads the widget's current HFONT via `GetObjectW`, mutates `lfUnderline` (decoration=1) / `lfStrikeOut` (decoration=2) on the LOGFONTW, recreates via `CreateFontIndirectW`, and re-emits through the existing `apply_font` lifecycle (which handles old-HFONT `DeleteObject`). Falls back to a fresh "Segoe UI 14/400" if no HFONT is set yet so calling `set_decoration` before `set_font_*` still works. **Opacity** (`mod.rs::set_opacity`) — new `apply_opacity` adds `WS_EX_LAYERED` to the child HWND's extended style if not yet set, then applies the alpha (clamped 0-1, ×255) via `SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA)`. Per-child `WS_EX_LAYERED` works on Windows 8+ (Perry's minimum). **Borders** (`mod.rs::{set_border_color,set_border_width}`) — new `ensure_border_subclass` lazily installs a one-time `SetWindowSubclass` per HWND (id=`0x70_72_72_79`, idempotent via thread_local `BORDER_SUBCLASSED` HashSet, handle passed via `dwRefData`). The subclass's WM_PAINT handler calls `DefSubclassProc` first so the wrapped control renders normally, then `GetDC` + `CreatePen(PS_SOLID, w, COLORREF(...))` + `SelectObject` + `Rectangle` to draw a colored outline at the client-rect bounds. Defaults match CSS: missing color → black 1.0 alpha, missing width → 1px (so calling either setter alone produces a visible 1px black border); width clamped to 1 minimum, with explicit 0 producing no-op. Both setters share the joint `BORDER_STATE` so the paint reads color + width together. **Matrix** (`crates/perry-ui/src/styling_matrix.rs`) flips Windows column on the 4 wired rows from `Stub` → `Wired`. Auto-regenerated `docs/src/ui/styling-matrix.md` summary line now shows Windows at **42 Wired / 1 Stub** (down from 5 stubs); only `widget.shadow` remains as `Stub` because real CSS-style shadow rendering on Win32 child controls needs DirectComposition (`IDCompositionVisual` + `DropShadowEffect`) — a multi-day refactor that's its own follow-up. Cross-compile from macOS to Windows isn't possible without the SDK, so the Win32 calls (`SetWindowSubclass`, `DefSubclassProc`, `GetWindowLongW`, `SetWindowLongW`, `SetLayeredWindowAttributes`, `GetObjectW`, `CreateFontIndirectW`, `CreatePen`, `Rectangle`) are validated by inspection against existing usage patterns in the codebase (`canvas.rs::Stroke` for the pen + `Rectangle` shape, `text.rs::set_font_family` for the LOGFONT round-trip pattern); CI's windows-2022 runner verifies the link. Pre-existing 4 host-build errors in `create_font_with_family` (missing `#[cfg(target_os="windows")]`) unchanged.
154155
- **v0.5.346** — Issue #185 follow-up: fix iOS / tvOS / visionOS sim crash on `Button("X", () => {}, { color: "white" })`. Same shape as the v0.5.313 macOS bug, just unwired on the three UIKit platforms. `apply_inline_style` (`crates/perry-codegen/src/lower_call.rs`) routes every `color: ...` inline-style prop through `text_set_color`, comment-as-spec'd "no-op on widgets that ignore it." But `crates/perry-ui-{ios,tvos,visionos}/src/widgets/text.rs::set_color` did an unchecked `setTextColor:` on the receiver — and on UIKit `UIButton` doesn't implement that selector (UIButton uses `setTitleColor:forState:`). On a styled Button the call raised ObjC `unrecognized selector`, objc2 panicked across the FFI boundary, the panic was non-unwinding (`extern "C"`) → process abort. Crash trace from the iOS sim: `panic_cannot_unwind → perry_ui_text_set_color → main`. Fix mirrors the macOS v0.5.313 class-probe pattern: in each platform's `text::set_color`, probe the widget's runtime class via `isKindOfClass:` — `UIButton` routes to `super::button::set_text_color` (which exists on all three platforms and does `setTitleColor:forState:UIControlStateNormal=0`), `UILabel` follows the original `setTextColor:` path, anything else silently no-ops (matching the codegen comment's stated intent). New regression: `docs/examples/ui/styling/visual_test.ts` (the v0.5.324 comprehensive test app) now launches end-to-end on iPhone 16 sim from a fresh boot — pre-fix died at the Buttons section's "Styled" cell with `color: "white"`, post-fix renders all 13 sections cleanly. tvOS / visionOS sim builds need nightly Rust + `-Zbuild-std` (tier-3 targets), deferred for runtime-launch verification but the patch follows the iOS-validated shape verbatim and the macOS twin has been in production since v0.5.313.

Cargo.lock

Lines changed: 29 additions & 29 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ opt-level = "s" # Optimize for size in stdlib
113113
opt-level = 3
114114

115115
[workspace.package]
116-
version = "0.5.348"
116+
version = "0.5.349"
117117
edition = "2021"
118118
license = "MIT"
119119
repository = "https://github.com/PerryTS/perry"

crates/perry-codegen/src/codegen.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,14 @@ pub(crate) struct CrossModuleCtx {
245245
/// `stdlib` feature on when perry-stdlib depends on it, which
246246
/// excludes the cfg-gated stub in `perry-runtime/src/stdlib_stubs.rs`).
247247
pub needs_stdlib: bool,
248+
/// Whether the project needs the Geisterhand inspector linked in.
249+
/// Threaded through from `CompileOptions::needs_geisterhand` so the
250+
/// entry-module init prelude can emit the `perry_geisterhand_start`
251+
/// call site (which also pins the geisterhand server module against
252+
/// `-dead_strip`, keeping `INSPECTOR_HTML` referenced).
253+
pub needs_geisterhand: bool,
254+
/// Port the Geisterhand inspector listens on when `needs_geisterhand`.
255+
pub geisterhand_port: u16,
248256
/// Compile-time constant values for module globals. Maps LocalId → f64
249257
/// for variables like `__platform__` whose value is known at compile time.
250258
/// Used by `lower_if` to constant-fold platform checks and skip emitting
@@ -615,6 +623,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
615623
}),
616624
imported_vars: opts.imported_vars,
617625
needs_stdlib: opts.needs_stdlib,
626+
needs_geisterhand: opts.needs_geisterhand,
627+
geisterhand_port: opts.geisterhand_port,
618628
compile_time_constants,
619629
clamp3_functions: hir.functions.iter()
620630
.filter_map(|f| crate::collectors::detect_clamp3(f).map(|_| f.id))
@@ -2424,6 +2434,13 @@ fn compile_module_entry(
24242434
// and the first GC cycle after connect() would free them (issue #54).
24252435
let ic_base = llmod.ic_counter;
24262436
let buffer_alias_base = llmod.buffer_alias_counter;
2437+
// Declare `perry_geisterhand_start` BEFORE `main` is created — once
2438+
// `main` holds a mutable borrow on `llmod`, no further
2439+
// `llmod.declare_function` calls are allowed. Inline (not in
2440+
// `runtime_decls`) because most builds don't link geisterhand.
2441+
if cross_module.needs_geisterhand && !is_dylib {
2442+
llmod.declare_function("perry_geisterhand_start", VOID, &[I32]);
2443+
}
24272444
let main = if is_dylib {
24282445
llmod.define_function("perry_module_init", VOID, vec![])
24292446
} else {
@@ -2445,6 +2462,25 @@ fn compile_module_entry(
24452462
if cross_module.needs_stdlib {
24462463
blk.call_void("js_stdlib_init_dispatch", &[]);
24472464
}
2465+
// Start the Geisterhand HTTP inspector if requested. The
2466+
// port comes from `--geisterhand-port` (default 7676). Calling
2467+
// `perry_geisterhand_start` here also pins the geisterhand
2468+
// server module against macOS's lazy-load `-dead_strip`, so
2469+
// the inspector_ui HTML embedded via `include_str!` makes it
2470+
// into the final binary instead of being eliminated as
2471+
// unreferenced rodata.
2472+
}
2473+
if !is_dylib && cross_module.needs_geisterhand {
2474+
// Function was declared above (before `main` claimed
2475+
// `&mut llmod`). Lifetime: `port_str` lives for the body of
2476+
// this block, long enough for `call_void` to consume the
2477+
// `&str` reference.
2478+
let port_str = cross_module.geisterhand_port.to_string();
2479+
let blk = main.block_mut(0).unwrap();
2480+
blk.call_void("perry_geisterhand_start", &[(I32, port_str.as_str())]);
2481+
}
2482+
{
2483+
let blk = main.block_mut(0).unwrap();
24482484
// Entry module's own string pool first.
24492485
blk.call_void(&strings_init_name, &[]);
24502486
// Then every non-entry module's init in order. Each

0 commit comments

Comments
 (0)