From 824d87014b5df87589bd59c59b52827464c72cb4 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:04:28 +0800 Subject: [PATCH 1/7] feat(desktop): add Linux and web System hosts --- .github/workflows/desktop-linux.yml | 104 +++++ .github/workflows/macos.yml | 27 +- contracts/spec/platforms.ts | 52 ++- docs/BACKENDS.md | 41 +- docs/STRUCTURE.md | 6 +- engine/backends/gpui/Cargo.toml | 2 +- engine/wasm/src/lib.rs | 279 ++++++++++++- hosts/{macos => desktop}/Cargo.lock | 63 +-- hosts/{macos => desktop}/Cargo.toml | 13 +- hosts/{macos => desktop}/src/main.rs | 63 +-- hosts/web/app-instance.html | 5 + hosts/web/app-instance.js | 102 +++++ hosts/web/system-engine.js | 452 +++++++++++++++++++++ hosts/web/wasm-ops.d.ts | 19 + hosts/web/wasm-ops.js | 74 +++- site/content/blog/pocket-pi-on-esp32-p4.md | 4 +- tests/platform-contracts.test.ts | 47 +++ tests/pocket-system.test.ts | 16 + tests/web-system-host.test.ts | 39 ++ tools/bench-desktop.ts | 4 +- tools/macos.ts | 24 +- 21 files changed, 1319 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/desktop-linux.yml rename hosts/{macos => desktop}/Cargo.lock (99%) rename hosts/{macos => desktop}/Cargo.toml (58%) rename hosts/{macos => desktop}/src/main.rs (96%) create mode 100644 hosts/web/app-instance.html create mode 100644 hosts/web/app-instance.js create mode 100644 hosts/web/system-engine.js create mode 100644 tests/web-system-host.test.ts diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml new file mode 100644 index 00000000..2c23434d --- /dev/null +++ b/.github/workflows/desktop-linux.yml @@ -0,0 +1,104 @@ +name: Linux gpui host + +on: + pull_request: + paths: + - ".github/workflows/desktop-linux.yml" + - "contracts/spec/**" + - "engine/core/**" + - "engine/crates/pocket-mod/**" + - "engine/crates/pocket-ui-surface/**" + - "engine/backends/gpui/**" + - "hosts/desktop/**" + - "hosts/web/app-instance.*" + - "hosts/web/system-engine.js" + - "hosts/web/wasm-ops.js" + - "framework/src/manifest/**" + - "tests/platform-contracts.test.ts" + - "tests/pocket-system.test.ts" + - "tests/web-system-host.test.ts" + push: + branches: [main] + paths: + - ".github/workflows/desktop-linux.yml" + - "contracts/spec/**" + - "engine/core/**" + - "engine/crates/pocket-mod/**" + - "engine/crates/pocket-ui-surface/**" + - "engine/backends/gpui/**" + - "hosts/desktop/**" + - "hosts/web/app-instance.*" + - "hosts/web/system-engine.js" + - "hosts/web/wasm-ops.js" + - "framework/src/manifest/**" + - "tests/platform-contracts.test.ts" + - "tests/pocket-system.test.ts" + - "tests/web-system-host.test.ts" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + linux: + name: linux-app build + X11 smoke + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - name: Install gpui Linux dependencies + run: >- + sudo apt-get update && sudo apt-get install -y --no-install-recommends + clang cmake pkg-config libasound2-dev libfontconfig-dev libglib2.0-dev + libssl-dev libvulkan1 libwayland-dev libx11-xcb-dev + libxkbcommon-x11-dev libzstd-dev mesa-vulkan-drivers xvfb xauth + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + engine/backends/gpui + hosts/desktop + engine/wasm + + - uses: oven-sh/setup-bun@v2 + + - run: bun install --frozen-lockfile + + - name: Linux, web and System contract tests + run: bun test tests/platform-contracts.test.ts tests/pocket-system.test.ts tests/web-system-host.test.ts + + - name: Resolve and build the note for linux-app + run: | + bun -e ' + import { validateAndResolveBuildPlan } from "./framework/src/manifest/resolve.ts"; + import { mkdirSync } from "node:fs"; + const manifest = await Bun.file("apps/note/pocket.json").json(); + const result = validateAndResolveBuildPlan(manifest, { target: "linux-app" }); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + mkdirSync(".pocket/linux-app", { recursive: true }); + await Bun.write(".pocket/linux-app/note-main.plan.json", JSON.stringify(result.plan, null, 2) + "\n");' + bun tools/build.ts --plan=.pocket/linux-app/note-main.plan.json + + - name: Rust format, lint and tests + run: | + cargo fmt --check --manifest-path hosts/desktop/Cargo.toml + cargo clippy --locked --manifest-path hosts/desktop/Cargo.toml -- -D warnings + cargo test --locked --manifest-path hosts/desktop/Cargo.toml + + - name: Build linux-app release host + run: cargo build --release --locked --manifest-path hosts/desktop/Cargo.toml + + - name: Launch linux-app under X11 with software Vulkan + env: + POCKETJS_DIST: ${{ github.workspace }}/dist + run: >- + timeout 30s xvfb-run -a hosts/desktop/target/release/pocket-desktop-host + --app note-main --title Note --viewport 720x480 --density 1 + --native-text --companions note --editor --file /tmp/pocket-note.md + --quit-after 20 diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index e608b89d..0305f546 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -1,8 +1,9 @@ name: macOS gpui backend # The first macOS lane in CI: the gpui backend (engine/backends/gpui) and -# the macos-app host (hosts/macos) are standalone crates excluded from the -# ubuntu release workspace, so this is the only place they build. The lane +# the macos-app host (hosts/desktop) are standalone crates excluded from the +# release workspace. This lane pins the macOS build while desktop-linux.yml +# pins the same crate on Linux. The lane # also runs the TS surfaces its path filter watches (contract drift, the # platform/note/font-bake tests, and a real macos-app plan+bundle build of # the note). gpui needs a window server for anything more; runtime behavior @@ -18,7 +19,7 @@ on: - "engine/crates/pocket-ui-surface/**" - "engine/backends/gpui/**" - "engine/wasm/**" - - "hosts/macos/**" + - "hosts/desktop/**" - "tools/macos.ts" - "framework/src/components*.ts*" - "framework/src/host.ts" @@ -26,12 +27,15 @@ on: - "framework/src/primitives.ts" - "framework/src/manifest/**" - "hosts/web/wasm-ops.js" + - "hosts/web/app-instance.*" + - "hosts/web/system-engine.js" - "apps/note/**" - "framework/compiler/**" - "assets/fonts/**" - "tests/platform-contracts.test.ts" - "tests/note.test.ts" - "tests/pocket-system.test.ts" + - "tests/web-system-host.test.ts" - "tests/font-bake.test.ts" - "tests/npm-package.test.ts" - "package.json" @@ -45,7 +49,7 @@ on: - "engine/crates/pocket-ui-surface/**" - "engine/backends/gpui/**" - "engine/wasm/**" - - "hosts/macos/**" + - "hosts/desktop/**" - "tools/macos.ts" - "framework/src/components*.ts*" - "framework/src/host.ts" @@ -53,12 +57,15 @@ on: - "framework/src/primitives.ts" - "framework/src/manifest/**" - "hosts/web/wasm-ops.js" + - "hosts/web/app-instance.*" + - "hosts/web/system-engine.js" - "apps/note/**" - "framework/compiler/**" - "assets/fonts/**" - "tests/platform-contracts.test.ts" - "tests/note.test.ts" - "tests/pocket-system.test.ts" + - "tests/web-system-host.test.ts" - "tests/font-bake.test.ts" - "tests/npm-package.test.ts" - "package.json" @@ -85,7 +92,7 @@ jobs: with: workspaces: | engine/backends/gpui - hosts/macos + hosts/desktop engine/wasm - uses: oven-sh/setup-bun@v2 @@ -94,7 +101,7 @@ jobs: run: bun install --frozen-lockfile - name: Contract + System + note + platform + npm package tests - run: bun test tests/platform-contracts.test.ts tests/pocket-system.test.ts tests/note.test.ts tests/font-bake.test.ts tests/npm-package.test.ts && bun tests/contract.ts + run: bun test tests/platform-contracts.test.ts tests/pocket-system.test.ts tests/web-system-host.test.ts tests/note.test.ts tests/font-bake.test.ts tests/npm-package.test.ts && bun tests/contract.ts - name: Compile the note against macos-app (plan + bundle + pak) run: | @@ -117,13 +124,13 @@ jobs: - name: rustfmt run: | cargo fmt --check --manifest-path engine/backends/gpui/Cargo.toml - cargo fmt --check --manifest-path hosts/macos/Cargo.toml + cargo fmt --check --manifest-path hosts/desktop/Cargo.toml - name: Clippy gpui backend run: cargo clippy --locked --manifest-path engine/backends/gpui/Cargo.toml -- -D warnings - - name: Clippy macos-app host - run: cargo clippy --locked --manifest-path hosts/macos/Cargo.toml -- -D warnings + - name: Clippy native desktop host + run: cargo clippy --locked --manifest-path hosts/desktop/Cargo.toml -- -D warnings - name: Build macos-app host - run: cargo build --locked --manifest-path hosts/macos/Cargo.toml + run: cargo build --locked --manifest-path hosts/desktop/Cargo.toml diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index aa344d17..6a38d880 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -218,6 +218,8 @@ export const POCKET_TARGETS = defineTargetRegistry; readonly "macos-widget": TargetProfile; readonly "macos-app": TargetProfile; + readonly "linux-app": TargetProfile; + readonly "web-app": TargetProfile; }>({ psp: { hostAbi: 1, @@ -308,7 +310,7 @@ export const POCKET_TARGETS = defineTargetRegistry; diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 6d5aa24d..c3330376 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -30,8 +30,8 @@ hosts, the PSP GE walker, the ESP32-P4 PPA and Symbian GLES2 ports. ## The gpui backend `engine/backends/gpui` (`pocket-ui-gpui`) paints the same DrawList through -[gpui](https://gpui.rs) — Zed's Metal renderer — and is embedded by -`hosts/macos`, the stock host of the `macos-app` target. Boxes, gradients +[gpui](https://gpui.rs) — Zed's native GPU renderer — and is embedded by +`hosts/desktop`, the stock host of the `macos-app` and `linux-app` targets. Boxes, gradients and images become antialiased vector quads instead of upscaled raster; text can come from the host text system. @@ -39,7 +39,7 @@ text can come from the host text system. `enhances: ["text.layout.native"]` gets a core text measurer installed before the guest mounts (`Ui::set_text_measure`): taffy leaf sizes, the `measureText` op and painted glyphs all come from one provider — CoreText - through gpui's text system. Codepoint coverage is the OS font fallback + through gpui's platform text system. Codepoint coverage is the OS font fallback chain (CJK, emoji, everything), with **no runtime atlas baking and no tofu**. - **The op is `TEXT_RUN` (9).** With a measurer installed, translation-only @@ -95,13 +95,13 @@ instead of `text.glyphs.baked` — and why gpui-hosted apps verify like the note does (pure-math unit tests over an injected measurer, sim traces, `--proof` acceptance runs) instead of joining `tests/golden-specs.ts`. -## The `macos-app` target +## Native desktop targets -`contracts/spec/platforms.ts` registers the profile: hostAbi 4 (the -compositor-surface wire generation), `form: "window"`, -dynamic viewport with `acceptsFixed` — a general app frame, not a widget -shell. Fixed-viewport console apps run size-locked and letterboxed with -their baked glyph pipeline intact; the manifest decides everything else: +`contracts/spec/platforms.ts` registers `macos-app` and `linux-app` at +hostAbi 4 with `form: "window"`, a dynamic viewport and `acceptsFixed`. +Both profiles use the same generic host. macOS resolves density 2; Linux +resolves density 1. Fixed-viewport console apps run size-locked and +letterboxed with their baked glyph pipeline intact. ``` bun run macos note # dynamic viewport, native text, svc editor protocol @@ -119,9 +119,24 @@ viewport or title fields. `--editor` is NOT a capability: it enables the note's companion svc adapter (an app protocol — the profile deliberately registers no pointer/text/IME/clipboard ids, see contracts/spec/platforms.ts). On exit -the host prints its governor receipt (`pocket-macos: N ticks, M frames +the host prints its governor receipt (`pocket-desktop-host: N ticks, M frames rendered`); a settled app shows M ≪ N. +## Browser System host + +**`web-app` runs every package in an independent same-origin iframe +JavaScript Realm with its own wasm `Ui`.** `hosts/web/system-engine.js` +derives the package catalog, surface handles and lifecycle policy from one +`ResolvedSystemPlan`. Live shell bindings create and remove AppInstances; +focus and visible painter order determine scheduling. + +The wasm software compositor retains each visible child framebuffer and +replaces `SURFACE_QUAD` with a texture quad only during the host render. +Full bounds determine the child coordinate origin, clip bounds constrain the +visible pixels, and the instruction remains at its original DrawList offset. +Shell chrome emitted after the surface therefore stays above the child. A +missing child raster leaves the shell's loading fallback visible. + ## System UI companion input The host speaks the `system-ui` svc dialect when the resolved System UI plan @@ -145,7 +160,7 @@ System UI role and background-execution policy. **Every installed entry reaches the native host as a complete `ResolvedBuildPlan`; ordinary applications resolve without the System UI-only compositor capability.** -- **`hosts/macos` implements a generic `AppSupervisor`; the System contract +- **`hosts/desktop` implements a generic `AppSupervisor`; the System contract does not expose that implementation.** The host contains no product catalog or package-name rules. Live `` bindings create one AppInstance with its own `Guest`, QuickJS `Runtime`, QuickJS `Context`, @@ -174,8 +189,8 @@ X,Y[,d|u|r]@TICK` (drags, right clicks), `--key | | portable | gpui | | ------------------ | ---------------------------------------------------------------- | ------------------------------------------- | -| hosts | PSP, Vita, PocketBook, ESP32-P4, Symbian, web, sim, macOS widget | macOS (`hosts/macos`) | -| text measurement | core, atlas advance tables | host text system (CoreText), per-app opt-in | +| hosts | PSP, Vita, PocketBook, ESP32-P4, Symbian, web, sim, macOS widget | macOS and Linux (`hosts/desktop`) | +| text measurement | core, atlas advance tables | gpui platform text system, per-app opt-in | | codepoint coverage | baked charset (+ runtime extension) | OS fallback chain, color emoji | | pixel determinism | byte-exact across hosts | per-host; transactions still deterministic | | pixel goldens | `tests/golden-specs.ts`, tape hashes | opted out (note-style verification) | diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 9b5ce01a..2efae36b 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -10,7 +10,7 @@ pocketjs/ ├─ engine/ Cores: the Rust simulation cores │ ├─ core/ pocketjs-core — retained UI tree, taffy layout, damage + raster (standalone crate) │ ├─ backends/ platform render backends (ESP32-P4 PPA is a standalone no_std -│ │ crate; gpui is the standalone macOS Metal backend with native +│ │ crate; gpui is the standalone native desktop backend with native │ │ text layout — docs/BACKENDS.md) │ ├─ wasm/ core compiled to wasm32 for web/sim hosts (standalone crate) │ ├─ symbian/ no_std Symbian UI static library: C ABI, capture raster + GLES2 DrawList backend (standalone crate) @@ -26,8 +26,8 @@ pocketjs/ │ ├─ pocketbook/ PocketBook e-reader host (inkview, standalone lone-bin crate) │ ├─ symbian/ Nokia E7 Qt/QuickJS runtime + visible toolchain probe │ ├─ apple/ NativeScript iOS shell over engine/apple + @nativescript/pocketjs -│ ├─ macos/ gpui (Metal) window host — the macos-app target (standalone lone-bin crate) -│ ├─ web/ browser dev host (wasm core) +│ ├─ desktop/ gpui window host — macos-app + linux-app (standalone lone-bin crate) +│ ├─ web/ browser dev + Pocket System host (wasm core, isolated iframe Realms) │ └─ sim/ deterministic headless simulation host (docs/DETERMINISM.md) ├─ framework/ Guest: @pocketjs/framework │ ├─ src/ the TS runtime (Solid + Vue Vapor renderers, components, input, osk…) diff --git a/engine/backends/gpui/Cargo.toml b/engine/backends/gpui/Cargo.toml index b1dd1c7a..357f6771 100644 --- a/engine/backends/gpui/Cargo.toml +++ b/engine/backends/gpui/Cargo.toml @@ -3,7 +3,7 @@ # Standalone on purpose (excluded from engine/Cargo.toml, own Cargo.lock, # same policy as backends/esp32p4-ppa): gpui builds only where a display # server exists, and the ubuntu release workspace build must not depend on -# it. Built through hosts/macos (the stock macos-app host) and by +# it. Built through hosts/desktop (the stock macos-app host) and by # .github/workflows/macos.yml. [package] diff --git a/engine/wasm/src/lib.rs b/engine/wasm/src/lib.rs index 7330d852..9d00ca69 100644 --- a/engine/wasm/src/lib.rs +++ b/engine/wasm/src/lib.rs @@ -30,6 +30,20 @@ use pocketjs_core::raster; static mut UI: Option = None; static mut FRAMEBUFFER: Vec = Vec::new(); +#[derive(Clone, Copy)] +struct CompositorTexture { + texture: i32, + width: u32, + height: u32, +} + +/// Browser System hosts upload visible child AppInstance framebuffers here. +/// Values are core texture handles indexed by the shell's compositor-surface +/// handle. They remain outside the guest HostOps contract. +static mut COMPOSITOR_TEXTURES: Vec> = Vec::new(); +/// Staged compositor frame records. Each record is ten u32 words: +/// handle, full x/y/w/h f32 bits, clip x/y/w/h f32 bits, focused (0/1). +static mut COMPOSITOR_FRAMES: Vec = Vec::new(); static mut DAMAGE_TRACKER: DamageTracker = DamageTracker::new(); /// wrapText result staging (same lifetime contract as FRAMEBUFFER: the /// pointer from `ui_wrap_text_ptr` stays valid until the next wrapText or @@ -67,6 +81,8 @@ pub extern "C" fn ui_init(raster_density: u32) { unsafe { UI = Some(Ui::new_with_raster_density(raster_density.max(1))); FRAMEBUFFER.clear(); + COMPOSITOR_TEXTURES.clear(); + COMPOSITOR_FRAMES.clear(); DAMAGE_TRACKER = DamageTracker::new(); } } @@ -169,6 +185,95 @@ pub extern "C" fn ui_set_compositor_surface(id: i32, surface: i32, focused: i32) ui().set_compositor_surface(id, surface, focused != 0) } +/// Replace one browser compositor surface's retained RGBA8 framebuffer. +/// This is a host-only wasm ABI: guest code can declare a surface binding, +/// but cannot upload or inspect another AppInstance's pixels. +#[no_mangle] +pub extern "C" fn ui_compositor_upload_surface( + surface: u32, + ptr: *const u8, + len: usize, + width: u32, + height: u32, +) -> i32 { + let Some(expected) = (width as usize) + .checked_mul(height as usize) + .and_then(|pixels| pixels.checked_mul(4)) + else { + return -1; + }; + if width == 0 || height == 0 || expected != len { + return -1; + } + let slot = surface as usize; + unsafe { + if COMPOSITOR_TEXTURES.len() <= slot { + COMPOSITOR_TEXTURES.resize(slot + 1, None); + } + if let Some(previous) = COMPOSITOR_TEXTURES[slot] { + ui().free_texture(previous.texture); + } + let texture = ui().upload_texture( + bytes(ptr, len), + width, + height, + pocketjs_core::spec::psm::PSM_8888, + ); + if texture < 0 { + COMPOSITOR_TEXTURES[slot] = None; + return -1; + } + COMPOSITOR_TEXTURES[slot] = Some(CompositorTexture { + texture, + width, + height, + }); + texture + } +} + +/// Release one retained browser compositor surface framebuffer. +#[no_mangle] +pub extern "C" fn ui_compositor_free_surface(surface: u32) { + let slot = surface as usize; + unsafe { + if let Some(entry) = COMPOSITOR_TEXTURES.get_mut(slot).and_then(Option::take) { + ui().free_texture(entry.texture); + } + } +} + +/// Stage the shell's visible compositor frames in exact painter order. +#[no_mangle] +pub extern "C" fn ui_compositor_frames() -> u32 { + let frames = ui().compositor_surface_frames(); + unsafe { + COMPOSITOR_FRAMES.clear(); + COMPOSITOR_FRAMES.reserve(frames.len() * 10); + for frame in frames { + COMPOSITOR_FRAMES.extend_from_slice(&[ + frame.handle, + frame.full[0].to_bits(), + frame.full[1].to_bits(), + frame.full[2].to_bits(), + frame.full[3].to_bits(), + frame.clip[0].to_bits(), + frame.clip[1].to_bits(), + frame.clip[2].to_bits(), + frame.clip[3].to_bits(), + u32::from(frame.focused), + ]); + } + (COMPOSITOR_FRAMES.len() / 10) as u32 + } +} + +/// Pointer to records staged by [`ui_compositor_frames`]. +#[no_mangle] +pub extern "C" fn ui_compositor_frames_ptr() -> *const u32 { + unsafe { COMPOSITOR_FRAMES.as_ptr() } +} + #[no_mangle] pub extern "C" fn ui_set_sprite(id: i32, atlas: i32, frames: u32, cols: u32, step: u32) { ui().set_sprite(id, atlas, frames, cols, step) @@ -286,6 +391,87 @@ fn draw_hash(words: &[u32]) -> u64 { hash } +fn draw_op_len(words: &[u32], at: usize) -> Option { + let op = *words.get(at)?; + Some(match op { + pocketjs_core::spec::draw_op::RECT => 4, + pocketjs_core::spec::draw_op::GRAD_RECT => 6, + pocketjs_core::spec::draw_op::GLYPH_RUN => 3 + 2 * ((*words.get(at + 1)? >> 16) as usize), + pocketjs_core::spec::draw_op::TEX_QUAD => 9, + pocketjs_core::spec::draw_op::SCISSOR => 3, + pocketjs_core::spec::draw_op::SCISSOR_POP => 1, + pocketjs_core::spec::draw_op::TRI => 7, + pocketjs_core::spec::draw_op::TEX_TRI => 12, + pocketjs_core::spec::draw_op::TEXT_RUN => 8 + (*words.get(at + 7)? as usize).div_ceil(4), + pocketjs_core::spec::draw_op::SURFACE_QUAD => 9, + _ => return None, + }) +} + +#[inline] +fn packed_xy(x: i32, y: i32) -> u32 { + (x as i16 as u16 as u32) | ((y as i16 as u16 as u32) << 16) +} + +#[inline] +fn packed_wh(width: i32, height: i32) -> u32 { + (width as u16 as u32) | ((height as u16 as u32) << 16) +} + +/// Translate host-only SURFACE_QUAD instructions into ordinary texture quads +/// for the browser software compositor. Missing child rasters remain surface +/// instructions, preserving the shell's already-painted loading fallback. +fn composited_words(words: &[u32]) -> Vec { + let mut out = words.to_vec(); + let mut at = 0usize; + while at < out.len() { + let Some(len) = draw_op_len(&out, at) else { + break; + }; + let Some(end) = at.checked_add(len) else { + break; + }; + if end > out.len() { + break; + } + if out[at] == pocketjs_core::spec::draw_op::SURFACE_QUAD { + let surface = out[at + 1] as usize; + let entry = unsafe { COMPOSITOR_TEXTURES.get(surface).and_then(|entry| *entry) }; + if let Some(entry) = entry { + let full_x = f32::from_bits(out[at + 2]); + let full_y = f32::from_bits(out[at + 3]); + let clip_xy = out[at + 6]; + let clip_wh = out[at + 7]; + let clip_x = clip_xy as u16 as i16 as i32; + let clip_y = (clip_xy >> 16) as u16 as i16 as i32; + let clip_w = (clip_wh & 0xffff) as i32; + let clip_h = (clip_wh >> 16) as i32; + let x0 = clip_x.max(full_x.ceil() as i32); + let y0 = clip_y.max(full_y.ceil() as i32); + let x1 = (clip_x + clip_w).min((full_x + entry.width as f32).ceil() as i32); + let y1 = (clip_y + clip_h).min((full_y + entry.height as f32).ceil() as i32); + if x0 < x1 && y0 < y1 { + let u0 = ((x0 as f32 - full_x) / entry.width as f32).clamp(0.0, 1.0); + let v0 = ((y0 as f32 - full_y) / entry.height as f32).clamp(0.0, 1.0); + let u1 = ((x1 as f32 - full_x) / entry.width as f32).clamp(0.0, 1.0); + let v1 = ((y1 as f32 - full_y) / entry.height as f32).clamp(0.0, 1.0); + out[at] = pocketjs_core::spec::draw_op::TEX_QUAD; + out[at + 1] = entry.texture as u32; + out[at + 2] = packed_xy(x0, y0); + out[at + 3] = packed_wh(x1 - x0, y1 - y0); + out[at + 4] = u0.to_bits(); + out[at + 5] = v0.to_bits(); + out[at + 6] = u1.to_bits(); + out[at + 7] = v1.to_bits(); + out[at + 8] = 0xffff_ffff; + } + } + } + at = end; + } + out +} + // ---- DevTools ops (spec ops 18..22, docs/DEVTOOLS.md) ----------------------------- #[no_mangle] @@ -353,6 +539,38 @@ fn render_at_scale(scale: u32) -> *const u8 { } } +fn render_composited_at_scale(scale: u32) -> *const u8 { + if !(1..=raster::MAX_RENDER_SCALE).contains(&scale) { + return core::ptr::null(); + } + let u = ui(); + let (viewport_w, viewport_h) = u.viewport(); + let Some(bytes) = (viewport_w as usize) + .checked_mul(scale as usize) + .and_then(|width| { + (viewport_h as usize) + .checked_mul(scale as usize) + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)) + else { + return core::ptr::null(); + }; + let dl: *const pocketjs_core::DrawList = u.draw(); + let words = unsafe { composited_words(&(*dl).words) }; + let u_ref: &Ui = unsafe { &*(u as *const Ui) }; + unsafe { + FRAMEBUFFER.resize(bytes, 0); + if scale == 1 { + raster::render(u_ref, &words, &mut FRAMEBUFFER); + } else { + raster::render_scaled(u_ref, &words, &mut FRAMEBUFFER, scale); + } + DAMAGE_TRACKER.invalidate(); + FRAMEBUFFER.as_ptr() + } +} + fn render_incremental_at_scale(scale: u32) -> *const u8 { if !(1..=raster::MAX_RENDER_SCALE).contains(&scale) { return core::ptr::null(); @@ -409,6 +627,19 @@ pub extern "C" fn ui_render_scaled(scale: u32) -> *const u8 { render_at_scale(scale) } +/// Rasterize the shell with retained child AppInstance framebuffers inserted +/// at SURFACE_QUAD painter positions. +#[no_mangle] +pub extern "C" fn ui_render_composited() -> *const u8 { + render_composited_at_scale(1) +} + +/// Integer-scaled equivalent of [`ui_render_composited`]. +#[no_mangle] +pub extern "C" fn ui_render_composited_scaled(scale: u32) -> *const u8 { + render_composited_at_scale(scale) +} + /// Incrementally rasterize at the logical viewport size. #[no_mangle] pub extern "C" fn ui_render_incremental() -> *const u8 { @@ -423,7 +654,10 @@ pub extern "C" fn ui_render_incremental_scaled(scale: u32) -> *const u8 { #[cfg(test)] mod tests { - use super::draw_hash; + use super::{ + composited_words, draw_hash, packed_wh, packed_xy, CompositorTexture, COMPOSITOR_TEXTURES, + }; + use pocketjs_core::{raster, spec, Ui}; #[test] fn draw_hash_is_stable_and_content_sensitive() { @@ -432,4 +666,47 @@ mod tests { assert_ne!(draw_hash(&words), draw_hash(&[0x0102_0304, 0x0506_0709])); assert_ne!(draw_hash(&[]), draw_hash(&words)); } + + #[test] + fn browser_compositor_keeps_child_between_shell_ops() { + let mut ui = Ui::new(); + ui.set_viewport(4.0, 4.0); + let red = [255u8, 0, 0, 255].repeat(16); + let texture = ui.upload_texture(&red, 4, 4, spec::psm::PSM_8888); + unsafe { + COMPOSITOR_TEXTURES = vec![Some(CompositorTexture { + texture, + width: 4, + height: 4, + })]; + } + let words = [ + spec::draw_op::RECT, + packed_xy(0, 0), + packed_wh(4, 4), + 0xff00_0000, + spec::draw_op::SURFACE_QUAD, + 0, + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 4.0f32.to_bits(), + 4.0f32.to_bits(), + packed_xy(0, 0), + packed_wh(4, 4), + 1, + spec::draw_op::RECT, + packed_xy(1, 1), + packed_wh(2, 2), + 0xff00_ff00, + ]; + let translated = composited_words(&words); + assert_eq!(translated[4], spec::draw_op::TEX_QUAD); + assert_eq!(translated[13], spec::draw_op::RECT); + + let mut framebuffer = vec![0; 4 * 4 * 4]; + raster::render(&ui, &translated, &mut framebuffer); + assert_eq!(&framebuffer[0..4], &[255, 0, 0, 255]); + let center = (1 * 4 + 1) * 4; + assert_eq!(&framebuffer[center..center + 4], &[0, 255, 0, 255]); + } } diff --git a/hosts/macos/Cargo.lock b/hosts/desktop/Cargo.lock similarity index 99% rename from hosts/macos/Cargo.lock rename to hosts/desktop/Cargo.lock index 5b498ddb..976d0595 100644 --- a/hosts/macos/Cargo.lock +++ b/hosts/desktop/Cargo.lock @@ -1,5 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +# Lockfile for the standalone native desktop host crate. version = 4 [[package]] @@ -822,9 +823,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -1510,9 +1511,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "embed-resource" @@ -1813,9 +1814,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-types" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75382bc7392ef10aad10935f92fc3db36d2d4dad0e5d96d8d65e04f89a07ec39" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" dependencies = [ "bytemuck", ] @@ -2445,9 +2446,9 @@ checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" [[package]] name = "h2" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -2743,9 +2744,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -3128,9 +3129,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "serde_core", "value-bag", @@ -3928,7 +3929,7 @@ dependencies = [ ] [[package]] -name = "pocket-macos" +name = "pocket-desktop-host" version = "0.1.0" dependencies = [ "anyhow", @@ -4495,18 +4496,18 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", @@ -4753,9 +4754,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -6099,9 +6100,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.1" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -7378,9 +7379,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -7389,9 +7390,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", @@ -7430,9 +7431,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", @@ -7446,9 +7447,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -7459,9 +7460,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", diff --git a/hosts/macos/Cargo.toml b/hosts/desktop/Cargo.toml similarity index 58% rename from hosts/macos/Cargo.toml rename to hosts/desktop/Cargo.toml index 6a6dada6..16f0b070 100644 --- a/hosts/macos/Cargo.toml +++ b/hosts/desktop/Cargo.toml @@ -1,20 +1,19 @@ -# pocket-macos — the PocketJS UI runtime in a gpui (Metal) macOS window: -# the stock host of the `macos-app` target (contracts/spec/platforms.ts). +# pocket-desktop-host — the PocketJS UI runtime in a native gpui window: +# the stock host of the `macos-app` and `linux-app` targets. # # STANDALONE LONE-BIN CRATE (same model as hosts/pocketbook): outside any # workspace with its own Cargo.lock, path-depending on the engine crates. -# gpui must never enter the ubuntu release workspace build, which is also why +# gpui stays outside the release workspace, which is also why # engine/backends/gpui sits on the engine exclude list. # -# Build + run through `bun run macos ` (tools/macos.ts), or directly: -# cargo run --release --manifest-path hosts/macos/Cargo.toml -- --app note-main +# Build + run through the product wrapper, or directly with a resolved plan. [package] -name = "pocket-macos" +name = "pocket-desktop-host" version = "0.1.0" edition = "2024" license = "MIT" -description = "PocketJS UI runtime host on gpui/Metal — the macos-app target" +description = "PocketJS UI runtime host on gpui for macOS and Linux app targets" [dependencies] pocketjs-core = { path = "../../engine/core", features = ["std"] } diff --git a/hosts/macos/src/main.rs b/hosts/desktop/src/main.rs similarity index 96% rename from hosts/macos/src/main.rs rename to hosts/desktop/src/main.rs index 286a3d41..e27d81bd 100644 --- a/hosts/macos/src/main.rs +++ b/hosts/desktop/src/main.rs @@ -1,4 +1,4 @@ -//! pocket-macos — the PocketJS UI runtime in a gpui window. +//! pocket-desktop-host — the PocketJS UI runtime in a native gpui window. //! //! The same app bundle + pak the consoles boot runs here in a QuickJS guest //! (pocket-mod) driving the same pocketjs-core, painted by the gpui backend @@ -25,8 +25,8 @@ //! map work for every app regardless of this flag. //! - System UI shell — the `system-ui` companion adapter: the same //! input-line dialect as the editor, extended with right-button mouse -//! lines (b:2), alt/ctl key modifiers, F1–F12, cmd-flagged ⌘ chords -//! (except ⌘Q = host quit and ⌘V = host-side paste), a boot epoch in the +//! lines (b:2), alt/ctl key modifiers, F1–F12, platform-command chords +//! (except command-Q = host quit and command-V = host-side paste), a boot epoch in the //! hello (for the guest's wall clock), a {t:"cursor"} guest intent that //! sets the window's pointer shape, and a {t:"paste-req"} guest intent //! answered with a paste line (menu-driven Paste). Wired when the plan's @@ -58,7 +58,12 @@ use pocket_ui_gpui::{GpuiRenderer, TextConfig, native_measure, native_wrap}; use pocket_ui_surface::UiSurface; use serde::Deserialize; +#[cfg(target_os = "macos")] const HOST_ID: &str = "macos-app"; +#[cfg(target_os = "linux")] +const HOST_ID: &str = "linux-app"; +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +compile_error!("pocket-desktop-host supports macOS and Linux"); const HOST_ABI: u32 = 4; const TICK_HZ: f64 = 60.0; const MAX_CATCHUP_TICKS: u32 = 6; @@ -520,7 +525,7 @@ fn resolve_asset(explicit: Option, app: &str, ext: &str) -> Result log::info!("pocket-macos: saved {} bytes", text.len()), - Err(e) => log::warn!("pocket-macos: save failed: {e}"), + Ok(()) => log::info!("pocket-desktop-host: saved {} bytes", text.len()), + Err(e) => log::warn!("pocket-desktop-host: save failed: {e}"), } } @@ -1218,7 +1223,7 @@ impl PocketRoot { vp.0, vp.1 ), ) { - log::warn!("pocket-macos: resize hook failed: {e}"); + log::warn!("pocket-desktop-host: resize hook failed: {e}"); } if self.forward_input() { self.svc(serde_json::json!({"t": "resize", "w": vp.0, "h": vp.1})); @@ -1255,7 +1260,7 @@ impl PocketRoot { }; self.click_edge = false; if let Err(e) = self.guest.frame(buttons) { - log::error!("pocket-macos: guest frame error: {e}"); + log::error!("pocket-desktop-host: guest frame error: {e}"); self.exit = true; return; } @@ -1270,7 +1275,7 @@ impl PocketRoot { .borrow_mut() .sync(&self.surface, self.text_system.clone()); for (package, message) in sync_failures { - log::error!("pocket-macos: starting AppInstance {package}: {message}"); + log::error!("pocket-desktop-host: starting AppInstance {package}: {message}"); } // Guest → host intents (editor protocol). @@ -1309,9 +1314,9 @@ impl PocketRoot { cx.notify(); } } - other => log::warn!("pocket-macos: unknown intent {other:?}"), + other => log::warn!("pocket-desktop-host: unknown intent {other:?}"), }, - Err(e) => log::warn!("pocket-macos: bad svc line from guest: {e}"), + Err(e) => log::warn!("pocket-desktop-host: bad svc line from guest: {e}"), } } @@ -1320,7 +1325,7 @@ impl PocketRoot { // corrupt sibling instances. let failures = self.app_supervisor.borrow_mut().tick(); for (package, message) in failures { - log::error!("pocket-macos: AppInstance {package} frame failed: {message}"); + log::error!("pocket-desktop-host: AppInstance {package} frame failed: {message}"); } // Tick before draw; arm a paint only when the content hash moved @@ -1341,7 +1346,7 @@ impl PocketRoot { } if self.exit { println!( - "pocket-macos: {} ticks, {} frames rendered ({:.1}%)", + "pocket-desktop-host: {} ticks, {} frames rendered ({:.1}%)", self.ticks, self.frames.get(), self.frames.get() as f64 / self.ticks.max(1) as f64 * 100.0 @@ -1369,11 +1374,11 @@ impl PocketRoot { let shift = ks.modifiers.shift; if ks.modifiers.platform { if self.system_ui_input { - // The System UI dialect reserves ⌘Q for the host and - // ⌘V pastes here (the guest can't read the clipboard); + // The System UI dialect reserves command-Q for the host and + // command-V pastes here (the guest can't read the clipboard); // every other chord goes to the guest as a cmd-flagged // key line — the compositor owns its own shortcuts - // (⌘W close, ⌘M minimize, ⌘` cycle, ⌘C/X/A editing). + // (command-W close, command-M minimize, command-` cycle). match ks.key.as_str() { "q" => self.exit = true, "v" => { @@ -1389,8 +1394,8 @@ impl PocketRoot { "alt": ks.modifiers.alt, "ctl": ks.modifiers.control, })), } - // Consumed: don't let AppKit hunt the (empty) menu bar - // for a key equivalent and beep. + // Consumed: don't let the platform menu system hunt for + // an unavailable key equivalent. cx.stop_propagation(); return; } @@ -1859,7 +1864,7 @@ fn main() -> Result<()> { }), is_resizable: resizable, window_min_size: Some(size(px(240.0), px(180.0))), - app_id: Some("dev.pocket-stack.macos".into()), + app_id: Some("dev.pocket-stack.desktop-host".into()), ..Default::default() }; let args = ARGS @@ -1871,7 +1876,7 @@ fn main() -> Result<()> { cx.new(|cx| match PocketRoot::boot(args, window, cx) { Ok(root) => root, Err(e) => { - eprintln!("pocket-macos: {e:#}"); + eprintln!("pocket-desktop-host: {e:#}"); std::process::exit(1); } }) @@ -1884,7 +1889,7 @@ fn main() -> Result<()> { cx.activate(true); } Err(e) => { - eprintln!("pocket-macos: open_window failed: {e:#}"); + eprintln!("pocket-desktop-host: open_window failed: {e:#}"); std::process::exit(1); } } @@ -1990,7 +1995,7 @@ mod tests { "title": "Pocket Desktop", "version": "0.1.0" }, - "target": { "id": "macos-app", "hostAbi": 4 }, + "target": { "id": HOST_ID, "hostAbi": 4 }, "roles": { "systemUI": "dev.pocket-stack.shell" }, "lifecycle": { "backgroundExecution": "suspend" }, "installation": { @@ -2009,7 +2014,7 @@ mod tests { "entry": "apps/shell/main.tsx", "framework": "solid" }, - "target": { "id": "macos-app", "hostAbi": 4 }, + "target": { "id": HOST_ID, "hostAbi": 4 }, "viewport": { "logical": [800, 600], "physical": [1600, 1200], diff --git a/hosts/web/app-instance.html b/hosts/web/app-instance.html new file mode 100644 index 00000000..b7bd8dff --- /dev/null +++ b/hosts/web/app-instance.html @@ -0,0 +1,5 @@ + + +Pocket AppInstance + + diff --git a/hosts/web/app-instance.js b/hosts/web/app-instance.js new file mode 100644 index 00000000..50313441 --- /dev/null +++ b/hosts/web/app-instance.js @@ -0,0 +1,102 @@ +// One browser AppInstance. A same-origin hidden iframe loads this module so +// every Pocket package receives an independent JavaScript Realm and wasm Ui. +// The parent System host owns scheduling and composition through this narrow +// object; package code never receives another realm or framebuffer. + +import { createWasmUi } from "./wasm-ops.js"; + +async function requiredFetch(url, kind) { + const response = await fetch(url); + if (!response.ok) throw new Error(`${kind} not found at ${url} (${response.status})`); + return response; +} + +export async function create(options) { + const viewport = [...(options.viewport ?? [480, 272])]; + const density = options.rasterDensity ?? 1; + const wasmBytes = await (await requiredFetch(options.wasmUrl, "PocketJS wasm")).arrayBuffer(); + const wasm = await createWasmUi(wasmBytes, { + width: viewport[0], + height: viewport[1], + rasterDensity: density, + }); + + const incoming = []; + const outgoing = []; + const companions = new Set(options.companions ?? []); + wasm.ops.svcOpen = (name) => companions.has(name); + wasm.ops.svcPoll = () => { + if (incoming.length === 0) return null; + const batch = incoming.splice(0).join("\n"); + return `${batch}\n`; + }; + wasm.ops.svcSend = (line) => { + if (typeof line === "string" && outgoing.length < 1024) outgoing.push(line); + }; + if (options.surfaces) wasm.ops.__surfaces = { ...options.surfaces }; + + globalThis.ui = wasm.ops; + globalThis.frame = undefined; + globalThis.__simHz = options.simHz ?? 60; + globalThis.__pocketApp = options.packageId; + const pak = await fetch(options.pakUrl); + globalThis.__pak = pak.ok ? await pak.arrayBuffer() : undefined; + const source = await (await requiredFetch(options.bundleUrl, "Pocket app bundle")).text(); + new Function(`${source}\n//# sourceURL=${options.packageId}.js`)(); + if (typeof globalThis.frame !== "function") { + throw new Error(`${options.packageId} evaluated but installed no frame()`); + } + + return { + packageId: options.packageId, + viewport, + step(buttons = 0) { + globalThis.frame(buttons, 0x8080); + wasm.tick(); + }, + render() { + return wasm.render(); + }, + renderComposited() { + return wasm.renderComposited(); + }, + resize(width, height) { + wasm.resizeViewport(width, height); + viewport[0] = width; + viewport[1] = height; + if (typeof globalThis.__pocketResizeViewport === "function") { + globalThis.__pocketResizeViewport(width, height); + } + }, + drawHash() { + return wasm.drawHash ? wasm.drawHash() : 0n; + }, + bindings() { + return wasm.compositorBindings(); + }, + frames() { + return wasm.compositorFrames(); + }, + uploadSurface(handle, pixels, width, height) { + return wasm.uploadCompositorSurface(handle, pixels, width, height); + }, + freeSurface(handle) { + wasm.freeCompositorSurface(handle); + }, + sendService(line) { + incoming.push(typeof line === "string" ? line : JSON.stringify(line)); + }, + drainService() { + return outgoing.splice(0); + }, + dispose() { + incoming.length = 0; + outgoing.length = 0; + globalThis.frame = undefined; + globalThis.ui = undefined; + globalThis.__pak = undefined; + }, + }; +} + +globalThis.PocketAppInstance = { create }; diff --git a/hosts/web/system-engine.js b/hosts/web/system-engine.js new file mode 100644 index 00000000..d03e08e8 --- /dev/null +++ b/hosts/web/system-engine.js @@ -0,0 +1,452 @@ +// Browser Pocket System host. Each package runs in an independent same-origin +// iframe Realm with its own wasm Ui. This module owns AppInstance lifecycle, +// focused scheduling, child raster retention and browser input adaptation. + +const BTN = { + SELECT: 0x0001, + START: 0x0008, + UP: 0x0010, + RIGHT: 0x0020, + DOWN: 0x0040, + LEFT: 0x0080, + LTRIGGER: 0x0100, + RTRIGGER: 0x0200, + TRIANGLE: 0x1000, + CIRCLE: 0x2000, + CROSS: 0x4000, + SQUARE: 0x8000, +}; + +const KEY_BUTTONS = { + ArrowUp: BTN.UP, + ArrowRight: BTN.RIGHT, + ArrowDown: BTN.DOWN, + ArrowLeft: BTN.LEFT, + KeyZ: BTN.CROSS, + Enter: BTN.CROSS, + KeyX: BTN.CIRCLE, + KeyA: BTN.SQUARE, + KeyS: BTN.TRIANGLE, + KeyQ: BTN.LTRIGGER, + KeyW: BTN.RTRIGGER, + ShiftLeft: BTN.SELECT, + ShiftRight: BTN.SELECT, + Space: BTN.START, +}; + +const NAMED_KEYS = { + Backspace: "Backspace", + Delete: "Delete", + Enter: "Enter", + Tab: "Tab", + ArrowLeft: "Left", + ArrowRight: "Right", + ArrowUp: "Up", + ArrowDown: "Down", + Home: "Home", + End: "End", + PageUp: "PageUp", + PageDown: "PageDown", + Escape: "Escape", + F1: "F1", + F2: "F2", + F3: "F3", + F4: "F4", + F5: "F5", + F6: "F6", + F7: "F7", + F8: "F8", + F9: "F9", + F10: "F10", + F11: "F11", + F12: "F12", +}; + +function absolute(url, base = location.href) { + return new URL(url, base).href; +} + +export function validateSystemPlan(plan) { + if (!plan || plan.target?.id !== "web-app" || plan.target?.hostAbi !== 4) { + throw new Error("browser System host requires a web-app ABI 4 ResolvedSystemPlan"); + } + if (!plan.systemUI || plan.roles?.systemUI !== plan.systemUI.package) { + throw new Error("resolved SystemUI role does not match its package plan"); + } + if (plan.systemUI.plan?.features?.["ui.compositor-surfaces"] !== true) { + throw new Error("SystemUI plan lacks ui.compositor-surfaces"); + } + const packages = [plan.systemUI, ...(plan.applications ?? [])]; + const ids = new Set(); + const outputs = new Set(); + for (const entry of packages) { + if (ids.has(entry.package)) throw new Error(`duplicate System package ${entry.package}`); + ids.add(entry.package); + const output = entry.plan?.app?.output; + if (!output || outputs.has(output)) throw new Error(`duplicate or missing artifact output ${output}`); + outputs.add(output); + if (entry.plan.app.id !== entry.package) { + throw new Error(`package ${entry.package} carries plan for ${entry.plan.app.id}`); + } + if (entry.plan.target?.id !== "web-app" || entry.plan.target?.hostAbi !== 4) { + throw new Error(`package ${entry.package} was not resolved for web-app ABI 4`); + } + } + for (const entry of plan.applications ?? []) { + if ((entry.plan.companions ?? []).length > 0) { + throw new Error(`AppInstance ${entry.package} declares unsupported companions`); + } + } + const installed = new Set(plan.installation?.installedPackages ?? []); + if (installed.size !== ids.size || [...ids].some((id) => !installed.has(id))) { + throw new Error("resolved packages do not match the System installation snapshot"); + } +} + +async function createRealm(instanceUrl, options) { + const iframe = document.createElement("iframe"); + iframe.hidden = true; + iframe.tabIndex = -1; + iframe.setAttribute("aria-hidden", "true"); + iframe.src = instanceUrl; + document.body.appendChild(iframe); + await new Promise((resolve, reject) => { + iframe.addEventListener("load", resolve, { once: true }); + iframe.addEventListener("error", () => reject(new Error(`failed to load ${instanceUrl}`)), { + once: true, + }); + }); + const factory = iframe.contentWindow?.PocketAppInstance; + if (!factory) { + iframe.remove(); + throw new Error("app-instance Realm did not publish PocketAppInstance"); + } + try { + return { iframe, api: await factory.create(options) }; + } catch (error) { + iframe.remove(); + throw error; + } +} + +export async function mountPocketSystem(canvas, options = {}) { + const planUrl = absolute(options.planUrl ?? "./pocket.system.plan.json"); + const response = await fetch(planUrl); + if (!response.ok) throw new Error(`System plan not found at ${planUrl}`); + const plan = await response.json(); + validateSystemPlan(plan); + + const base = new URL(options.baseUrl ?? "./", planUrl); + const distBase = absolute(options.distBase ?? "./dist/", base); + const wasmUrl = absolute(options.wasmUrl ?? "./pocketjs.wasm", base); + const instanceUrl = absolute(options.instanceUrl ?? "./app-instance.html", base); + const log = options.onLog ?? (() => {}); + const shellPlan = plan.systemUI.plan; + const viewport = [...shellPlan.viewport.logical]; + canvas.width = viewport[0]; + canvas.height = viewport[1]; + const context = canvas.getContext("2d"); + context.imageSmoothingEnabled = false; + let image = context.createImageData(viewport[0], viewport[1]); + + const catalog = new Map(); + const surfaces = {}; + plan.applications.forEach((entry, handle) => { + catalog.set(handle, entry); + surfaces[entry.package] = handle; + }); + const artifact = (entry, extension) => + absolute(`${entry.plan.app.output}.${extension}`, distBase); + const shellRealm = await createRealm(instanceUrl, { + packageId: plan.systemUI.package, + wasmUrl, + bundleUrl: artifact(plan.systemUI, "js"), + pakUrl: artifact(plan.systemUI, "pak"), + viewport, + rasterDensity: shellPlan.viewport.rasterDensity, + companions: shellPlan.companions, + surfaces, + }); + const shell = shellRealm.api; + shell.sendService({ t: "hello", w: viewport[0], h: viewport[1], epoch: Date.now() }); + + const children = new Map(); + let stopped = false; + let raf = 0; + let accumulator = 0; + let last = performance.now(); + let heldButtons = 0; + let focusedHandle = null; + let primaryDown = false; + + async function openChild(handle) { + if (children.has(handle)) return children.get(handle); + const entry = catalog.get(handle); + if (!entry) throw new Error(`unknown compositor surface handle ${handle}`); + const pending = createRealm(instanceUrl, { + packageId: entry.package, + wasmUrl, + bundleUrl: artifact(entry, "js"), + pakUrl: artifact(entry, "pak"), + viewport: entry.plan.viewport.logical, + rasterDensity: entry.plan.viewport.rasterDensity, + companions: [], + }).then((realm) => ({ ...realm, entry })); + children.set(handle, pending); + try { + const child = await pending; + children.set(handle, child); + log(`started AppInstance ${entry.package}`); + return child; + } catch (error) { + children.delete(handle); + throw error; + } + } + + function closeChild(handle) { + const child = children.get(handle); + children.delete(handle); + shell.freeSurface(handle); + if (!child || child instanceof Promise) { + child?.then((resolved) => { + resolved.api.dispose(); + resolved.iframe.remove(); + }); + return; + } + child.api.dispose(); + child.iframe.remove(); + log(`removed AppInstance ${child.entry.package}`); + } + + async function reconcile() { + const bindings = shell.bindings(); + const live = new Set(bindings.map((binding) => binding.handle)); + for (const handle of [...children.keys()]) { + if (!live.has(handle)) closeChild(handle); + } + await Promise.all([...live].map((handle) => openChild(handle).catch((error) => { + log(`AppInstance ${handle} failed: ${error.message ?? error}`); + return null; + }))); + + const frames = shell.frames(); + const visible = new Set(frames.map((frame) => frame.handle)); + const focused = [...frames].reverse().find((frame) => frame.focused); + focusedHandle = focused?.handle ?? null; + const background = plan.lifecycle.backgroundExecution; + const ordered = [...frames].sort((a, b) => { + if (a.handle === focusedHandle) return -1; + if (b.handle === focusedHandle) return 1; + return b.order - a.order; + }); + if (background === "continue") { + for (const handle of live) { + if (!visible.has(handle)) ordered.push({ handle, focused: false, order: -1 }); + } + } + + for (const fact of ordered) { + const child = children.get(fact.handle); + if (!child || child instanceof Promise) continue; + child.api.step(fact.handle === focusedHandle ? heldButtons : 0); + if (visible.has(fact.handle)) { + const pixels = child.api.render(); + const [width, height] = child.api.viewport; + shell.uploadSurface(fact.handle, pixels, width, height); + } + } + } + + function processShellIntents() { + for (const line of shell.drainService()) { + let message; + try { + message = JSON.parse(line); + } catch { + continue; + } + if (message.t === "cursor" && typeof message.k === "string") { + canvas.style.cursor = message.k === "default" ? "default" : message.k; + } else if (message.t === "copy" && typeof message.text === "string") { + navigator.clipboard?.writeText(message.text).catch(() => {}); + } else if (message.t === "paste-req") { + navigator.clipboard?.readText().then((text) => { + if (text) shell.sendService({ t: "paste", text }); + }).catch(() => {}); + } else if (message.t === "quit") { + options.onQuit?.(); + } + } + } + + async function step() { + shell.step(0); + await reconcile(); + processShellIntents(); + } + + function paint() { + image.data.set(shell.renderComposited()); + context.putImageData(image, 0, 0); + } + + async function tick(now) { + if (stopped) return; + let elapsed = Math.min(250, now - last); + last = now; + accumulator += elapsed; + let count = 0; + while (accumulator >= 1000 / 60 && count < 4) { + await step(); + accumulator -= 1000 / 60; + count++; + } + if (count > 0) paint(); + raf = requestAnimationFrame(tick); + } + + function logicalPoint(event) { + const rect = canvas.getBoundingClientRect(); + return { + x: Math.max(0, Math.min(viewport[0] - 1, (event.clientX - rect.left) * viewport[0] / rect.width)), + y: Math.max(0, Math.min(viewport[1] - 1, (event.clientY - rect.top) * viewport[1] / rect.height)), + }; + } + + function sendMouse(event, down, button = 1) { + const point = logicalPoint(event); + shell.sendService({ + t: "mouse", + x: point.x, + y: point.y, + d: down, + ...(button === 2 ? { b: 2 } : {}), + sh: event.shiftKey, + }); + } + + const onPointerMove = (event) => sendMouse(event, primaryDown); + const onPointerDown = (event) => { + canvas.focus(); + if (event.button === 0) primaryDown = true; + if (event.button === 0 || event.button === 2) sendMouse(event, true, event.button === 2 ? 2 : 1); + event.preventDefault(); + }; + const onPointerUp = (event) => { + if (event.button === 0) primaryDown = false; + if (event.button === 0 || event.button === 2) sendMouse(event, false, event.button === 2 ? 2 : 1); + event.preventDefault(); + }; + const onWheel = (event) => { + shell.sendService({ t: "scroll", dy: event.deltaY }); + event.preventDefault(); + }; + const onKeyDown = (event) => { + const button = KEY_BUTTONS[event.code]; + if (focusedHandle !== null && button !== undefined && !event.metaKey && !event.ctrlKey && !event.altKey) { + heldButtons |= button; + event.preventDefault(); + return; + } + const command = event.metaKey || event.ctrlKey; + if (command) { + if (event.key.toLowerCase() === "v") { + navigator.clipboard?.readText().then((text) => { + if (text) shell.sendService({ t: "paste", text }); + }).catch(() => {}); + event.preventDefault(); + return; + } + if (event.key.toLowerCase() === "q") { + options.onQuit?.(); + event.preventDefault(); + return; + } + shell.sendService({ + t: "key", + k: event.key.toLowerCase(), + cmd: true, + sh: event.shiftKey, + alt: event.altKey, + ctl: event.ctrlKey, + }); + event.preventDefault(); + return; + } + const named = NAMED_KEYS[event.key]; + if (named) { + shell.sendService({ t: "key", k: named, sh: event.shiftKey, alt: event.altKey, ctl: event.ctrlKey }); + event.preventDefault(); + } else if (event.key.length === 1 && !event.altKey) { + shell.sendService({ t: "ch", s: event.key }); + event.preventDefault(); + } + }; + const onKeyUp = (event) => { + const button = KEY_BUTTONS[event.code]; + if (button !== undefined) { + heldButtons &= ~button; + if (focusedHandle !== null) event.preventDefault(); + } + }; + const onBlur = () => { + primaryDown = false; + heldButtons = 0; + }; + canvas.tabIndex = 0; + canvas.addEventListener("pointermove", onPointerMove); + canvas.addEventListener("pointerdown", onPointerDown); + window.addEventListener("pointerup", onPointerUp); + canvas.addEventListener("contextmenu", (event) => event.preventDefault()); + canvas.addEventListener("wheel", onWheel, { passive: false }); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", onBlur); + + function resizeShell(width, height) { + width = Math.max(320, Math.min(4096, Math.round(width))); + height = Math.max(240, Math.min(4096, Math.round(height))); + if (width === viewport[0] && height === viewport[1]) return; + viewport[0] = width; + viewport[1] = height; + shell.resize(width, height); + shell.sendService({ t: "resize", w: width, h: height }); + canvas.width = width; + canvas.height = height; + image = context.createImageData(width, height); + } + const resizeObserver = options.liveResize === false || typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) resizeShell(width, height); + }); + resizeObserver?.observe(canvas); + + await step(); + paint(); + log(`booted ${plan.system.title} with ${plan.applications.length} installed applications`); + raf = requestAnimationFrame(tick); + + return { + plan, + stop() { + if (stopped) return; + stopped = true; + cancelAnimationFrame(raf); + for (const handle of [...children.keys()]) closeChild(handle); + shell.dispose(); + shellRealm.iframe.remove(); + canvas.removeEventListener("pointermove", onPointerMove); + canvas.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("pointerup", onPointerUp); + canvas.removeEventListener("wheel", onWheel); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", onBlur); + resizeObserver?.disconnect(); + }, + }; +} diff --git a/hosts/web/wasm-ops.d.ts b/hosts/web/wasm-ops.d.ts index 9bc17604..7842c85b 100644 --- a/hosts/web/wasm-ops.d.ts +++ b/hosts/web/wasm-ops.d.ts @@ -11,14 +11,33 @@ export interface WasmUi { exports: WebAssembly.Exports & { memory: WebAssembly.Memory }; /** Reset the core and set raster samples per logical pixel (default 1). */ init(rasterDensity?: number): void; + /** Resize a dynamic browser viewport. */ + resizeViewport(width: number, height: number): void; /** Advance exactly one fixed-dt (1/60 s) frame. */ tick(): void; /** Hash the current DrawList without rasterizing it; null for an older wasm. */ drawHash: (() => bigint) | null; + compositorBindings(): Array<{ handle: number; focused: boolean }>; + compositorFrames(): Array<{ + handle: number; + full: [number, number, number, number]; + clip: [number, number, number, number]; + focused: boolean; + order: number; + }>; + uploadCompositorSurface( + handle: number, + pixels: Uint8Array, + width: number, + height: number, + ): number; + freeCompositorSurface(handle: number): void; /** Rasterize the byte-exact RGBA8 framebuffer at the logical viewport size. */ render(): Uint8Array; /** Rasterize directly at an integer physical scale from 1 through 4. */ renderScaled(scale: number): Uint8Array; + /** Render child surfaces at their compositor instructions. */ + renderComposited(scale?: number): Uint8Array; /** Repaint only changed regions at the logical viewport size. */ renderIncremental(): Uint8Array; /** Repaint only changed regions at an integer physical scale. */ diff --git a/hosts/web/wasm-ops.js b/hosts/web/wasm-ops.js index e94d7187..5b0afecf 100644 --- a/hosts/web/wasm-ops.js +++ b/hosts/web/wasm-ops.js @@ -36,11 +36,16 @@ export async function createWasmUi(wasm, options = {}) { return value; } - const viewportWidth = integerInRange(options.width ?? FB_W, "viewport width", 1, 32000); - const viewportHeight = integerInRange(options.height ?? FB_H, "viewport height", 1, 32000); + let viewportWidth = integerInRange(options.width ?? FB_W, "viewport width", 1, 32000); + let viewportHeight = integerInRange(options.height ?? FB_H, "viewport height", 1, 32000); const initialDensity = integerInRange(options.rasterDensity ?? 1, "rasterDensity", 1, 255); + // node id -> { handle, focused }. The core owns geometry/painter order; + // this mirror gives a browser AppSupervisor the complete lifecycle binding + // set, including surfaces currently hidden by shell opacity. + const compositorBindings = new Map(); const init = (rasterDensity = initialDensity) => { + compositorBindings.clear(); ex.ui_init(integerInRange(rasterDensity, "rasterDensity", 1, 255)); // Older wasm binaries predate ui_set_viewport (same convention as // drawHash): tolerate them at the stock size, fail loud otherwise. @@ -69,7 +74,10 @@ export async function createWasmUi(wasm, options = {}) { const ops = { __viewport: { w: viewportWidth, h: viewportHeight }, createNode: (type) => ex.ui_create_node(type), - destroyNode: (id) => ex.ui_destroy_node(id), + destroyNode: (id) => { + compositorBindings.delete(id); + ex.ui_destroy_node(id); + }, insertBefore: (parent, child, anchor) => ex.ui_insert_before(parent, child, anchor), removeChild: (parent, child) => ex.ui_remove_child(parent, child), setStyle: (id, styleId) => ex.ui_set_style(id, styleId), @@ -84,8 +92,11 @@ export async function createWasmUi(wasm, options = {}) { replaceText: (id, str) => withStr(str, (p, l) => ex.ui_replace_text(id, p, l)), uploadTexture: (buf, w, h, psm) => withBytes(buf, (p, l) => ex.ui_upload_texture(p, l, w, h, psm)), setImage: (id, tex) => ex.ui_set_image(id, tex), - setCompositorSurface: (id, surface, focused) => - ex.ui_set_compositor_surface(id, surface, focused), + setCompositorSurface: (id, surface, focused) => { + if (surface < 0) compositorBindings.delete(id); + else compositorBindings.set(id, { handle: surface, focused: focused !== 0 }); + ex.ui_set_compositor_surface(id, surface, focused); + }, setSprite: (id, atlas, frames, cols, step) => ex.ui_set_sprite(id, atlas, frames, cols, step), animate: (id, propId, to, durMs, easing, delayMs) => ex.ui_animate(id, propId, to, durMs, easing, delayMs), @@ -153,10 +164,55 @@ export async function createWasmUi(wasm, options = {}) { exports: ex, /** Reset the core and set raster samples per logical pixel (default 1). */ init, + /** Resize a dynamic browser viewport and keep the guest-visible fact aligned. */ + resizeViewport(width, height) { + viewportWidth = integerInRange(width, "viewport width", 1, 32000); + viewportHeight = integerInRange(height, "viewport height", 1, 32000); + if (!ex.ui_set_viewport) { + throw new Error("this pocketjs.wasm predates ui_set_viewport — rebuild it: bun tools/wasm.ts"); + } + ex.ui_set_viewport(viewportWidth, viewportHeight); + ops.__viewport.w = viewportWidth; + ops.__viewport.h = viewportHeight; + }, /** Advance exactly one fixed-dt (1/60 s) frame. */ tick: () => ex.ui_tick(), /** Hash the current DrawList without rasterizing it (BigInt, wasm i64). */ drawHash: ex.ui_draw_hash ? () => ex.ui_draw_hash() : null, + /** Every live shell binding, including hidden/minimized surface nodes. */ + compositorBindings() { + return Array.from(compositorBindings.values(), (entry) => ({ ...entry })); + }, + /** Visible child frames in exact shell painter order. */ + compositorFrames() { + if (!ex.ui_compositor_frames || !ex.ui_compositor_frames_ptr) return []; + const count = ex.ui_compositor_frames(); + const ptr = ex.ui_compositor_frames_ptr(); + const words = new Uint32Array(ex.memory.buffer, ptr, count * 10); + const floats = new Float32Array(ex.memory.buffer, ptr, count * 10); + const frames = []; + for (let i = 0; i < count; i++) { + const at = i * 10; + frames.push({ + handle: words[at], + full: [floats[at + 1], floats[at + 2], floats[at + 3], floats[at + 4]], + clip: [floats[at + 5], floats[at + 6], floats[at + 7], floats[at + 8]], + focused: words[at + 9] !== 0, + order: i, + }); + } + return frames; + }, + /** Retain one child RGBA8 framebuffer in the shell compositor. */ + uploadCompositorSurface(handle, pixels, width, height) { + if (!ex.ui_compositor_upload_surface) return -1; + return withBytes(pixels, (ptr, len) => + ex.ui_compositor_upload_surface(handle, ptr, len, width, height), + ); + }, + freeCompositorSurface(handle) { + if (ex.ui_compositor_free_surface) ex.ui_compositor_free_surface(handle); + }, /** Rasterize the byte-exact framebuffer at the logical viewport size. */ render() { return framebufferView(ex.ui_render(), 1); @@ -166,6 +222,14 @@ export async function createWasmUi(wasm, options = {}) { scale = integerInRange(scale, "render scale", 1, 4); return framebufferView(ex.ui_render_scaled(scale), scale); }, + /** Render shell chrome and child rasters in SURFACE_QUAD painter order. */ + renderComposited(scale = 1) { + scale = integerInRange(scale, "render scale", 1, 4); + const ptr = scale === 1 + ? ex.ui_render_composited?.() + : ex.ui_render_composited_scaled?.(scale); + return framebufferView(ptr || ex.ui_render_scaled(scale), scale); + }, /** Repaint only changed regions, falling back for older wasm builds. */ renderIncremental() { const ptr = ex.ui_render_incremental diff --git a/site/content/blog/pocket-pi-on-esp32-p4.md b/site/content/blog/pocket-pi-on-esp32-p4.md index 677ad06f..471c9fa1 100644 --- a/site/content/blog/pocket-pi-on-esp32-p4.md +++ b/site/content/blog/pocket-pi-on-esp32-p4.md @@ -45,7 +45,7 @@ With the runtime working, we measured what the board would actually have to hold Booting the full bundle takes 46.6 MB of heap — 27.4 MB of it just compiled JS functions — against 32 MB of PSRAM that also has to hold framebuffers, Wi-Fi buffers, and TLS. That chart closed the "ship the desktop profile" debate in an afternoon and split the runtime into two compositions of one family: - + crates/pocket-pi @@ -56,7 +56,7 @@ Booting the full bundle takes 46.6 MB of heap — 27.4 MB of it just compiled JS - hosts/macos + hosts/desktop crates/pocket-pi-device-ui one draw list · one hit map · one font set diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index 02e206e6..8da87aee 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -171,6 +171,8 @@ describe("platform registry", () => { "pocketbook", "macos-widget", "macos-app", + "linux-app", + "web-app", ]); expect(validatePlatformContractRegistry(POCKET_PLATFORM_CONTRACTS)).toEqual([]); expect(POCKET_TARGETS.psp.capabilities).toEqual([ @@ -245,6 +247,51 @@ describe("platform registry", () => { max: [4096, 4096], acceptsFixed: true, }); + expect(POCKET_TARGETS["linux-app"]).toEqual({ + hostAbi: 4, + platform: "linux", + form: "window", + display: { + physicalViewport: [1280, 800], + logicalViewports: [[800, 600]], + dynamicViewport: { + min: [240, 180], + max: [4096, 4096], + acceptsFixed: true, + }, + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: [ + "input.buttons", + "display.viewport.live", + "text.glyphs.baked", + "text.layout.native", + ], + roleCapabilities: { systemUI: ["ui.compositor-surfaces"] }, + }); + expect(POCKET_TARGETS["web-app"]).toEqual({ + hostAbi: 4, + platform: "web", + form: "window", + display: { + physicalViewport: [800, 600], + logicalViewports: [[800, 600]], + dynamicViewport: { + min: [320, 240], + max: [4096, 4096], + acceptsFixed: true, + }, + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: [ + "input.buttons", + "display.viewport.live", + "text.glyphs.baked", + ], + roleCapabilities: { systemUI: ["ui.compositor-surfaces"] }, + }); }); test("TargetId and capability registries extend without changing the resolver", () => { diff --git a/tests/pocket-system.test.ts b/tests/pocket-system.test.ts index 62573e2c..87d842be 100644 --- a/tests/pocket-system.test.ts +++ b/tests/pocket-system.test.ts @@ -21,6 +21,22 @@ async function packageInputs(system: any = systemInput) { } describe("Pocket System resolution", () => { + test("resolves the same installed package model for the browser System host", async () => { + const resolution = validateAndResolveSystemPlan(systemInput, { + target: "web-app", + packages: await packageInputs(), + }); + expect(resolution.ok).toBe(true); + if (!resolution.ok) return; + expect(resolution.plan.target).toEqual({ id: "web-app", hostAbi: 4 }); + expect(resolution.plan.systemUI.plan.features["ui.compositor-surfaces"]).toBe(true); + expect( + resolution.plan.applications.every( + (entry) => entry.plan.features["ui.compositor-surfaces"] !== true, + ), + ).toBe(true); + }); + test("preserves each installed package's complete resolved plan", async () => { const packages = await packageInputs(); const resolution = validateAndResolveSystemPlan(systemInput, { diff --git a/tests/web-system-host.test.ts b/tests/web-system-host.test.ts new file mode 100644 index 00000000..023006be --- /dev/null +++ b/tests/web-system-host.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { validateSystemPlan } from "../hosts/web/system-engine.js"; +import { validateAndResolveSystemPlan } from "@pocketjs/framework/manifest"; +import systemInput from "./fixtures/systems/managed-desktop.json"; + +async function resolvedWebSystem() { + const installed = new Set(systemInput.installation.installedPackages); + const packages = await Promise.all( + systemInput.applications.catalog + .filter((entry) => installed.has(entry.package)) + .map(async (entry) => ({ + source: entry.manifest, + manifest: await Bun.file(entry.manifest).json(), + })), + ); + const result = validateAndResolveSystemPlan(systemInput, { + target: "web-app", + packages, + }); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + return result.plan; +} + +describe("browser Pocket System host", () => { + test("accepts a complete resolved web System plan", async () => { + const plan = await resolvedWebSystem(); + expect(() => validateSystemPlan(plan)).not.toThrow(); + }); + + test("rejects child companions and artifact collisions at its trust boundary", async () => { + const companions = structuredClone(await resolvedWebSystem()); + companions.applications[0].plan.companions = ["note"]; + expect(() => validateSystemPlan(companions)).toThrow("unsupported companions"); + + const collision = structuredClone(await resolvedWebSystem()); + collision.applications[1].plan.app.output = collision.applications[0].plan.app.output; + expect(() => validateSystemPlan(collision)).toThrow("duplicate or missing artifact output"); + }); +}); diff --git a/tools/bench-desktop.ts b/tools/bench-desktop.ts index eb4bcb12..6d46ffeb 100644 --- a/tools/bench-desktop.ts +++ b/tools/bench-desktop.ts @@ -1,5 +1,5 @@ // bun tools/bench-desktop.ts — the desktop markdown-editor benchmark: -// the pocket note on the gpui host (hosts/macos, docs/BACKENDS.md) against +// the pocket note on the gpui host (hosts/desktop, docs/BACKENDS.md) against // byte-identical web editors shelled by Tauri v2 (WKWebView) and Electron // (tools/bench-desktop/). Protocol follows the pocket-character measurement // (site/content/blog/pocket-character.md): same machine, hands off, process @@ -153,7 +153,7 @@ interface Launcher { disk: () => Promise; } -const pocketBin = `${root}hosts/macos/target/release/pocket-macos`; +const pocketBin = `${root}hosts/desktop/target/release/pocket-desktop-host`; const electronDir = `${root}tools/bench-desktop/electron`; const electronBin = `${electronDir}/node_modules/.bin/electron`; const tauriBin = `${root}tools/bench-desktop/tauri/src-tauri/target/release/bench-tauri`; diff --git a/tools/macos.ts b/tools/macos.ts index 693306d4..be49602c 100644 --- a/tools/macos.ts +++ b/tools/macos.ts @@ -1,5 +1,5 @@ // bun run macos [app] [flags…] — build + launch a PocketJS app on the gpui -// macOS host (hosts/macos over the macos-app target; docs/BACKENDS.md). +// macOS host (hosts/desktop over the macos-app target; docs/BACKENDS.md). // // bun run macos # the note, native text (the flagship) // bun run macos hero # a fixed-viewport console demo, @@ -13,7 +13,7 @@ // one ResolvedSystemPlan containing complete installed-package // plans; the host receives that file without child-plan field projection. // The windowed run stays attached to your terminal — ⌘Q quits. On exit the -// host prints its governor receipt: "pocket-macos: N ticks, M frames +// host prints its governor receipt: "pocket-desktop-host: N ticks, M frames // rendered" — a settled app should show M ≪ N. import { existsSync, mkdirSync } from "node:fs"; import { resolve as resolvePath } from "node:path"; @@ -30,9 +30,9 @@ const root = new URL("..", import.meta.url).pathname; // The host and the gpui backend are git-only crates (npm files map ships // this wrapper for parity with tools/note.ts, whose widget host is git-only // too) — fail with directions instead of a cargo error mid-build. -if (!existsSync(`${root}hosts/macos/Cargo.toml`)) { +if (!existsSync(`${root}hosts/desktop/Cargo.toml`)) { console.error( - "bun run macos needs a git checkout: hosts/macos and engine/backends/gpui are not part of the npm package (github.com/pocket-stack/pocketjs).", + "bun run macos needs a git checkout: hosts/desktop and engine/backends/gpui are not part of the npm package (github.com/pocket-stack/pocketjs).", ); process.exit(1); } @@ -45,7 +45,7 @@ const appDir = appArg.replace(/-main$/, ""); const manifestPath = `${root}apps/${appDir}/pocket.json`; if (!existsSync(manifestPath)) { - throw new Error(`pocket-macos: no manifest at apps/${appDir}/pocket.json`); + throw new Error(`pocket-desktop-host: no manifest at apps/${appDir}/pocket.json`); } const systemPath = `${root}apps/${appDir}/pocket.system.json`; let systemPlan: ResolvedSystemPlan | undefined; @@ -56,7 +56,7 @@ if (existsSync(systemPath)) { const system = validatePocketSystem(rawSystem); if (!system.ok) { throw new Error( - `pocket-macos: invalid Pocket System at apps/${appDir}/pocket.system.json: ` + + `pocket-desktop-host: invalid Pocket System at apps/${appDir}/pocket.system.json: ` + system.diagnostics .map((d) => `${d.path || "/"}: ${d.message}`) .join("; "), @@ -77,7 +77,7 @@ if (existsSync(systemPath)) { }); if (!resolution.ok) { throw new Error( - `pocket-macos: ${system.value.title} did not resolve against macos-app: ` + + `pocket-desktop-host: ${system.value.title} did not resolve against macos-app: ` + resolution.diagnostics .map((d) => `${d.path || "/"}: ${d.message}`) .join("; "), @@ -92,7 +92,7 @@ if (existsSync(systemPath)) { }); if (!resolution.ok) { throw new Error( - `pocket-macos: ${appDir} did not resolve against macos-app: ${resolution.diagnostics + `pocket-desktop-host: ${appDir} did not resolve against macos-app: ${resolution.diagnostics .map((d) => `${d.path || "/"}: ${d.message}`) .join("; ")}`, ); @@ -121,19 +121,19 @@ if (systemPlan) { systemPlanPath = `${root}.pocket/macos-app/${systemPlan.system.name}.system.plan.json`; await Bun.write(systemPlanPath, JSON.stringify(systemPlan, null, 2) + "\n"); } -await $`cargo build --release`.cwd(`${root}hosts/macos`); +await $`cargo build --release`.cwd(`${root}hosts/desktop`); if (buildOnly) { console.log( (systemPlan - ? `pocket-macos: built SystemUI ${plan.app.output} + ` + + ? `pocket-desktop-host: built SystemUI ${plan.app.output} + ` + `${systemPlan.applications.length} application packages` - : `pocket-macos: built ${plan.app.output}`) + " + release host", + : `pocket-desktop-host: built ${plan.app.output}`) + " + release host", ); process.exit(0); } -const bin = `${root}hosts/macos/target/release/pocket-macos`; +const bin = `${root}hosts/desktop/target/release/pocket-desktop-host`; const env = { ...process.env, RUST_LOG: process.env.RUST_LOG ?? "info" }; // Every host flag derives from the resolved plan (issue #295 landed the From a56f98912bd0dbd6c380fcbe69d1b31b744d8e7a Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:15:59 +0800 Subject: [PATCH 2/7] fix(web): preserve canvas position on focus --- hosts/web/system-engine.js | 6 +++++- tests/web-system-host.test.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/hosts/web/system-engine.js b/hosts/web/system-engine.js index d03e08e8..ad27aa2d 100644 --- a/hosts/web/system-engine.js +++ b/hosts/web/system-engine.js @@ -66,6 +66,10 @@ function absolute(url, base = location.href) { return new URL(url, base).href; } +export function focusCanvas(canvas) { + canvas.focus({ preventScroll: true }); +} + export function validateSystemPlan(plan) { if (!plan || plan.target?.id !== "web-app" || plan.target?.hostAbi !== 4) { throw new Error("browser System host requires a web-app ABI 4 ResolvedSystemPlan"); @@ -329,7 +333,7 @@ export async function mountPocketSystem(canvas, options = {}) { const onPointerMove = (event) => sendMouse(event, primaryDown); const onPointerDown = (event) => { - canvas.focus(); + focusCanvas(canvas); if (event.button === 0) primaryDown = true; if (event.button === 0 || event.button === 2) sendMouse(event, true, event.button === 2 ? 2 : 1); event.preventDefault(); diff --git a/tests/web-system-host.test.ts b/tests/web-system-host.test.ts index 023006be..35fe1dad 100644 --- a/tests/web-system-host.test.ts +++ b/tests/web-system-host.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { validateSystemPlan } from "../hosts/web/system-engine.js"; +import { focusCanvas, validateSystemPlan } from "../hosts/web/system-engine.js"; import { validateAndResolveSystemPlan } from "@pocketjs/framework/manifest"; import systemInput from "./fixtures/systems/managed-desktop.json"; @@ -22,6 +22,16 @@ async function resolvedWebSystem() { } describe("browser Pocket System host", () => { + test("focusing the canvas cannot move a double-click onto another surface", () => { + let options: FocusOptions | undefined; + focusCanvas({ + focus(next: FocusOptions) { + options = next; + }, + }); + expect(options).toEqual({ preventScroll: true }); + }); + test("accepts a complete resolved web System plan", async () => { const plan = await resolvedWebSystem(); expect(() => validateSystemPlan(plan)).not.toThrow(); From 1d6d42b9b88358b817bd12c455bb60333fd11d60 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:18:59 +0800 Subject: [PATCH 3/7] fix(web): reserve the null compositor surface --- hosts/web/system-engine.js | 18 ++++++++++++------ tests/web-system-host.test.ts | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/hosts/web/system-engine.js b/hosts/web/system-engine.js index ad27aa2d..c142a882 100644 --- a/hosts/web/system-engine.js +++ b/hosts/web/system-engine.js @@ -70,6 +70,17 @@ export function focusCanvas(canvas) { canvas.focus({ preventScroll: true }); } +export function createSurfaceCatalog(applications) { + const catalog = new Map(); + const surfaces = {}; + applications.forEach((entry, index) => { + const handle = index + 1; + catalog.set(handle, entry); + surfaces[entry.package] = handle; + }); + return { catalog, surfaces }; +} + export function validateSystemPlan(plan) { if (!plan || plan.target?.id !== "web-app" || plan.target?.hostAbi !== 4) { throw new Error("browser System host requires a web-app ABI 4 ResolvedSystemPlan"); @@ -153,12 +164,7 @@ export async function mountPocketSystem(canvas, options = {}) { context.imageSmoothingEnabled = false; let image = context.createImageData(viewport[0], viewport[1]); - const catalog = new Map(); - const surfaces = {}; - plan.applications.forEach((entry, handle) => { - catalog.set(handle, entry); - surfaces[entry.package] = handle; - }); + const { catalog, surfaces } = createSurfaceCatalog(plan.applications); const artifact = (entry, extension) => absolute(`${entry.plan.app.output}.${extension}`, distBase); const shellRealm = await createRealm(instanceUrl, { diff --git a/tests/web-system-host.test.ts b/tests/web-system-host.test.ts index 35fe1dad..5b03ddb3 100644 --- a/tests/web-system-host.test.ts +++ b/tests/web-system-host.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { focusCanvas, validateSystemPlan } from "../hosts/web/system-engine.js"; +import { + createSurfaceCatalog, + focusCanvas, + validateSystemPlan, +} from "../hosts/web/system-engine.js"; import { validateAndResolveSystemPlan } from "@pocketjs/framework/manifest"; import systemInput from "./fixtures/systems/managed-desktop.json"; @@ -32,6 +36,15 @@ describe("browser Pocket System host", () => { expect(options).toEqual({ preventScroll: true }); }); + test("assigns one-based compositor handles in installation order", async () => { + const plan = await resolvedWebSystem(); + const { catalog, surfaces } = createSurfaceCatalog(plan.applications); + expect(catalog.get(0)).toBeUndefined(); + expect(catalog.get(1)).toBe(plan.applications[0]); + expect(surfaces[plan.applications[0].package]).toBe(1); + expect(surfaces[plan.applications[1].package]).toBe(2); + }); + test("accepts a complete resolved web System plan", async () => { const plan = await resolvedWebSystem(); expect(() => validateSystemPlan(plan)).not.toThrow(); From 207a9d174c54476014f1bd76bec986437c84379c Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:20:09 +0800 Subject: [PATCH 4/7] feat(web): report first child composition --- hosts/web/system-engine.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/hosts/web/system-engine.js b/hosts/web/system-engine.js index c142a882..3d603c86 100644 --- a/hosts/web/system-engine.js +++ b/hosts/web/system-engine.js @@ -201,7 +201,7 @@ export async function mountPocketSystem(canvas, options = {}) { viewport: entry.plan.viewport.logical, rasterDensity: entry.plan.viewport.rasterDensity, companions: [], - }).then((realm) => ({ ...realm, entry })); + }).then((realm) => ({ ...realm, entry, composited: false })); children.set(handle, pending); try { const child = await pending; @@ -265,6 +265,10 @@ export async function mountPocketSystem(canvas, options = {}) { const pixels = child.api.render(); const [width, height] = child.api.viewport; shell.uploadSurface(fact.handle, pixels, width, height); + if (!child.composited) { + child.composited = true; + log(`composited AppInstance ${child.entry.package}`); + } } } } From acc69c9cd2d4ea06525b682cb37e96f41669ba70 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:21:42 +0800 Subject: [PATCH 5/7] fix(web): fail rejected surface uploads --- hosts/web/system-engine.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/hosts/web/system-engine.js b/hosts/web/system-engine.js index 3d603c86..0f1b5863 100644 --- a/hosts/web/system-engine.js +++ b/hosts/web/system-engine.js @@ -264,10 +264,15 @@ export async function mountPocketSystem(canvas, options = {}) { if (visible.has(fact.handle)) { const pixels = child.api.render(); const [width, height] = child.api.viewport; - shell.uploadSurface(fact.handle, pixels, width, height); + const texture = shell.uploadSurface(fact.handle, pixels, width, height); + if (texture < 0) { + throw new Error(`failed to upload compositor surface ${fact.handle}`); + } if (!child.composited) { child.composited = true; - log(`composited AppInstance ${child.entry.package}`); + log( + `composited AppInstance ${child.entry.package} (${width}x${height} into ${fact.full[2]}x${fact.full[3]})`, + ); } } } From 27d5e932ec45b65abe9663004151f817691ca765 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:27:53 +0800 Subject: [PATCH 6/7] fix(web): isolate compositor raster storage --- engine/wasm/src/lib.rs | 275 +++++++++++++++++++++++++++-------------- 1 file changed, 183 insertions(+), 92 deletions(-) diff --git a/engine/wasm/src/lib.rs b/engine/wasm/src/lib.rs index 9d00ca69..250e525d 100644 --- a/engine/wasm/src/lib.rs +++ b/engine/wasm/src/lib.rs @@ -30,17 +30,17 @@ use pocketjs_core::raster; static mut UI: Option = None; static mut FRAMEBUFFER: Vec = Vec::new(); -#[derive(Clone, Copy)] -struct CompositorTexture { - texture: i32, +#[derive(Clone)] +struct CompositorRaster { + pixels: Vec, width: u32, height: u32, } /// Browser System hosts upload visible child AppInstance framebuffers here. -/// Values are core texture handles indexed by the shell's compositor-surface -/// handle. They remain outside the guest HostOps contract. -static mut COMPOSITOR_TEXTURES: Vec> = Vec::new(); +/// Values are arbitrary-size RGBA rasters indexed by the shell's compositor +/// handle. They deliberately do not enter the pow2 guest image texture pool. +static mut COMPOSITOR_RASTERS: Vec> = Vec::new(); /// Staged compositor frame records. Each record is ten u32 words: /// handle, full x/y/w/h f32 bits, clip x/y/w/h f32 bits, focused (0/1). static mut COMPOSITOR_FRAMES: Vec = Vec::new(); @@ -81,7 +81,7 @@ pub extern "C" fn ui_init(raster_density: u32) { unsafe { UI = Some(Ui::new_with_raster_density(raster_density.max(1))); FRAMEBUFFER.clear(); - COMPOSITOR_TEXTURES.clear(); + COMPOSITOR_RASTERS.clear(); COMPOSITOR_FRAMES.clear(); DAMAGE_TRACKER = DamageTracker::new(); } @@ -207,28 +207,15 @@ pub extern "C" fn ui_compositor_upload_surface( } let slot = surface as usize; unsafe { - if COMPOSITOR_TEXTURES.len() <= slot { - COMPOSITOR_TEXTURES.resize(slot + 1, None); + if COMPOSITOR_RASTERS.len() <= slot { + COMPOSITOR_RASTERS.resize(slot + 1, None); } - if let Some(previous) = COMPOSITOR_TEXTURES[slot] { - ui().free_texture(previous.texture); - } - let texture = ui().upload_texture( - bytes(ptr, len), - width, - height, - pocketjs_core::spec::psm::PSM_8888, - ); - if texture < 0 { - COMPOSITOR_TEXTURES[slot] = None; - return -1; - } - COMPOSITOR_TEXTURES[slot] = Some(CompositorTexture { - texture, + COMPOSITOR_RASTERS[slot] = Some(CompositorRaster { + pixels: bytes(ptr, len).to_vec(), width, height, }); - texture + i32::try_from(surface).unwrap_or(-1) } } @@ -237,9 +224,7 @@ pub extern "C" fn ui_compositor_upload_surface( pub extern "C" fn ui_compositor_free_surface(surface: u32) { let slot = surface as usize; unsafe { - if let Some(entry) = COMPOSITOR_TEXTURES.get_mut(slot).and_then(Option::take) { - ui().free_texture(entry.texture); - } + COMPOSITOR_RASTERS.get_mut(slot).and_then(Option::take); } } @@ -409,67 +394,168 @@ fn draw_op_len(words: &[u32], at: usize) -> Option { } #[inline] +#[cfg(test)] fn packed_xy(x: i32, y: i32) -> u32 { (x as i16 as u16 as u32) | ((y as i16 as u16 as u32) << 16) } #[inline] +#[cfg(test)] fn packed_wh(width: i32, height: i32) -> u32 { (width as u16 as u32) | ((height as u16 as u32) << 16) } -/// Translate host-only SURFACE_QUAD instructions into ordinary texture quads -/// for the browser software compositor. Missing child rasters remain surface -/// instructions, preserving the shell's already-painted loading fallback. -fn composited_words(words: &[u32]) -> Vec { - let mut out = words.to_vec(); +fn render_segment( + ui: &Ui, + inherited_scissors: &[[u32; 3]], + words: &[u32], + framebuffer: &mut [u8], + scale: u32, +) { + if words.is_empty() { + return; + } + let mut staged = Vec::with_capacity(inherited_scissors.len() * 3 + words.len()); + for scissor in inherited_scissors { + staged.extend_from_slice(scissor); + } + staged.extend_from_slice(words); + raster::render_scaled_over(ui, &staged, framebuffer, scale); +} + +fn blend_surface( + entry: &CompositorRaster, + op: &[u32], + framebuffer: &mut [u8], + viewport_width: u32, + viewport_height: u32, + scale: u32, +) { + let full_x = f32::from_bits(op[2]); + let full_y = f32::from_bits(op[3]); + let full_w = f32::from_bits(op[4]).min(entry.width as f32).max(0.0); + let full_h = f32::from_bits(op[5]).min(entry.height as f32).max(0.0); + let clip_xy = op[6]; + let clip_wh = op[7]; + let clip_x = clip_xy as u16 as i16 as i32; + let clip_y = (clip_xy >> 16) as u16 as i16 as i32; + let clip_w = (clip_wh & 0xffff) as i32; + let clip_h = (clip_wh >> 16) as i32; + let scale_i = scale as i32; + let width = viewport_width as i32 * scale_i; + let height = viewport_height as i32 * scale_i; + let x0 = (clip_x * scale_i) + .max((full_x * scale as f32).ceil() as i32) + .max(0); + let y0 = (clip_y * scale_i) + .max((full_y * scale as f32).ceil() as i32) + .max(0); + let x1 = ((clip_x + clip_w) * scale_i) + .min(((full_x + full_w) * scale as f32).ceil() as i32) + .min(width); + let y1 = ((clip_y + clip_h) * scale_i) + .min(((full_y + full_h) * scale as f32).ceil() as i32) + .min(height); + if x0 >= x1 || y0 >= y1 { + return; + } + + for y in y0..y1 { + let source_y = (((y as f32 + 0.5) / scale as f32) - full_y).floor() as i32; + if !(0..entry.height as i32).contains(&source_y) { + continue; + } + for x in x0..x1 { + let source_x = (((x as f32 + 0.5) / scale as f32) - full_x).floor() as i32; + if !(0..entry.width as i32).contains(&source_x) { + continue; + } + let source = (source_y as usize * entry.width as usize + source_x as usize) * 4; + let destination = (y as usize * width as usize + x as usize) * 4; + let alpha = entry.pixels[source + 3] as u32; + if alpha == 0 { + continue; + } + if alpha == 255 { + framebuffer[destination..destination + 4] + .copy_from_slice(&entry.pixels[source..source + 4]); + continue; + } + let inverse = 255 - alpha; + for channel in 0..3 { + framebuffer[destination + channel] = ((entry.pixels[source + channel] as u32 + * alpha + + framebuffer[destination + channel] as u32 * inverse + + 127) + / 255) as u8; + } + framebuffer[destination + 3] = + (alpha + (framebuffer[destination + 3] as u32 * inverse + 127) / 255) as u8; + } + } +} + +/// Composite arbitrary-size child rasters at their SURFACE_QUAD painter +/// positions without entering the guest image texture namespace. +fn render_composited_words(ui: &Ui, words: &[u32], framebuffer: &mut [u8], scale: u32) { + let (viewport_width, viewport_height) = ui.viewport(); + raster::render_scaled(ui, &[], framebuffer, scale); + let mut inherited_scissors: Vec<[u32; 3]> = Vec::new(); + let mut segment_scissors: Vec<[u32; 3]> = Vec::new(); + let mut segment_start = 0usize; let mut at = 0usize; - while at < out.len() { - let Some(len) = draw_op_len(&out, at) else { + while at < words.len() { + let Some(len) = draw_op_len(words, at) else { break; }; let Some(end) = at.checked_add(len) else { break; }; - if end > out.len() { + if end > words.len() { break; } - if out[at] == pocketjs_core::spec::draw_op::SURFACE_QUAD { - let surface = out[at + 1] as usize; - let entry = unsafe { COMPOSITOR_TEXTURES.get(surface).and_then(|entry| *entry) }; - if let Some(entry) = entry { - let full_x = f32::from_bits(out[at + 2]); - let full_y = f32::from_bits(out[at + 3]); - let clip_xy = out[at + 6]; - let clip_wh = out[at + 7]; - let clip_x = clip_xy as u16 as i16 as i32; - let clip_y = (clip_xy >> 16) as u16 as i16 as i32; - let clip_w = (clip_wh & 0xffff) as i32; - let clip_h = (clip_wh >> 16) as i32; - let x0 = clip_x.max(full_x.ceil() as i32); - let y0 = clip_y.max(full_y.ceil() as i32); - let x1 = (clip_x + clip_w).min((full_x + entry.width as f32).ceil() as i32); - let y1 = (clip_y + clip_h).min((full_y + entry.height as f32).ceil() as i32); - if x0 < x1 && y0 < y1 { - let u0 = ((x0 as f32 - full_x) / entry.width as f32).clamp(0.0, 1.0); - let v0 = ((y0 as f32 - full_y) / entry.height as f32).clamp(0.0, 1.0); - let u1 = ((x1 as f32 - full_x) / entry.width as f32).clamp(0.0, 1.0); - let v1 = ((y1 as f32 - full_y) / entry.height as f32).clamp(0.0, 1.0); - out[at] = pocketjs_core::spec::draw_op::TEX_QUAD; - out[at + 1] = entry.texture as u32; - out[at + 2] = packed_xy(x0, y0); - out[at + 3] = packed_wh(x1 - x0, y1 - y0); - out[at + 4] = u0.to_bits(); - out[at + 5] = v0.to_bits(); - out[at + 6] = u1.to_bits(); - out[at + 7] = v1.to_bits(); - out[at + 8] = 0xffff_ffff; + match words[at] { + pocketjs_core::spec::draw_op::SURFACE_QUAD => { + render_segment( + ui, + &segment_scissors, + &words[segment_start..at], + framebuffer, + scale, + ); + let surface = words[at + 1] as usize; + if let Some(entry) = + unsafe { COMPOSITOR_RASTERS.get(surface).and_then(Option::as_ref) } + { + blend_surface( + entry, + &words[at..end], + framebuffer, + viewport_width as u32, + viewport_height as u32, + scale, + ); } + segment_start = end; + segment_scissors = inherited_scissors.clone(); } + pocketjs_core::spec::draw_op::SCISSOR => { + inherited_scissors.push([words[at], words[at + 1], words[at + 2]]); + } + pocketjs_core::spec::draw_op::SCISSOR_POP => { + inherited_scissors.pop(); + } + _ => {} } at = end; } - out + render_segment( + ui, + &segment_scissors, + &words[segment_start..], + framebuffer, + scale, + ); } // ---- DevTools ops (spec ops 18..22, docs/DEVTOOLS.md) ----------------------------- @@ -557,15 +643,10 @@ fn render_composited_at_scale(scale: u32) -> *const u8 { return core::ptr::null(); }; let dl: *const pocketjs_core::DrawList = u.draw(); - let words = unsafe { composited_words(&(*dl).words) }; let u_ref: &Ui = unsafe { &*(u as *const Ui) }; unsafe { FRAMEBUFFER.resize(bytes, 0); - if scale == 1 { - raster::render(u_ref, &words, &mut FRAMEBUFFER); - } else { - raster::render_scaled(u_ref, &words, &mut FRAMEBUFFER, scale); - } + render_composited_words(u_ref, &(*dl).words, &mut FRAMEBUFFER, scale); DAMAGE_TRACKER.invalidate(); FRAMEBUFFER.as_ptr() } @@ -655,9 +736,10 @@ pub extern "C" fn ui_render_incremental_scaled(scale: u32) -> *const u8 { #[cfg(test)] mod tests { use super::{ - composited_words, draw_hash, packed_wh, packed_xy, CompositorTexture, COMPOSITOR_TEXTURES, + draw_hash, packed_wh, packed_xy, render_composited_words, CompositorRaster, + COMPOSITOR_RASTERS, }; - use pocketjs_core::{raster, spec, Ui}; + use pocketjs_core::{spec, Ui}; #[test] fn draw_hash_is_stable_and_content_sensitive() { @@ -671,42 +753,51 @@ mod tests { fn browser_compositor_keeps_child_between_shell_ops() { let mut ui = Ui::new(); ui.set_viewport(4.0, 4.0); - let red = [255u8, 0, 0, 255].repeat(16); - let texture = ui.upload_texture(&red, 4, 4, spec::psm::PSM_8888); + let red = [255u8, 0, 0, 255].repeat(6); unsafe { - COMPOSITOR_TEXTURES = vec![Some(CompositorTexture { - texture, - width: 4, - height: 4, - })]; + COMPOSITOR_RASTERS = vec![ + None, + Some(CompositorRaster { + pixels: red, + width: 3, + height: 2, + }), + ]; } let words = [ spec::draw_op::RECT, packed_xy(0, 0), packed_wh(4, 4), - 0xff00_0000, + 0xffff_0000, + spec::draw_op::SCISSOR, + packed_xy(0, 0), + packed_wh(2, 2), spec::draw_op::SURFACE_QUAD, - 0, + 1, 0.0f32.to_bits(), 0.0f32.to_bits(), - 4.0f32.to_bits(), - 4.0f32.to_bits(), + 3.0f32.to_bits(), + 2.0f32.to_bits(), packed_xy(0, 0), - packed_wh(4, 4), + packed_wh(2, 2), 1, spec::draw_op::RECT, packed_xy(1, 1), packed_wh(2, 2), 0xff00_ff00, + spec::draw_op::SCISSOR_POP, ]; - let translated = composited_words(&words); - assert_eq!(translated[4], spec::draw_op::TEX_QUAD); - assert_eq!(translated[13], spec::draw_op::RECT); - let mut framebuffer = vec![0; 4 * 4 * 4]; - raster::render(&ui, &translated, &mut framebuffer); + render_composited_words(&ui, &words, &mut framebuffer, 1); assert_eq!(&framebuffer[0..4], &[255, 0, 0, 255]); let center = (1 * 4 + 1) * 4; assert_eq!(&framebuffer[center..center + 4], &[0, 255, 0, 255]); + let outside = 2 * 4; + assert_eq!(&framebuffer[outside..outside + 4], &[0, 0, 255, 255]); + let clipped_overlay = (2 * 4 + 2) * 4; + assert_eq!( + &framebuffer[clipped_overlay..clipped_overlay + 4], + &[0, 0, 255, 255] + ); } } From 36820a03aece112682438342345d6cd49272e1ec Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:30:47 +0800 Subject: [PATCH 7/7] test(platforms): accept desktop production targets --- tests/symbian-runtime.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/symbian-runtime.test.ts b/tests/symbian-runtime.test.ts index 68c01e22..49389ef7 100644 --- a/tests/symbian-runtime.test.ts +++ b/tests/symbian-runtime.test.ts @@ -180,13 +180,15 @@ describe("experimental Nokia E7 runtime profile", () => { expect([...catalog.blob.subarray(16)]).toEqual([4, 5]); }); - test("does not register an unproven production target", () => { + test("keeps the experimental E7 target out of the production registry", () => { expect(Object.keys(POCKET_TARGETS)).toEqual([ "psp", "vita", "pocketbook", "macos-widget", "macos-app", + "linux-app", + "web-app", ]); expect(POCKET_TARGETS).not.toHaveProperty(SYMBIAN_E7_DEV_TARGET_ID); expect(SYMBIAN_E7_DEV_HOST_ABI).toBe(4);