feat(shell): switch openpencil-shell-web to wasm32-unknown-unknown C-hard pipeline

Lights up the Phase A WebShell on the C-hard pipeline (vendor/skia-
safe-op + crates/wasm-libc-shim, wired in the previous two commits)
so `cargo build --target wasm32-unknown-unknown --features skia`
followed by `wasm-bindgen --target web` produces a browser-loadable
ES module with 0 env.* imports.

What's added:
  - WebBackend (src/backend/mod.rs): impl RenderBackend over a
    skia-safe raster N32_PREMUL surface; presents each frame to the
    host <canvas> via image_snapshot → read_pixels → ImageData →
    put_image_data. end_frame surfaces present errors via
    last_present_error / take_present_error so a stale failure does
    not leak into a subsequent successful frame
  - skia_wasm.rs: thin make_raster_surface helper so swapping in a
    GPU GrContext (Phase A round 2) is a self-contained change
  - mount(canvas_id) entry: locates the host <canvas>, builds a
    WebBackend, paints the Phase A red-rect demo synchronously,
    propagates any present error as a JsValue exception
  - smoke/step-1b.html: manual smoke harness that mounts the shell
    and surfaces a structured diagnostic (with regression-mode
    LinkError messaging + rebuild instructions) if loading fails
  - extern crate wasm_libc_shim as _; in lib.rs to keep the shim's
    no_mangle symbols from being dead-code-eliminated
  - .gitignore for wasm-bindgen pkg/ output

Cargo.toml feature wiring:
  - default = ["web"] keeps the kickoff §1.2 wasm32-clean compile
    guard CI green (stub mount, no skia)
  - skia = ["dep:skia-safe", "wasm-libc-shim"] opts into the real
    WebBackend + raster paint loop; the shim dep is target-gated
    so only wasm32-unknown-unknown actually pulls it in
  - wasm-bindgen = "=0.2.117" pinned (last release that compiles
    on Rust 1.85; bump alongside the toolchain in a future commit)

Verified end-to-end:
  - `cargo build … --features skia --release` green
  - `wasm-bindgen --target web` produces ../pkg/*.{js,_bg.wasm}
  - WebAssembly.Module.imports() returns 22 imports, all from
    ./openpencil_shell_web_bg.js; 0 env.* imports
  - post `wasm-opt -Oz`: 1542 KiB raw / 599 KiB gzip — within
    spec §6 ceiling (≤ 1024 KiB gzip)
  - the kickoff §1.2 wasm32-clean compile guard still passes
    (`cargo check … --no-default-features --features web`)

Browser-side manual smoke (Phase E) is still TODO; the bundle is
structurally LinkError-free but a human still needs to confirm the
red rect actually paints in Safari / Chrome / Firefox before the
sub-phase can be marked complete.

Step 1b §3.2 P0.5B Run path, sub-phase C-hard.2.
This commit is contained in:
Kayshen-X 2026-05-09 21:08:00 +08:00
parent 8ac685ff84
commit 5af1674f6b
6 changed files with 520 additions and 17 deletions

View file

@ -0,0 +1,2 @@
# wasm-bindgen / wasm-pack output (regenerated each build).
pkg/

View file

@ -4,7 +4,7 @@ version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "OpenPencil shell — wasm32 web bundle entry (CanvasKit + accesskit_web + OPFS)"
description = "OpenPencil shell — wasm32-unknown-unknown web bundle entry (Step 1b §1.2, C-hard pipeline)"
[lib]
name = "openpencil_shell_web"
@ -13,13 +13,65 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
openpencil-shell-core = { path = "../openpencil-shell-core", version = "0.1.0" }
wasm-bindgen = "0.2"
# Step 1b §3.6: handwritten DOM mirror consumes accesskit::TreeUpdate (0.24).
accesskit = "0.24"
console_error_panic_hook = "0.1"
js-sys = "0.3"
# Pin to 0.2.117 — last wasm-bindgen-cli release compatible with the
# workspace's Rust 1.85 toolchain. Newer 0.2.120+ requires rustc 1.86.
# When the workspace bumps Rust, raise this bound.
wasm-bindgen = "=0.2.117"
# Step 1b §2.2 (post C-hard.2 lock-in 2026-05-09): wasm32-unknown-unknown
# via the vendor/skia-safe-op fork + crates/wasm-libc-shim. Optional dep
# behind the `skia` feature so `cargo check --target wasm32-unknown-
# unknown --no-default-features --features web` (the kickoff §1.2
# wasm32-clean CI baseline) still compile-checks without pulling skia
# — that path uses the stub mount entry. Phase A and onward opt in via
# `--features skia` to get the real WebBackend + raster paint loop.
[dependencies.skia-safe]
version = "0.97.0"
default-features = false
features = ["binary-cache", "textlayout"]
optional = true
# C-hard.2 libc/libcxx/libm shim — only linked when targeting
# wasm32-unknown-unknown with the `skia` feature on. The crate's lib.rs
# guards every symbol with `cfg(all(target_arch = "wasm32", target_os
# = "unknown"))` so native builds (where this would conflict with the
# host C runtime) link an empty crate.
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
wasm-libc-shim = { path = "../wasm-libc-shim", optional = true }
[dependencies.web-sys]
version = "0.3"
features = ["console", "Document", "Element", "HtmlCanvasElement", "Window"]
features = [
"CanvasRenderingContext2d",
"CompositionEvent",
"console",
"Document",
"Element",
"Event",
"EventTarget",
"FocusEvent",
"HtmlCanvasElement",
"HtmlElement",
"ImageData",
"KeyboardEvent",
"PointerEvent",
"WheelEvent",
"Window",
]
[features]
# `web` keeps the kickoff §1.2 wasm32-unknown-unknown CI baseline green —
# the crate compile-checks as a wasm32-clean stub without pulling skia.
default = ["web"]
web = []
# `skia` opts into the Phase A WebBackend (skia-safe + raster surface).
# When the cargo target is wasm32-unknown-unknown the build also links
# the libc/libcxx/libm shim crate so the bundle is runtime-loadable.
# Native targets resolve the target-conditional shim dep to nothing.
skia = ["dep:skia-safe", "wasm-libc-shim"]
# Internal feature toggled on by `skia` to flip the cfg-target dep.
wasm-libc-shim = ["dep:wasm-libc-shim"]

View file

@ -0,0 +1,114 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Step 1b WebShell Smoke</title>
<style>
html,
body {
margin: 0;
height: 100%;
background: #fafafa;
font:
14px/1.5 -apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
#status {
padding: 16px;
}
#status pre {
background: #fff;
border: 1px solid #ccc;
padding: 12px;
white-space: pre-wrap;
}
canvas {
display: block;
width: 960px;
height: 640px;
border: 1px solid #111;
}
</style>
</head>
<body>
<div id="status"></div>
<canvas id="op" width="960" height="640"></canvas>
<script type="module">
// Phase A smoke harness for the WebShell red-rect demo.
//
// CURRENT STATE (post C-hard.2, 2026-05-09):
// - Skia C++ + skia-bindings + skia-safe + shell-web build to
// wasm32-unknown-unknown via the vendor/skia-safe-op fork
// (build_support/platform/wasm_unknown.rs).
// - The libc / libcxx / libm shim crate (crates/wasm-libc-shim)
// resolves the previously-missing env.* imports. Verified via
// `WebAssembly.Module.imports()`: 22 imports, all from
// `./openpencil_shell_web_bg.js`; 0 env.* imports.
// - The bundle is structurally LinkError-free; a LinkError here
// is now a REGRESSION signal, not the expected state.
//
// What this harness verifies:
// - module loads (LinkError ⇒ regression in libc shim or fork)
// - mount() returns a live WebShell
// - paint_phase_a draws a red rect via skia raster + put_image_data
//
// What this harness does NOT verify (Phase E manual smoke):
// - the red rect is actually visible to a human in a browser tab
// - latency is within the 16ms / 60fps budget
// - DPI scale + canvas resize behave correctly
//
// Round 2 C-R2-1 fix: assign the returned WebShell to
// `window.__opShell` so the GC keeps it alive for the page
// lifetime; the shell may hold closures into the wasm module.
const status = document.getElementById('status');
const log = (cls, msg) => {
const block = document.createElement('pre');
block.className = cls;
block.textContent = msg;
status.appendChild(block);
};
try {
const mod = await import('../pkg/openpencil_shell_web.js');
await mod.default();
window.__opShell = mod.mount('op');
log('ok', 'WebShell mounted; canvas should show centered red rect.');
} catch (e) {
const isLinkError =
e instanceof WebAssembly.LinkError ||
(e && e.constructor && e.constructor.name === 'LinkError') ||
(e && e.message && e.message.startsWith('WebAssembly.instantiate()'));
const header = isLinkError
? 'WebShell wasm bundle failed to link — REGRESSION (post C-hard.2 the bundle is supposed to be LinkError-free).'
: 'WebShell loader could not be imported.';
log(
'error',
[
header,
'',
'Expected file: ../pkg/openpencil_shell_web.js',
'',
'How to rebuild the bundle (when the file is missing or stale):',
' 1. brew install emscripten # 5.0.7 + EMSDK shim',
' 2. mkdir -p ~/.emsdk/upstream && \\',
' ln -sfn /opt/homebrew/opt/emscripten/libexec ~/.emsdk/upstream/emscripten',
' 3. EMSDK="$HOME/.emsdk" cargo build -p openpencil-shell-web \\',
' --target wasm32-unknown-unknown --features skia --release',
' 4. (one-time) ln -sf libfoo.wasm.a libfoo.a in target/.../skia-bindings-*/out/skia/',
' 5. wasm-bindgen --target web \\',
' --out-dir crates/openpencil-shell-web/pkg \\',
' target/wasm32-unknown-unknown/release/openpencil_shell_web.wasm',
'',
'A LinkError after a clean rebuild means a new env.* import',
'snuck back in (skia upgrade, new shim gap, etc). Inspect via',
' wasm-objdump --details --section=Import ../pkg/openpencil_shell_web_bg.wasm',
'and add the missing symbol to crates/wasm-libc-shim/src/imp.rs.',
'',
'Underlying error: ' + (e && e.message ? e.message : String(e)),
].join('\n'),
);
}
</script>
</body>
</html>

View file

@ -0,0 +1,199 @@
//! `WebBackend` — Step 1b shell-web RenderBackend implementation.
//!
//! Step 1b §2.2 (post C-hard.2 lock-in 2026-05-09): wasm32-unknown-unknown
//! via the vendor/skia-safe-op fork + crates/wasm-libc-shim. This backend
//! draws into a skia-safe raster surface (N32_PREMUL) and presents the
//! snapshot to the host `<canvas>` via `CanvasRenderingContext2d::
//! put_image_data`. Phase A target = single red rectangle on a 960×640
//! canvas; Phase B+ adds widget tree dispatch through shell-core's
//! `RenderBackend` trait.
//!
//! Skia GL backend (WebGL2 via Skia GL) is deferred to Phase A round 2 once
//! the raster path is proven; raster fallback contract is documented in spec
//! §5.3.
pub mod skia_wasm;
use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend, TextLayout};
use wasm_bindgen::prelude::*;
use wasm_bindgen::Clamped;
use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData};
pub struct WebBackend {
surface: skia_safe::Surface,
canvas_element: HtmlCanvasElement,
pixels: Vec<u8>,
width: u32,
height: u32,
dpi_scale: f32,
/// Most recent present() error captured via the infallible
/// `end_frame` trait method. Callers that need to propagate errors
/// (e.g. `WebShell::mount`) call `take_present_error()` after the
/// paint cycle.
last_present_error: Option<JsValue>,
}
impl WebBackend {
pub fn new(canvas: HtmlCanvasElement) -> Result<Self, JsValue> {
let width = canvas.width().max(1);
let height = canvas.height().max(1);
let surface = skia_wasm::make_raster_surface(width, height)?;
let pixels = vec![0u8; (width as usize) * (height as usize) * 4];
Ok(Self {
surface,
canvas_element: canvas,
pixels,
width,
height,
dpi_scale: 1.0,
last_present_error: None,
})
}
/// Drain and return the most recent `end_frame` present error, if any.
/// Cleared on read so subsequent frames do not re-surface the same
/// failure. Use in mount entry / Phase E manual smoke verification.
pub fn take_present_error(&mut self) -> Option<JsValue> {
self.last_present_error.take()
}
/// Snapshot the raster surface and `put_image_data` it onto the host
/// `<canvas>`'s 2D context. Spec §5.3 raster-fallback contract:
/// N32_PREMUL surface, RGBA8888 + Unpremul read, full-frame copy each
/// frame. Phase A measures latency; >16ms (60fps budget) → revisit GL
/// backend in Phase A round 2.
pub fn present(&mut self) -> Result<(), JsValue> {
let image = self.surface.image_snapshot();
let info = skia_safe::ImageInfo::new(
(self.width as i32, self.height as i32),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let ok = image.read_pixels(
&info,
self.pixels.as_mut_slice(),
(self.width as usize) * 4,
(0, 0),
skia_safe::image::CachingHint::Allow,
);
if !ok {
return Err(JsValue::from_str("WebBackend: read_pixels failed"));
}
let image_data = ImageData::new_with_u8_clamped_array_and_sh(
Clamped(self.pixels.as_mut_slice()),
self.width,
self.height,
)?;
let context = self
.canvas_element
.get_context("2d")?
.ok_or_else(|| JsValue::from_str("WebBackend: 2d context unavailable"))?
.dyn_into::<CanvasRenderingContext2d>()?;
context.put_image_data(&image_data, 0.0, 0.0)
}
}
/// Implementation of shell-core `RenderBackend` over the raster surface.
/// Phase A wires `fill_rect` + `begin_frame` / `end_frame` only; the
/// remaining widget-facing methods are conservative stubs that Phase B+
/// will fill in alongside the shell-core widget set.
impl RenderBackend for WebBackend {
fn begin_frame(&mut self) {
// Raster surface starts each frame with whatever pixels the previous
// present left behind; widgets that need a clean canvas should
// explicitly clear via `fill_rect` covering the viewport.
}
fn end_frame(&mut self) {
// RenderBackend trait is infallible by design (native + web share
// the same shape). end_frame's fallible work is `present()` →
// ImageData round-trip, which CAN fail (read_pixels / 2d-context /
// put_image_data). We surface the latest result in
// `last_present_error` so the caller can propagate it; both the
// success and failure cases are stored, so a stale prior failure
// does NOT bleed into a subsequent frame's status.
// Callers that need real propagation (e.g. WebShell::mount) check
// `take_present_error()` after each end_frame.
match self.present() {
Ok(()) => {
self.last_present_error = None;
}
Err(e) => {
web_sys::console::error_1(&e);
self.last_present_error = Some(e);
}
}
}
fn fill_rect(&mut self, rect: Rect, color: Color) {
let paint = skia_safe::Paint::new(
skia_safe::Color4f::new(color.r, color.g, color.b, color.a),
None,
);
self.surface.canvas().draw_rect(
skia_safe::Rect::from_xywh(rect.origin.x, rect.origin.y, rect.size.x, rect.size.y),
&paint,
);
}
fn stroke_rect(&mut self, rect: Rect, color: Color, width: f32) {
let mut paint = skia_safe::Paint::new(
skia_safe::Color4f::new(color.r, color.g, color.b, color.a),
None,
);
paint.set_stroke(true);
paint.set_stroke_width(width);
paint.set_anti_alias(true);
self.surface.canvas().draw_rect(
skia_safe::Rect::from_xywh(rect.origin.x, rect.origin.y, rect.size.x, rect.size.y),
&paint,
);
}
fn draw_text(&mut self, _layout: &TextLayout, _origin: Point2D) {
// Phase B will route through jian-skia textlayout; Phase A red-rect
// demo does not draw text.
}
fn clip_rect(&mut self, rect: Rect) {
self.surface.canvas().clip_rect(
skia_safe::Rect::from_xywh(rect.origin.x, rect.origin.y, rect.size.x, rect.size.y),
None,
true,
);
}
fn save(&mut self) {
self.surface.canvas().save();
}
fn restore(&mut self) {
self.surface.canvas().restore();
}
fn translate(&mut self, offset: Point2D) {
self.surface.canvas().translate((offset.x, offset.y));
}
fn resize(&mut self, width: u32, height: u32) {
let width = width.max(1);
let height = height.max(1);
if width == self.width && height == self.height {
return;
}
if let Ok(surface) = skia_wasm::make_raster_surface(width, height) {
self.surface = surface;
self.pixels = vec![0u8; (width as usize) * (height as usize) * 4];
self.width = width;
self.height = height;
self.canvas_element.set_width(width);
self.canvas_element.set_height(height);
}
}
fn dpi_scale(&self) -> f32 {
self.dpi_scale
}
}

View file

@ -0,0 +1,16 @@
//! skia-safe surface bring-up for wasm32-unknown-unknown (Step 1b §2.2,
//! post C-hard.2 lock-in 2026-05-09).
//!
//! Phase A path: raster N32_PREMUL surface, presented to the host `<canvas>`
//! via `put_image_data`. Phase A round 2 may add a GPU `GrContext` over
//! WebGL2 once raster is proven; the trait surface in `backend/mod.rs` is
//! agnostic, so swapping the surface constructor is a self-contained change.
use wasm_bindgen::JsValue;
/// Allocate a raster surface backed by N32_PREMUL pixels. `width` /
/// `height` are clamped to ≥ 1 by the caller (`WebBackend::new`).
pub fn make_raster_surface(width: u32, height: u32) -> Result<skia_safe::Surface, JsValue> {
skia_safe::surfaces::raster_n32_premul((width as i32, height as i32))
.ok_or_else(|| JsValue::from_str("skia_safe::surfaces::raster_n32_premul returned None"))
}

View file

@ -1,22 +1,142 @@
//! OpenPencil shell — web (wasm32) bundle entry.
//! OpenPencil shell — web bundle entry.
//!
//! Per spec v19 §1.2 (FROZEN 2026-05-04): this crate is the web bundle entry.
//! CI invariant requires `cargo check --target wasm32-unknown-unknown -p
//! openpencil-shell-web --no-default-features --features web` to pass on
//! every PR.
//! Per spec v19 §1.2 (FROZEN 2026-05-04): this crate is the web bundle
//! entry. CI invariant requires `cargo check --target wasm32-unknown-
//! unknown -p openpencil-shell-web --no-default-features --features web`
//! to pass on every PR — that path uses the **stub** mount entry below
//! and is purely a wasm32-clean compile guard (no skia, no real render).
//!
//! Step 1a Task 1 status: only link-checks the shell-core re-export; the
//! CanvasKit WebBackend lands in Step 1b.
//! Phase A onward enables `--features skia` and targets
//! `wasm32-unknown-unknown` via the C-hard pipeline (vendor/skia-safe-op
//! fork + crates/wasm-libc-shim). The same target serves both the
//! compile-guard CI baseline and the real render path; the only
//! difference is the `skia` feature flag and the EMSDK env var (used
//! at build time only — for libcxx headers + emsdk's wasm-aware clang
//! — never linked into the final bundle).
#[cfg(feature = "skia")]
mod backend;
// Force the wasm32-unknown-unknown libc/libcxx/libm shim to be linked
// even though no Rust code calls it — its `#[no_mangle]` symbols are
// referenced only by the C++ side of the wasm (Skia static lib). Without
// this `extern crate`, cargo would dead-code-eliminate the shim because
// no Rust path imports anything from it.
#[cfg(all(feature = "skia", target_arch = "wasm32", target_os = "unknown"))]
extern crate wasm_libc_shim as _;
use wasm_bindgen::prelude::*;
/// Skeleton placeholder until Step 1b lands `WebCanvasKitBackend`.
/// Long-lived shell handle. The smoke HTML must keep this alive (e.g.
/// `window.__opShell = mount("op")`) so closures stored on the shell
/// remain reachable for the page lifetime.
///
/// Uses the OP `Color::TRANSPARENT` named constant to prove the shell-core
/// re-export links on wasm32 — this is the minimal link-check (shell-core
/// must stay wasm32-clean per spec §1.2).
/// The stub variant (without `skia` feature) carries no fields and exists
/// only so the wasm32-unknown-unknown CI baseline can compile-check the
/// public surface.
#[wasm_bindgen]
pub fn placeholder() -> String {
let _t = openpencil_shell_core::Color::TRANSPARENT;
"openpencil-shell-web skeleton (Task 1: deps wired)".to_string()
pub struct WebShell {
#[cfg(feature = "skia")]
backend: backend::WebBackend,
}
#[cfg(feature = "skia")]
impl WebShell {
/// Phase A red-rect demo: clear to white, draw a centered red rect,
/// snapshot to the host canvas. Returns the present error if the
/// final ImageData round-trip failed — callers MUST propagate this
/// to JS instead of treating mount as successful when the canvas
/// stayed blank.
///
/// Phase B+ replaces this with the widget host paint loop.
fn paint_phase_a(&mut self) -> Result<(), JsValue> {
use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend};
self.backend.begin_frame();
// Clear background.
self.backend.fill_rect(
Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(960.0, 640.0),
},
Color::WHITE,
);
// Centered red rect: 320×120 inside the 960×640 canvas.
self.backend.fill_rect(
Rect {
origin: Point2D::new(320.0, 260.0),
size: Point2D::new(320.0, 120.0),
},
Color::RED,
);
self.backend.end_frame();
if let Some(err) = self.backend.take_present_error() {
return Err(err);
}
Ok(())
}
}
/// Mount the WebShell on the canvas identified by `canvas_id` in the host
/// document. Returns the live shell instance to the caller; the caller
/// MUST keep it alive (`window.__opShell = mount("op")`).
///
/// Errors propagate back to JS as a `JsValue` exception.
///
/// Without the `skia` feature this is a stub that returns the
/// fields-less `WebShell` after validating the canvas element exists
/// — useful only for the kickoff §1.2 wasm32-clean compile guard CI.
#[cfg(feature = "skia")]
#[wasm_bindgen]
pub fn mount(canvas_id: &str) -> Result<WebShell, JsValue> {
use wasm_bindgen::JsCast;
use web_sys::HtmlCanvasElement;
// Install the panic hook on first call so panics print to the browser
// console instead of being swallowed silently.
console_error_panic_hook::set_once();
let window = web_sys::window().ok_or_else(|| JsValue::from_str("mount: window unavailable"))?;
let document = window
.document()
.ok_or_else(|| JsValue::from_str("mount: document unavailable"))?;
let element = document
.get_element_by_id(canvas_id)
.ok_or_else(|| JsValue::from_str(&format!("mount: canvas '{canvas_id}' not found")))?;
let canvas = element
.dyn_into::<HtmlCanvasElement>()
.map_err(|_| JsValue::from_str("mount: target element is not <canvas>"))?;
let backend = backend::WebBackend::new(canvas)?;
let mut shell = WebShell { backend };
// Phase A demo paints synchronously inside mount(); any present error
// (read_pixels / put_image_data) MUST surface as a JS exception so
// callers do not see Ok with an unpainted canvas.
shell.paint_phase_a()?;
Ok(shell)
}
/// Stub mount used by the kickoff §1.2 wasm32-clean compile guard CI.
/// Returns a fields-less `WebShell` after verifying the host has a
/// canvas with the given id; never paints. Real rendering needs the
/// `skia` feature.
#[cfg(not(feature = "skia"))]
#[wasm_bindgen]
pub fn mount(canvas_id: &str) -> Result<WebShell, JsValue> {
use wasm_bindgen::JsCast;
use web_sys::HtmlCanvasElement;
console_error_panic_hook::set_once();
let window = web_sys::window().ok_or_else(|| JsValue::from_str("mount: window unavailable"))?;
let document = window
.document()
.ok_or_else(|| JsValue::from_str("mount: document unavailable"))?;
let element = document
.get_element_by_id(canvas_id)
.ok_or_else(|| JsValue::from_str(&format!("mount: canvas '{canvas_id}' not found")))?;
let _canvas = element
.dyn_into::<HtmlCanvasElement>()
.map_err(|_| JsValue::from_str("mount: target element is not <canvas>"))?;
Ok(WebShell {})
}