diff --git a/apps/blackberry-classic-demo/app.tsx b/apps/blackberry-classic-demo/app.tsx new file mode 100644 index 00000000..035c94f9 --- /dev/null +++ b/apps/blackberry-classic-demo/app.tsx @@ -0,0 +1,21 @@ +import Hero from "../hero/app.tsx"; +import { reportAppAction } from "@pocketjs/framework/host"; + +/** + * The same guest bundle mounts under both Classic hosts (the native QNX + * runtime and the Android Runtime shell); nothing here may depend on which + * one is running it. + */ +export default function BlackBerryClassicHero() { + return ( + reportAppAction("hero_press", count)} + presentationHz={60} + runtimeLabel="RUST + QUICKJS + GLES2" + spinnerFrameStep={6} + /> + ); +} diff --git a/apps/blackberry-classic-demo/main.tsx b/apps/blackberry-classic-demo/main.tsx new file mode 100644 index 00000000..8532811e --- /dev/null +++ b/apps/blackberry-classic-demo/main.tsx @@ -0,0 +1,5 @@ +// @title PocketJS: BlackBerry Classic Hero +import { mount } from "@pocketjs/framework/solid"; +import BlackBerryClassicHero from "./app.tsx"; + +mount(() => ); diff --git a/apps/blackberry-classic-demo/pocket.json b/apps/blackberry-classic-demo/pocket.json new file mode 100644 index 00000000..b4fe8155 --- /dev/null +++ b/apps/blackberry-classic-demo/pocket.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.blackberry-classic-demo", + "name": "pocketjs-blackberry-classic-hero", + "title": "PocketJS: BlackBerry Classic Hero", + "version": "0.1.1", + "engine": { + "capabilities": { + "requires": ["input.buttons", "text.glyphs.baked"], + "enhances": ["input.touch"] + } + }, + "app": { + "entry": "apps/blackberry-classic-demo/main.tsx", + "output": "blackberry-classic-main", + "framework": "solid", + "viewport": { + "fixed": { + "logical": [360, 360], + "presentation": "native" + } + } + } +} diff --git a/contracts/spec/gen-c.ts b/contracts/spec/gen-c.ts new file mode 100644 index 00000000..e717c0d9 --- /dev/null +++ b/contracts/spec/gen-c.ts @@ -0,0 +1,46 @@ +// Deterministic codegen: contracts/spec/spec.ts -> hosts/iphone2g/pocket_spec.h, +// the C header every native host includes for the cross-language input +// constants (button bitmask, analog center). Hosts keep their own platform +// key codes; the portable mask they map onto comes only from here. +// +// Run from PocketJS/: bun contracts/spec/gen-c.ts +// +// tests/contract.ts imports generateC() and byte-compares its output against +// the committed header, so the C constants can never drift from spec.ts. Keep +// this generator free of anything non-deterministic. + +import { ANALOG_CENTER, BTN } from "./spec.ts"; + +function hex(n: number, pad = 4): string { + return "0x" + (n >>> 0).toString(16).toUpperCase().padStart(pad, "0"); +} + +export function generateC(): string { + const lines: string[] = []; + const put = (line = "") => lines.push(line); + + put("/* Generated by contracts/spec/gen-c.ts from contracts/spec/spec.ts."); + put(" * Do not edit: run `bun contracts/spec/gen-c.ts` and commit the result;"); + put(" * tests/contract.ts byte-compares this file against the generator. */"); + put("#ifndef POCKET_SPEC_H"); + put("#define POCKET_SPEC_H"); + put(); + put("/* Portable button bitmask — identical on every host. Native hosts map"); + put(" * their platform key codes onto these bits; the guest only sees the mask. */"); + for (const [name, value] of Object.entries(BTN)) { + put(`#define POCKET_BTN_${name} ${hex(value)}U`); + } + put(); + put("/* frame(buttons, analog): analog packs a stick as (x << 8) | y, each axis"); + put(" * 0..255 with 128 = center. Hosts without a stick pass this value. */"); + put(`#define POCKET_ANALOG_CENTER ${hex(ANALOG_CENTER)}U`); + put(); + put("#endif"); + return lines.join("\n") + "\n"; +} + +if (import.meta.main) { + const out = new URL("../../hosts/iphone2g/pocket_spec.h", import.meta.url).pathname; + await Bun.write(out, generateC()); + console.log(`wrote ${out}`); +} diff --git a/docs/BLACKBERRY_CLASSIC.md b/docs/BLACKBERRY_CLASSIC.md new file mode 100644 index 00000000..8799bd65 --- /dev/null +++ b/docs/BLACKBERRY_CLASSIC.md @@ -0,0 +1,306 @@ +# BlackBerry Classic + +PocketJS runs on the BlackBerry Classic (SQC100, BlackBerry 10.3) through two +hosts that share everything above the operating-system boundary. **Both mount +the same guest bundle shape, the same no-std Rust UI core with its GLES2 +DrawList backend, and the same QuickJS bridge (`hosts/iphone2g/pocket_runtime.c`) +against one private device profile: 720×720 physical, 360×360 logical at +raster density 2, 60 Hz fixed simulation time, `input.buttons`, `input.touch`, +and `text.glyphs.baked`.** They differ only in how the process is packaged, +installed, and fed input: + +| | Native QNX host — `hosts/blackberry-qnx` | Android Runtime host — `hosts/blackberry-android` | +| --- | --- | --- | +| Process | BlackBerry 10 Core Native ELF: libscreen window, EGL, OpenGL ES 2, BPS event loop | Android 4.3 (API 18) APK: a `GLSurfaceView` Activity over one JNI `armeabi-v7a` library | +| Package | unsigned development BAR (`blackberry-nativepackager -devMode`) | v1-signed APK | +| Install requirement | **a rooted Classic**: a stock device accepts an unsigned development BAR only with a BlackBerry debug token, and the service that issued tokens is retired | **a stock Classic**: BlackBerry 10.3 sideloads APKs from the file manager once “Allow apps from other sources” is enabled | +| Input source | libscreen keyboard, multi-touch, and `SCREEN_EVENT_JOYSTICK` trackpad events; navigator system keys | Android `KeyEvent`, touch `MotionEvent`, and generic-motion/trackball events from the Android Runtime | +| Toolchain | digest-pinned BBNDK Docker image (compile, package, deploy) | Android SDK Platform 18 + Build-Tools 35.0.0 + NDK r23c unpacked by `setup`, JDK 17 in Docker | +| Hardware status | **first device run recorded** (below) | **no device result is recorded here yet** | +| Command | `bun blackberry-qnx …` | `bun blackberry-android …` | + +Use the native host when the device is rooted: it presents directly through +libscreen and receives the trackpad as its own event class. Use the Android +Runtime host on an unmodified device, or to compare the Android Runtime's input +mapping against the native one on the same hardware. + +Both targets stay private (`tools/blackberry-classic-profile.ts`) and outside +`POCKET_TARGETS` until installation, boot, presentation, touch, keyboard, +trackpad, background/resume, and repeatable delivery are all recorded per host. + +## Shared contract + +`apps/blackberry-classic-demo` is the Hero wrapper both hosts build. The +profile module registers `blackberry-qnx-dev` and `blackberry-android-dev` +with the same display, capabilities, and **host ABI 9**; the target id is +compiled into both the guest and the native host and checked at boot, which is +why they are two targets rather than one. + +**Package identity has one source: the resolved plan.** `plan.app` carries the +manifest's `id`, `title`, and `version`; `extractHostBuildInputs` hands them to +the host tools, and `packageIdentity` (`tools/native-host-build.ts`) maps them +onto the platform: the package id is the manifest id with `-` replaced by `_` +(`dev.pocket_stack.blackberry_classic_demo` — a valid Android package name and +BAR id), the version string is used verbatim, and the integer the platforms +need (Android `versionCode`, BAR `buildId`) is `major·1 000 000 + minor·1 000 ++ patch` (0.1.1 → 1001). `AndroidManifest.xml`, `strings.xml`, and +`bar-descriptor.xml` are templates with `@POCKET_…@` placeholders rendered at +build time; neither host directory holds a second copy of the id or version. + +Input reaches the guest only through the portable button mask and touch +snapshot; no Android or QNX concept crosses the boundary. **The mask constants +come from `hosts/iphone2g/pocket_spec.h`, generated from +`contracts/spec/spec.ts` by `contracts/spec/gen-c.ts` and byte-compared by +`tests/contract.ts`**, and both hosts feed their platform events into the same +state machine, `hosts/iphone2g/pocket_input.c` (unit-tested with the host +compiler in `tests/pocket-input.test.ts`): + +| Physical input | Portable input | +| --- | --- | +| trackpad movement | one d-pad focus pulse per threshold crossing of the accumulated motion, then the axis resets (QNX feeds the integer `SCREEN_PROPERTY_DISPLACEMENT` with threshold 1, so every non-zero event pulses; Android feeds scroll-axis/trackball deltas with threshold 0.35 — provisional, see below) | +| trackpad click | the press button (`CIRCLE`), held while the button is down, tracked separately from keys | +| Enter/Return, d-pad center | the press button | +| arrow keys | d-pad; a key down is one press edge, platform auto-repeat does not re-press | +| Space | `START` | +| Menu | `TRIANGLE` | +| Send (QNX navigator system key) | a one-shot press edge; End and Back stay with the system | +| touchscreen | one tracked contact (a second finger never becomes input), divided into 360×360 logical coordinates, with the host-resolved bounds hit fact; **a contact that went down and up between two frames still reports one down frame, and a release is reported at the very next frame** | + +The frame call is `pocket_runtime_tick(&input)` in +`hosts/iphone2g/pocket_runtime.c`: **exactly one guest turn followed by one +core tick per presented frame** (docs/RUNTIMES.md, law 3), taking the mask, +the sampled contact, and its hit fact. The older `pocket_runtime_frame` / +`pocket_runtime_frame_ticks` entry points stay for the original iPhone host +(two core ticks per 30 Hz guest turn) and the Windows CE host; new hosts do +not call them. `pocket_runtime_gl_reset` drops GL resources so the backend can +be re-initialized after the platform recreates the context (Android does on +pause/resume). + +The Rust core is `pocketjs-symbian-core` (`engine/symbian`): the no-std C-ABI +build of `pocketjs-core` plus the GLES2 DrawList backend that the Nokia E7, +iPhone 2G/4S, and Meizu M8 hosts already link. Both Classic hosts build it +with the `bare-platform` feature. The QNX build uses the checked-in +`hosts/blackberry-qnx/armv7-qnx-eabi.json` target (ARMv7, VFPv3, soft-float +ABI, PIC, `build-std`); the Android build uses the stock +`armv7-linux-androideabi` target. + +## Host requirements + +Both tools need Bun, git, `zip`/`unzip`, `patch`, Docker with a running +daemon, and rustup with **`nightly-2026-07-02`**: + +```sh +rustup toolchain install nightly-2026-07-02 --profile minimal --component rust-src +``` + +`rust-src` feeds the QNX `build-std` link; `bun blackberry-android setup` adds +the `armv7-linux-androideabi` target to that toolchain for the Android link. +**QuickJS is `pocket-stack/quickjs-rs` at `ba5bdd0dc013518768e76cd9e05cd30ed53dd35b` (version 2026-06-04) for both hosts**; +`setup` clones it under each tool's cache with `--filter=blob:none` and every +build refuses a checkout at another revision or with local changes. + +**Both tools were developed and run on Linux x86-64, and both complete +builds — the unsigned BAR and the Hero APK — have also been run on macOS on +Apple silicon with the same commands.** Device installation +from macOS has not been exercised. What is host-specific: + +- **QNX**: the compiler, BAR packager, and `blackberry-deploy` run inside + `accupara/bbndk` (linux/amd64, pinned by digest, about 2.9 GB compressed). + On Apple silicon Docker Desktop runs that image under the emulation it + registers itself (Rosetta for x86-64, QEMU for the BBNDK's 32-bit x86 host + tools); the QuickJS and host compile, link, and BAR packaging complete + there without extra setup, only more slowly. USB deployment (below) uses + `udevadm` and `ip route` and is Linux-only; other hosts skip the interface + check and reach the device by whatever address `POCKETJS_BLACKBERRY_DEVICE` + names. +- **Android**: `aapt2`, `aapt`, `zipalign`, and the NDK clang run on the host + from the SDK directory, so `setup` unpacks the host OS's own archives + (Linux or macOS build-tools and NDK; the NDK prebuilt is `linux-x86_64` or + `darwin-x86_64`, the latter running under Rosetta on Apple silicon). + `javac`, `d8`, `apksigner`, and `keytool` run inside + `eclipse-temurin:17-jdk-jammy` (pinned by digest, multi-arch), so no host + JDK is needed at any step. + +Caches live under `~/.cache/pocket-stack/`: `blackberry-qnx/` (QuickJS +checkout, Rust target directory) and `android/` (`sdk/`, `downloads/`, +`signing/`, QuickJS checkout). Nothing from either cache is copied into the +repository. + +## Native QNX host + +### Toolchain + +`tools/cli/blackberry-qnx-toolchain.json` pins: + +- BBNDK target API **10.3.1.995**, host tools **10.3.1.12**; +- `qcc` **GCC 4.8.3** for `armle-v7`; +- the `accupara/bbndk` image digest; +- the QuickJS revision and the Rust nightly and target spec. + +The image's default entry point is an interactive shell for a different user; +the tool overrides the entry point, runs as the calling uid/gid, publishes no +ports, and compiles and packages with **container networking disabled**. + +QuickJS needs two QNX-specific changes (`tools/blackberry-qnx/quickjs-qnx.patch`): +BlackBerry's C library has no ``, so the single-threaded host +omits the Atomics intrinsic, and it has no `malloc_usable_size()`, so the +allocator reports usable size as zero. + +```sh +bun blackberry-qnx setup # pulls the image, clones QuickJS, runs doctor +bun blackberry-qnx doctor +bun blackberry-qnx build # build-demo + build-runtime +``` + +`build-demo` resolves the manifest against `blackberry-qnx-dev`, writes the +plan to `.pocket/blackberry-qnx/`, and compiles the guest into +`dist/blackberry-qnx/guest/`. `build-runtime` builds the Rust core, compiles +QuickJS, `pocket_runtime.c`, and `hosts/blackberry-qnx/main.c` with the plan's +target id, host ABI, raster density, and logical viewport, links the PIE ELF +against `libbps`, `libscreen`, `libEGL`, and `libGLESv2` with `--no-undefined`, +and packages the unsigned BAR from the rendered `hosts/blackberry-qnx/bar-descriptor.xml` +template. +**The tool rejects a build whose ELF is not ARM, lacks the QNX dynamic loader +or one of the four libraries, whose BAR manifest does not carry the +plan-derived package name and version, or whose BAR embeds a different +executable than the one it linked.** + +```text +dist/blackberry-qnx/pocketjs-blackberry-classic-hero.bar +dist/blackberry-qnx/build-receipt.json +``` + +The receipt records the resolved host contract, image digest, QuickJS and +Rust pins, build id, `readelf` output, and SHA-256 of every native input and +output. + +### Install and device acceptance + +Installing or launching changes device state and is not part of `build`. +Enable Development Mode on the Classic (Settings › Security and Privacy › +Development Mode), which assigns the USB address `169.254.0.1`, then: + +```sh +export POCKETJS_BLACKBERRY_DEVICE=169.254.0.1 +export POCKETJS_BLACKBERRY_PASSWORD='device-password' # omit when the rooted transport takes none +bun blackberry-qnx device-info +bun blackberry-qnx install # -installApp -launchApp +bun blackberry-qnx device-status # reads data/pocketjs-qnx.status from the app sandbox +``` + +On Linux the Classic appears as a CDC-NCM network interface (USB vendor +`0fca`); the tool refuses to deploy until that interface carries a link-local +route and prints the `sudo ip address replace 169.254.0.2/16 dev …` command +that adds one. `blackberry-deploy` runs in the same image with `--network +host`; **on Docker Desktop that is the Linux VM's network, so use the device's +Wi-Fi development address if the USB link-local address is unreachable.** + +The host rewrites `data/pocketjs-qnx.status` whenever its content changes: +build id, lifecycle stage, frame count, raw keyboard and trackpad facts, event +totals, and the latest reported Hero action. + +The first hardware run must show: the Hero fills the 720×720 display through +GLES2; the spinner and underline animate at the fixed 60 Hz step; a tap +activates the button; trackpad movement focuses it and a click activates it; +Enter and Send activate it; background and resume stop and restart +presentation without losing state; repeated installs keep a usable sandbox. + +### First Classic hardware result + +**BlackBerry Classic SQC100-4, BlackBerry 10.3.3.3216.** The unsigned +development BAR installed and launched through the rooted device transport. +The live status record confirmed: + +- **720×720 GLES2 presentation with the 360×360 density-2 guest**; +- **2,747 rendered frames** across foreground, background, and resume; +- **12 touchscreen events**; +- **56 trackpad joystick events and 4 trackpad clicks**; +- **8 completed `hero_press` actions**. + +This accepts native loading, the QuickJS and Rust runtime, rendering, touch, +trackpad navigation and click, and lifecycle resume. Physical keyboard +symbols, navigation-key policy, repeated upgrade delivery, and a captured +screen remain open, so the target stays private. **The input path changed +after that run** — the host now feeds the shared `pocket_input` state machine, +which reports a touch release at the next frame instead of one frame later +and ignores a second finger — **and the host was re-accepted on the same +device with that path** (`device-status`: tap, trackpad focus and click, and +release timing as specified). + +## Android Runtime host + +### Toolchain + +`tools/cli/blackberry-android-toolchain.json` pins: + +- Android SDK Platform **18** (Android 4.3.1 — the Android Runtime in + BlackBerry 10.3); +- Build-Tools **35.0.0**; +- **NDK r23c (23.2.8568313), the last NDK series that still targets API 18**; +- `armeabi-v7a` with clang target `armv7a-linux-androideabi18`; +- the JDK image digest, the QuickJS revision, and the Rust nightly and target. + +`setup` unpacks the three SDK components into +`~/.cache/pocket-stack/android/sdk` from the archives Google's repository +serves for the host OS — `android-18_r03.zip`, `build-tools_r35_{linux,macosx}.zip`, +`android-ndk-r23c-{linux,darwin}.zip` — after checking each against the SHA-1 +published in `repository2-3.xml` (the same files and checksums `sdkmanager` +uses, so no host JDK is needed). A component whose directory already exists +is left alone; `POCKETJS_ANDROID_SDK_ROOT` points the tool at an SDK that +already holds `platforms;android-18`, `build-tools;35.0.0`, and +`ndk;23.2.8568313`. + +```sh +bun blackberry-android setup # SDK archives, JDK image, QuickJS checkout, Rust target, then doctor +bun blackberry-android doctor +bun blackberry-android build # build-demo + build-app +``` + +`build` compiles the guest against `blackberry-android-dev`, builds the Rust +core, compiles QuickJS, `pocket_runtime.c`, `pocket_input.c`, and +`hosts/blackberry-android/app/jni/runtime.c` with the plan's target id, host +ABI, raster density, and logical viewport, links `lib/armeabi-v7a/libpocketjs.so` +against `libGLESv2`, `liblog`, `libdl`, and `libm`, renders the manifest and +string templates with the plan-derived package id, version, and title, and +**rejects an APK whose badging does not report exactly those values**. **The +library does not link `libandroid.so`, and `--no-undefined` turns any missing +native symbol into a link failure.** `PocketActivity.java` owns only the +Android lifecycle, the APK asset reads for `app.js` and `app.pak`, and the +raw key, touch, and trackpad callbacks; the JNI layer feeds them into the +shared input state machine under one mutex and drives one guest tick per +`onDrawFrame`. + +**Android 4.3 verifies only the JAR (v1) signature scheme**, so `apksigner` +runs with v2, v3, and v4 disabled. The self-generated key in +`~/.cache/pocket-stack/android/signing/` signs the APK; keep it, because +Android upgrades an installed package only when the new APK carries the same +signing identity. + +```text +dist/blackberry-android/pocketjs-blackberry-classic.apk +dist/blackberry-android/pocketjs-blackberry-classic.receipt.json +``` + +The receipt records the plan hash, target, host ABI, viewport, guest and +native-library digests, `llvm-readelf` output, the QuickJS pin, the +`apksigner verify` report, and `aapt dump badging`. + +### Install and device acceptance + +Copy the APK to the Classic (USB mass storage, BlackBerry Link, or a network +share) and open it from the device file manager. No debug token, BAR +conversion, or root is involved. + +**The Android trackpad mapping is provisional.** `PocketActivity` forwards +generic-motion scroll axes and trackball deltas as relative movement and the +primary button state as the press; which of those callbacks the Classic's +Android Runtime actually delivers for the trackpad, and whether it presents +the trackpad as a pointer instead, has not been observed on a device yet. +Adjust `hosts/blackberry-android/app/jni/runtime.c` from the first device +run before accepting the Hero APK. + +The Hero APK must show the same list as the native host: 720×720 GLES2 +presentation, the 60 Hz animation, tap, trackpad focus and click, Enter, +background and resume, and repeated upgrades. The Activity prints the boot or +runtime error on screen when the native library, the guest, or the GLES2 +backend fails, so a failed run leaves a readable reason. diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 3611eeb3..9b5ce01a 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -34,7 +34,7 @@ pocketjs/ │ └─ compiler/ the interpreted-path build pipeline (jsx-plugin, tailwind, pak) ├─ vapor/ Pocket Vapor: the AOT compiler family (Vue Vapor subset → GBA/GB/NES) ├─ contracts/ single sources of truth binding the layers -│ ├─ spec/ op contract, platform contracts, manifest + package spec, gen-rust +│ ├─ spec/ op contract, platform contracts, manifest + package spec, gen-rust + gen-c │ └─ schema/ published JSON schemas (pocket-2.json) ├─ apps/ demo apps (pocket.json manifests; built by tools/build.ts) ├─ tools/ every command: build/dev/device/release bun scripts (flat), diff --git a/framework/src/manifest/host-build-inputs.ts b/framework/src/manifest/host-build-inputs.ts index 844bd7d8..ea9290c5 100644 --- a/framework/src/manifest/host-build-inputs.ts +++ b/framework/src/manifest/host-build-inputs.ts @@ -8,6 +8,13 @@ import { verifyPlanHash, type ResolvedBuildPlan } from "./plan.ts"; /** Stable subset of the internal build plan consumed by custom native hosts. */ export interface HostBuildInputs { readonly appOutput: string; + /** Package identity from the manifest, as the plan resolved it. Hosts map + * it onto their platform's package id and version scheme. */ + readonly app: { + readonly id: string; + readonly title: string; + readonly version: string; + }; readonly target: string; readonly hostAbi: number; readonly viewport: { @@ -42,7 +49,8 @@ function hasHostInputShape(input: unknown): input is ResolvedBuildPlan { if (!isRecord(input.viewport) || !isRecord(input.features)) return false; if ( typeof input.app.id !== "string" || input.app.id.length === 0 || - typeof input.app.title !== "string" || input.app.title.length === 0 + typeof input.app.title !== "string" || input.app.title.length === 0 || + typeof input.app.version !== "string" || input.app.version.length === 0 ) return false; if (typeof input.app.output !== "string" || input.app.output.length === 0) return false; if (typeof input.target.id !== "string" || input.target.id.length === 0) return false; @@ -91,6 +99,11 @@ export function extractHostBuildInputs( } return { appOutput: plan.app.output, + app: { + id: plan.app.id, + title: plan.app.title, + version: plan.app.version, + }, target: plan.target.id, hostAbi: plan.target.hostAbi, viewport: { @@ -109,6 +122,9 @@ export function hostBuildEnvironment( ): Readonly> { return { POCKETJS_APP_OUTPUT: inputs.appOutput, + POCKETJS_APP_ID: inputs.app.id, + POCKETJS_APP_TITLE: inputs.app.title, + POCKETJS_APP_VERSION: inputs.app.version, POCKETJS_EMBED_APP: options.embedApp ? "1" : "0", POCKETJS_OUTPUT_DIR: options.outputDirectory, POCKETJS_TARGET: inputs.target, diff --git a/framework/src/manifest/plan.ts b/framework/src/manifest/plan.ts index fa821c19..f5d4ff54 100644 --- a/framework/src/manifest/plan.ts +++ b/framework/src/manifest/plan.ts @@ -3,7 +3,9 @@ import type { PocketManifestV2 } from "../../../contracts/spec/pocket-manifest.t import type { PresentationMode, Viewport } from "../../../contracts/spec/platforms.ts"; export interface ResolvedBuildPlanContent { - readonly app: Pick & + /** Package identity travels with the plan: native hosts derive their + * platform package id and version from here, never from a second copy. */ + readonly app: Pick & Pick & { readonly output: string; }; diff --git a/framework/src/manifest/resolve.ts b/framework/src/manifest/resolve.ts index dba647da..0ed26c05 100644 --- a/framework/src/manifest/resolve.ts +++ b/framework/src/manifest/resolve.ts @@ -362,6 +362,7 @@ export function resolveBuildPlan( app: { id: manifest.id, title: manifest.title, + version: manifest.version, entry: manifest.app.entry, output, framework: manifest.app.framework, diff --git a/hosts/blackberry-android/app/AndroidManifest.xml b/hosts/blackberry-android/app/AndroidManifest.xml new file mode 100644 index 00000000..29f1de83 --- /dev/null +++ b/hosts/blackberry-android/app/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + diff --git a/hosts/blackberry-android/app/jni/runtime.c b/hosts/blackberry-android/app/jni/runtime.c new file mode 100644 index 00000000..790dbdcb --- /dev/null +++ b/hosts/blackberry-android/app/jni/runtime.c @@ -0,0 +1,307 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "pocket_input.h" +#include "pocket_runtime.h" +#include "pocket_spec.h" + +#define LOG_TAG "PocketJSClassic" +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +/* The logical viewport comes from the resolved build plan (blackberry-android.ts); + * the defaults match the private blackberry-android-dev profile. */ +#ifndef POCKET_LOGICAL_WIDTH +#define POCKET_LOGICAL_WIDTH 360 +#endif +#ifndef POCKET_LOGICAL_HEIGHT +#define POCKET_LOGICAL_HEIGHT 360 +#endif + +#define KEYCODE_BACK 4 +#define KEYCODE_DPAD_UP 19 +#define KEYCODE_DPAD_DOWN 20 +#define KEYCODE_DPAD_LEFT 21 +#define KEYCODE_DPAD_RIGHT 22 +#define KEYCODE_DPAD_CENTER 23 +#define KEYCODE_SPACE 62 +#define KEYCODE_ENTER 66 +#define KEYCODE_MENU 82 +#define KEYCODE_NUMPAD_ENTER 160 + +#define ACTION_DOWN 0 +#define ACTION_UP 1 +#define ACTION_CANCEL 3 +#define ACTION_POINTER_DOWN 5 +#define ACTION_POINTER_UP 6 +#define BUTTON_PRIMARY 1 + +/* Trackball and scroll-axis deltas are fractional; this much accumulated + * motion is one focus pulse (provisional until a device run records the + * Android Runtime's actual trackpad events). */ +#define RELATIVE_PULSE_THRESHOLD 0.35f + +static pthread_mutex_t input_mutex = PTHREAD_MUTEX_INITIALIZER; +static PocketInputState input; +static int input_ready; +static int surface_width = 720; +static int surface_height = 720; + +static uint8_t *guest_js; +static size_t guest_js_length; +static uint8_t *guest_pack; +static size_t guest_pack_length; +static int runtime_booted; +static int gl_initialized; +static char android_error[512]; + +static void set_android_error(const char *message) +{ + size_t length = message == NULL ? 0 : strlen(message); + if (length >= sizeof(android_error)) length = sizeof(android_error) - 1; + if (length > 0) memcpy(android_error, message, length); + android_error[length] = '\0'; + LOGE("%s", android_error); +} + +static uint8_t *copy_java_bytes( + JNIEnv *env, + jbyteArray source, + size_t *length +) +{ + if (source == NULL) return NULL; + jsize source_length = (*env)->GetArrayLength(env, source); + if (source_length <= 0) return NULL; + uint8_t *bytes = (uint8_t *)malloc((size_t)source_length); + if (bytes == NULL) return NULL; + (*env)->GetByteArrayRegion(env, source, 0, source_length, (jbyte *)bytes); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + free(bytes); + return NULL; + } + *length = (size_t)source_length; + return bytes; +} + +/* Android key codes onto the portable mask (pocket_spec.h). */ +static uint32_t button_for_key(int key_code) +{ + switch (key_code) { + case KEYCODE_DPAD_UP: return POCKET_BTN_UP; + case KEYCODE_DPAD_RIGHT: return POCKET_BTN_RIGHT; + case KEYCODE_DPAD_DOWN: return POCKET_BTN_DOWN; + case KEYCODE_DPAD_LEFT: return POCKET_BTN_LEFT; + case KEYCODE_DPAD_CENTER: + case KEYCODE_ENTER: + case KEYCODE_NUMPAD_ENTER: + return POCKET_BTN_CIRCLE; + case KEYCODE_SPACE: return POCKET_BTN_START; + case KEYCODE_MENU: return POCKET_BTN_TRIANGLE; + default: return 0; + } +} + +/* Input callbacks can arrive before the surface exists; the state machine is + * initialized lazily under the mutex. */ +static void ensure_input(void) +{ + if (input_ready) return; + pocket_input_init(&input, RELATIVE_PULSE_THRESHOLD); + input_ready = 1; +} + +JNIEXPORT jstring JNICALL +Java_dev_pocketstack_blackberry_PocketActivity_nativeSurfaceCreated( + JNIEnv *env, + jclass owner, + jbyteArray guest_java_script, + jbyteArray guest_asset_pack +) +{ + (void)owner; + android_error[0] = '\0'; + if (runtime_booted) { + pocket_runtime_gl_reset(); + gl_initialized = pocket_runtime_gl_initialize(); + if (!gl_initialized) set_android_error("GLES2 backend reinitialization failed"); + return (*env)->NewStringUTF(env, gl_initialized ? "ok" : android_error); + } + + uint8_t *new_guest_js = copy_java_bytes( + env, + guest_java_script, + &guest_js_length + ); + uint8_t *new_guest_pack = copy_java_bytes( + env, + guest_asset_pack, + &guest_pack_length + ); + if (new_guest_js == NULL || new_guest_pack == NULL) { + free(new_guest_js); + free(new_guest_pack); + set_android_error("APK assets/app.js or assets/app.pak could not be copied"); + return (*env)->NewStringUTF(env, android_error); + } + free(guest_js); + free(guest_pack); + guest_js = new_guest_js; + guest_pack = new_guest_pack; + + if (!pocket_runtime_boot( + (const char *)guest_js, + guest_js_length, + guest_pack, + guest_pack_length, + POCKET_LOGICAL_WIDTH, + POCKET_LOGICAL_HEIGHT + )) { + set_android_error(pocket_runtime_error()); + return (*env)->NewStringUTF(env, android_error); + } + runtime_booted = 1; + gl_initialized = pocket_runtime_gl_initialize(); + if (!gl_initialized) { + set_android_error("PocketJS GLES2 backend initialization failed"); + } + return (*env)->NewStringUTF(env, gl_initialized ? "ok" : android_error); +} + +JNIEXPORT void JNICALL +Java_dev_pocketstack_blackberry_PocketActivity_nativeSurfaceChanged( + JNIEnv *env, + jclass owner, + jint width, + jint height +) +{ + (void)env; + (void)owner; + pthread_mutex_lock(&input_mutex); + surface_width = width > 0 ? width : 1; + surface_height = height > 0 ? height : 1; + pthread_mutex_unlock(&input_mutex); +} + +JNIEXPORT jboolean JNICALL +Java_dev_pocketstack_blackberry_PocketActivity_nativeFrame( + JNIEnv *env, + jclass owner +) +{ + (void)env; + (void)owner; + if (!runtime_booted || !gl_initialized) return JNI_FALSE; + + PocketInputSample sample; + PocketRuntimeInput frame; + int width; + int height; + pthread_mutex_lock(&input_mutex); + ensure_input(); + pocket_input_sample(&input, &sample); + width = surface_width; + height = surface_height; + pthread_mutex_unlock(&input_mutex); + + frame.buttons = sample.buttons; + frame.touch_down = sample.touch_down; + frame.touch_x = (int)(sample.touch_x * POCKET_LOGICAL_WIDTH / width); + frame.touch_y = (int)(sample.touch_y * POCKET_LOGICAL_HEIGHT / height); + frame.touch_hit = sample.touch_down + ? pocket_runtime_hit_test_bounds((float)frame.touch_x, (float)frame.touch_y) + : 0; + if (!pocket_runtime_tick(&frame)) { + set_android_error(pocket_runtime_error()); + return JNI_FALSE; + } + if (!pocket_runtime_gl_render(width, height)) { + set_android_error("PocketJS GLES2 frame submission failed"); + return JNI_FALSE; + } + return JNI_TRUE; +} + +JNIEXPORT jstring JNICALL +Java_dev_pocketstack_blackberry_PocketActivity_nativeError(JNIEnv *env, jclass owner) +{ + (void)owner; + const char *message = android_error[0] != '\0' + ? android_error + : pocket_runtime_error(); + return (*env)->NewStringUTF(env, message == NULL ? "unknown error" : message); +} + +JNIEXPORT void JNICALL +Java_dev_pocketstack_blackberry_PocketActivity_nativeKey( + JNIEnv *env, + jclass owner, + jint action, + jint key_code, + jint scan_code, + jint unicode, + jint repeat +) +{ + (void)env; + (void)owner; + (void)scan_code; + (void)unicode; + uint32_t button = button_for_key(key_code); + if (button == 0 || key_code == KEYCODE_BACK) return; + if (action != ACTION_DOWN && action != ACTION_UP) return; + pthread_mutex_lock(&input_mutex); + ensure_input(); + pocket_input_button(&input, button, action == ACTION_DOWN, repeat != 0); + pthread_mutex_unlock(&input_mutex); +} + +JNIEXPORT void JNICALL +Java_dev_pocketstack_blackberry_PocketActivity_nativeTouch( + JNIEnv *env, + jclass owner, + jint action, + jint pointer_id, + jfloat x, + jfloat y +) +{ + (void)env; + (void)owner; + PocketTouchPhase phase; + if (action == ACTION_DOWN || action == ACTION_POINTER_DOWN) phase = POCKET_TOUCH_DOWN; + else if (action == ACTION_UP || action == ACTION_POINTER_UP) phase = POCKET_TOUCH_UP; + else if (action == ACTION_CANCEL) phase = POCKET_TOUCH_CANCEL; + else phase = POCKET_TOUCH_MOVE; + pthread_mutex_lock(&input_mutex); + ensure_input(); + pocket_input_touch(&input, phase, pointer_id, x, y); + pthread_mutex_unlock(&input_mutex); +} + +JNIEXPORT void JNICALL +Java_dev_pocketstack_blackberry_PocketActivity_nativeRelative( + JNIEnv *env, + jclass owner, + jfloat delta_x, + jfloat delta_y, + jint action, + jint button_state +) +{ + (void)env; + (void)owner; + int primary = (button_state & BUTTON_PRIMARY) != 0 || action == ACTION_DOWN; + if (action == ACTION_UP || action == ACTION_CANCEL) primary = 0; + pthread_mutex_lock(&input_mutex); + ensure_input(); + pocket_input_relative(&input, delta_x, delta_y); + pocket_input_primary(&input, primary); + pthread_mutex_unlock(&input_mutex); +} diff --git a/hosts/blackberry-android/app/res/values/strings.xml b/hosts/blackberry-android/app/res/values/strings.xml new file mode 100644 index 00000000..55762cc6 --- /dev/null +++ b/hosts/blackberry-android/app/res/values/strings.xml @@ -0,0 +1,5 @@ + + + + @POCKET_TITLE@ + diff --git a/hosts/blackberry-android/app/src/dev/pocketstack/blackberry/PocketActivity.java b/hosts/blackberry-android/app/src/dev/pocketstack/blackberry/PocketActivity.java new file mode 100644 index 00000000..10f63d1e --- /dev/null +++ b/hosts/blackberry-android/app/src/dev/pocketstack/blackberry/PocketActivity.java @@ -0,0 +1,268 @@ +package dev.pocketstack.blackberry; + +import android.app.Activity; +import android.graphics.Color; +import android.graphics.Typeface; +import android.opengl.GLSurfaceView; +import android.os.Bundle; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.TextView; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** Android 4.3 shell for the BlackBerry Classic PocketJS guest. */ +public final class PocketActivity extends Activity { + private static boolean nativeLoaded; + private static String nativeLoadError = ""; + + static { + try { + System.loadLibrary("pocketjs"); + nativeLoaded = true; + } catch (Throwable error) { + nativeLoaded = false; + nativeLoadError = error.getClass().getSimpleName() + ": " + error.getMessage(); + } + } + + private PocketSurfaceView surfaceView; + private TextView errorView; + + private static native String nativeSurfaceCreated(byte[] guestJavaScript, byte[] guestPack); + private static native void nativeSurfaceChanged(int width, int height); + private static native boolean nativeFrame(); + private static native String nativeError(); + private static native void nativeKey( + int action, + int keyCode, + int scanCode, + int unicode, + int repeat + ); + private static native void nativeTouch(int action, int pointerId, float x, float y); + private static native void nativeRelative( + float deltaX, + float deltaY, + int action, + int buttonState + ); + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + requestWindowFeature(Window.FEATURE_NO_TITLE); + getWindow().setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ); + + FrameLayout root = new FrameLayout(this); + surfaceView = new PocketSurfaceView(); + root.addView( + surfaceView, + new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ); + + errorView = new TextView(this); + errorView.setTextColor(Color.WHITE); + errorView.setTextSize(15.0f); + errorView.setTypeface(Typeface.MONOSPACE); + errorView.setGravity(Gravity.CENTER); + errorView.setPadding(28, 28, 28, 28); + errorView.setBackgroundColor(0xff250d12); + errorView.setText(nativeLoaded ? "BOOTING POCKETJS…" : "JNI FAILED\n" + nativeLoadError); + root.addView( + errorView, + new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ); + setContentView(root); + } + + @Override + protected void onResume() { + super.onResume(); + surfaceView.onResume(); + surfaceView.requestFocus(); + } + + @Override + protected void onPause() { + surfaceView.onPause(); + super.onPause(); + } + + @Override + public void onWindowFocusChanged(boolean focused) { + super.onWindowFocusChanged(focused); + if (focused) surfaceView.requestFocus(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if (nativeLoaded) { + nativeKey( + event.getAction(), + event.getKeyCode(), + event.getScanCode(), + event.getUnicodeChar(event.getMetaState()), + event.getRepeatCount() + ); + } + if (event.getKeyCode() == KeyEvent.KEYCODE_BACK || + event.getKeyCode() == KeyEvent.KEYCODE_HOME || + event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP || + event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_DOWN) { + return super.dispatchKeyEvent(event); + } + return true; + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if (!nativeLoaded) return super.dispatchTouchEvent(event); + int action = event.getActionMasked(); + int changed = event.getActionIndex(); + if (action == MotionEvent.ACTION_MOVE) { + for (int index = 0; index < event.getPointerCount(); index++) { + nativeTouch( + action, + event.getPointerId(index), + event.getX(index), + event.getY(index) + ); + } + } else if (action == MotionEvent.ACTION_CANCEL) { + for (int index = 0; index < event.getPointerCount(); index++) { + nativeTouch( + action, + event.getPointerId(index), + event.getX(index), + event.getY(index) + ); + } + } else { + nativeTouch( + action, + event.getPointerId(changed), + event.getX(changed), + event.getY(changed) + ); + } + return true; + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent event) { + if (!nativeLoaded) return super.dispatchGenericMotionEvent(event); + float horizontal = event.getAxisValue(MotionEvent.AXIS_HSCROLL); + float vertical = event.getAxisValue(MotionEvent.AXIS_VSCROLL); + nativeRelative(horizontal, vertical, event.getActionMasked(), event.getButtonState()); + return true; + } + + @Override + public boolean onTrackballEvent(MotionEvent event) { + if (!nativeLoaded) return super.onTrackballEvent(event); + nativeRelative(event.getX(), event.getY(), event.getActionMasked(), event.getButtonState()); + return true; + } + + private void showBootResult(final String result) { + runOnUiThread(new Runnable() { + public void run() { + if ("ok".equals(result)) { + errorView.setVisibility(View.GONE); + } else { + errorView.setText("POCKETJS BOOT FAILED\n\n" + result); + errorView.setVisibility(View.VISIBLE); + } + } + }); + } + + private void showRuntimeError(final String error) { + runOnUiThread(new Runnable() { + public void run() { + errorView.setText("POCKETJS RUNTIME FAILED\n\n" + error); + errorView.setVisibility(View.VISIBLE); + } + }); + } + + private byte[] readAsset(String name) throws IOException { + InputStream input = getAssets().open(name); + try { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int count; + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + } + return output.toByteArray(); + } finally { + input.close(); + } + } + + private final class PocketSurfaceView extends GLSurfaceView { + PocketSurfaceView() { + super(PocketActivity.this); + setEGLContextClientVersion(2); + setPreserveEGLContextOnPause(true); + setFocusable(true); + setFocusableInTouchMode(true); + setRenderer(new PocketRenderer()); + setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY); + } + } + + private final class PocketRenderer implements GLSurfaceView.Renderer { + private boolean failed; + + public void onSurfaceCreated( + javax.microedition.khronos.opengles.GL10 ignored, + javax.microedition.khronos.egl.EGLConfig config + ) { + if (!nativeLoaded) return; + String result; + try { + result = nativeSurfaceCreated(readAsset("app.js"), readAsset("app.pak")); + } catch (IOException error) { + result = "APK asset read failed: " + error.getMessage(); + } + failed = !"ok".equals(result); + showBootResult(result); + } + + public void onSurfaceChanged( + javax.microedition.khronos.opengles.GL10 ignored, + int width, + int height + ) { + if (nativeLoaded) nativeSurfaceChanged(width, height); + } + + public void onDrawFrame(javax.microedition.khronos.opengles.GL10 ignored) { + if (!nativeLoaded || failed) return; + if (!nativeFrame()) { + failed = true; + showRuntimeError(nativeError()); + } + } + } +} diff --git a/hosts/blackberry-qnx/armv7-qnx-eabi.json b/hosts/blackberry-qnx/armv7-qnx-eabi.json new file mode 100644 index 00000000..7e21ac4d --- /dev/null +++ b/hosts/blackberry-qnx/armv7-qnx-eabi.json @@ -0,0 +1,24 @@ +{ + "abi": "eabi", + "arch": "arm", + "c-enum-min-bits": 32, + "cpu": "cortex-a9", + "crt-objects-fallback": "false", + "data-layout": "e-m:e-p:32:32-Fi8-i64:64-v128:64:128-a:0:32-n32-S64", + "emit-debug-gdb-scripts": false, + "features": "+v7,+vfp3,+neon,+strict-align", + "frame-pointer": "always", + "function-sections": true, + "has-thumb-interworking": true, + "linker": "rust-lld", + "linker-flavor": "gnu-lld", + "llvm-floatabi": "soft", + "llvm-target": "armv7-none-eabi", + "max-atomic-width": 64, + "os": "none", + "panic-strategy": "abort", + "relocation-model": "pic", + "target-endian": "little", + "target-pointer-width": 32, + "vendor": "unknown" +} diff --git a/hosts/blackberry-qnx/bar-descriptor.xml b/hosts/blackberry-qnx/bar-descriptor.xml new file mode 100644 index 00000000..811e7868 --- /dev/null +++ b/hosts/blackberry-qnx/bar-descriptor.xml @@ -0,0 +1,33 @@ + + + + @POCKET_ID@ + @POCKET_TITLE@ + @POCKET_VERSION@ + @POCKET_BUILD_ID@ + PocketJS Hero running as a native BlackBerry 10 application. + PocketJS + 10.3.1.995 + + icon.png + app.js + app.pak + + + armle-v7 + pocketjs-classic + + + + false + none + + + + icon.png + + + + diff --git a/hosts/blackberry-qnx/main.c b/hosts/blackberry-qnx/main.c new file mode 100644 index 00000000..83ba1302 --- /dev/null +++ b/hosts/blackberry-qnx/main.c @@ -0,0 +1,547 @@ +#include "pocket_input.h" +#include "pocket_runtime.h" +#include "pocket_spec.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef POCKET_BUILD_ID +#error "POCKET_BUILD_ID must identify the concrete BlackBerry QNX build" +#endif + +/* The logical viewport comes from the resolved build plan (build.sh); the + * defaults match the private blackberry-qnx-dev profile. */ +#ifndef POCKET_LOGICAL_WIDTH +#define POCKET_LOGICAL_WIDTH 360 +#endif +#ifndef POCKET_LOGICAL_HEIGHT +#define POCKET_LOGICAL_HEIGHT 360 +#endif +#define DEFAULT_SURFACE_WIDTH 720 +#define DEFAULT_SURFACE_HEIGHT 720 + +#define STATUS_PATH "data/pocketjs-qnx.status" + +typedef struct { + unsigned char *bytes; + size_t length; +} Asset; + +static screen_context_t screen_context; +static screen_window_t screen_window; +static EGLDisplay egl_display = EGL_NO_DISPLAY; +static EGLSurface egl_surface = EGL_NO_SURFACE; +static EGLContext egl_context = EGL_NO_CONTEXT; + +static int surface_width = DEFAULT_SURFACE_WIDTH; +static int surface_height = DEFAULT_SURFACE_HEIGHT; +static int app_active = 1; +static int app_shutdown; +static int runtime_ready; +static PocketInputState input; + +static unsigned long frame_count; +static unsigned long touch_event_count; +static unsigned long keyboard_event_count; +static unsigned long trackpad_event_count; +static unsigned long trackpad_click_count; +static int last_trackpad_dx; +static int last_trackpad_dy; +static int last_trackpad_x; +static int last_trackpad_y; +static int last_trackpad_buttons; +static int last_key_sym; +static int last_key_scan; +static int last_key_flags; +static unsigned long last_action_sequence; +static char status_stage[32] = "starting"; +static char status_detail[256] = "host entry"; +static int status_dirty = 1; +static char executable_directory[PATH_MAX]; + +static void set_error(const char *message) +{ + snprintf(status_detail, sizeof(status_detail), "%s", message == NULL ? "unknown error" : message); + snprintf(status_stage, sizeof(status_stage), "%s", "error"); + status_dirty = 1; + fprintf(stderr, "PocketJS Classic: %s\n", status_detail); +} + +static void set_status(const char *stage, const char *detail) +{ + snprintf(status_stage, sizeof(status_stage), "%s", stage == NULL ? "unknown" : stage); + snprintf(status_detail, sizeof(status_detail), "%s", detail == NULL ? "" : detail); + status_dirty = 1; + fprintf(stderr, "PocketJS Classic: %s: %s\n", status_stage, status_detail); +} + +static void write_status(void) +{ + FILE *file; + const char *action_name; + if (!status_dirty) return; + file = fopen(STATUS_PATH, "wb"); + if (file == NULL) { + fprintf(stderr, "PocketJS Classic: cannot write %s: %s\n", STATUS_PATH, strerror(errno)); + return; + } + action_name = runtime_ready ? pocket_runtime_action_name() : ""; + fprintf(file, "schema=1\n"); + fprintf(file, "build_id=%s\n", POCKET_BUILD_ID); + fprintf(file, "stage=%s\n", status_stage); + fprintf(file, "detail=%s\n", status_detail); + fprintf(file, "surface=%dx%d\n", surface_width, surface_height); + fprintf(file, "logical=%dx%d\n", POCKET_LOGICAL_WIDTH, POCKET_LOGICAL_HEIGHT); + fprintf(file, "frames=%lu\n", frame_count); + fprintf(file, "touch_events=%lu\n", touch_event_count); + fprintf(file, "keyboard_events=%lu\n", keyboard_event_count); + fprintf(file, "trackpad_events=%lu\n", trackpad_event_count); + fprintf(file, "trackpad_clicks=%lu\n", trackpad_click_count); + fprintf(file, "trackpad_displacement=%d,%d\n", last_trackpad_dx, last_trackpad_dy); + fprintf(file, "trackpad_position=%d,%d\n", last_trackpad_x, last_trackpad_y); + fprintf(file, "trackpad_buttons=%d\n", last_trackpad_buttons); + fprintf(file, "key_sym=%d\n", last_key_sym); + fprintf(file, "key_scan=%d\n", last_key_scan); + fprintf(file, "key_flags=%d\n", last_key_flags); + fprintf(file, "action_sequence=%lu\n", runtime_ready ? pocket_runtime_action_sequence() : 0UL); + fprintf(file, "action_name=%s\n", action_name == NULL ? "" : action_name); + fprintf(file, "action_value=%d\n", runtime_ready ? pocket_runtime_action_value() : 0); + fclose(file); + status_dirty = 0; +} + +static int integer_environment(const char *name, int fallback) +{ + const char *text = getenv(name); + char *end = NULL; + long value; + if (text == NULL || text[0] == '\0') return fallback; + value = strtol(text, &end, 10); + if (end == text || *end != '\0' || value <= 0 || value > INT_MAX) return fallback; + return (int)value; +} + +static void initialize_executable_directory(const char *argv0) +{ + const char *slash; + size_t length; + executable_directory[0] = '\0'; + if (argv0 == NULL) return; + slash = strrchr(argv0, '/'); + if (slash == NULL) return; + length = (size_t)(slash - argv0); + if (length == 0 || length >= sizeof(executable_directory)) return; + memcpy(executable_directory, argv0, length); + executable_directory[length] = '\0'; +} + +static int read_file(const char *path, Asset *asset) +{ + FILE *file; + long end; + unsigned char *bytes; + size_t length; + file = fopen(path, "rb"); + if (file == NULL) return 0; + if (fseek(file, 0, SEEK_END) != 0) { + fclose(file); + return 0; + } + end = ftell(file); + if (end <= 0 || end > 64L * 1024L * 1024L || fseek(file, 0, SEEK_SET) != 0) { + fclose(file); + return 0; + } + length = (size_t)end; + bytes = (unsigned char *)malloc(length); + if (bytes == NULL) { + fclose(file); + return 0; + } + if (fread(bytes, 1, length, file) != length) { + free(bytes); + fclose(file); + return 0; + } + fclose(file); + asset->bytes = bytes; + asset->length = length; + return 1; +} + +static int read_asset(const char *name, Asset *asset) +{ + char path[PATH_MAX]; + const char *fallbacks[2]; + size_t index; + asset->bytes = NULL; + asset->length = 0; + if (executable_directory[0] != '\0') { + if (snprintf(path, sizeof(path), "%s/%s", executable_directory, name) < (int)sizeof(path) && + read_file(path, asset)) return 1; + } + fallbacks[0] = "app/native"; + fallbacks[1] = "."; + for (index = 0; index < sizeof(fallbacks) / sizeof(fallbacks[0]); index += 1) { + if (snprintf(path, sizeof(path), "%s/%s", fallbacks[index], name) >= (int)sizeof(path)) continue; + if (read_file(path, asset)) return 1; + } + return 0; +} + +static void destroy_graphics(void) +{ + if (egl_display != EGL_NO_DISPLAY) { + eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (egl_surface != EGL_NO_SURFACE) eglDestroySurface(egl_display, egl_surface); + if (egl_context != EGL_NO_CONTEXT) eglDestroyContext(egl_display, egl_context); + eglTerminate(egl_display); + } + egl_surface = EGL_NO_SURFACE; + egl_context = EGL_NO_CONTEXT; + egl_display = EGL_NO_DISPLAY; + if (screen_window != NULL) screen_destroy_window(screen_window); + if (screen_context != NULL) screen_destroy_context(screen_context); + screen_window = NULL; + screen_context = NULL; + eglReleaseThread(); +} + +static int initialize_graphics(void) +{ + EGLConfig config; + EGLint config_count = 0; + EGLint config_attributes[] = { + EGL_RED_SIZE, 8, + EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, + EGL_SURFACE_TYPE, EGL_WINDOW_BIT, + EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL_NONE + }; + EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE}; + int format = SCREEN_FORMAT_RGBX8888; + int usage = SCREEN_USAGE_OPENGL_ES2; + int size[2]; + char group[32]; + + surface_width = integer_environment("WIDTH", DEFAULT_SURFACE_WIDTH); + surface_height = integer_environment("HEIGHT", DEFAULT_SURFACE_HEIGHT); + size[0] = surface_width; + size[1] = surface_height; + + if (screen_create_context(&screen_context, SCREEN_APPLICATION_CONTEXT) != 0) { + set_error("screen_create_context failed"); + return 0; + } + egl_display = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (egl_display == EGL_NO_DISPLAY || !eglInitialize(egl_display, NULL, NULL) || + !eglBindAPI(EGL_OPENGL_ES_API) || + !eglChooseConfig(egl_display, config_attributes, &config, 1, &config_count) || + config_count != 1) { + set_error("EGL display or configuration initialization failed"); + return 0; + } + egl_context = eglCreateContext(egl_display, config, EGL_NO_CONTEXT, context_attributes); + if (egl_context == EGL_NO_CONTEXT) { + set_error("eglCreateContext for OpenGL ES 2 failed"); + return 0; + } + if (screen_create_window(&screen_window, screen_context) != 0) { + set_error("screen_create_window failed"); + return 0; + } + snprintf(group, sizeof(group), "pocketjs-%ld", (long)getpid()); + if (screen_create_window_group(screen_window, group) != 0 || + screen_set_window_property_iv(screen_window, SCREEN_PROPERTY_FORMAT, &format) != 0 || + screen_set_window_property_iv(screen_window, SCREEN_PROPERTY_USAGE, &usage) != 0 || + screen_set_window_property_iv(screen_window, SCREEN_PROPERTY_BUFFER_SIZE, size) != 0 || + screen_create_window_buffers(screen_window, 2) != 0) { + set_error("libscreen window configuration failed"); + return 0; + } + egl_surface = eglCreateWindowSurface(egl_display, config, screen_window, NULL); + if (egl_surface == EGL_NO_SURFACE || + !eglMakeCurrent(egl_display, egl_surface, egl_surface, egl_context) || + !eglSwapInterval(egl_display, 1)) { + set_error("EGL window surface initialization failed"); + return 0; + } + glViewport(0, 0, surface_width, surface_height); + glDisable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); + return 1; +} + +/* libscreen key symbols onto the portable mask (pocket_spec.h). */ +static uint32_t button_for_key(int symbol) +{ + switch (symbol) { + case KEYCODE_UP: return POCKET_BTN_UP; + case KEYCODE_RIGHT: return POCKET_BTN_RIGHT; + case KEYCODE_DOWN: return POCKET_BTN_DOWN; + case KEYCODE_LEFT: return POCKET_BTN_LEFT; + case KEYCODE_RETURN: return POCKET_BTN_CIRCLE; + case KEYCODE_SPACE: return POCKET_BTN_START; + case KEYCODE_MENU: return POCKET_BTN_TRIANGLE; + default: return 0; + } +} + +static void handle_keyboard(screen_event_t event) +{ + int flags = 0; + int symbol = 0; + int scan = 0; + if (screen_get_event_property_iv(event, SCREEN_PROPERTY_FLAGS, &flags) != 0) return; + screen_get_event_property_iv(event, SCREEN_PROPERTY_SYM, &symbol); + screen_get_event_property_iv(event, SCREEN_PROPERTY_SCAN, &scan); + keyboard_event_count += 1; + last_key_sym = symbol; + last_key_scan = scan; + last_key_flags = flags; + status_dirty = 1; + pocket_input_button( + &input, + button_for_key(symbol), + (flags & SCREEN_FLAG_KEY_DOWN) != 0, + (flags & SCREEN_FLAG_KEY_REPEAT) != 0 + ); +} + +static void handle_touch(screen_event_t event, int type) +{ + int position[2] = {0, 0}; + int id = -1; + PocketTouchPhase phase; + if (screen_get_event_property_iv(event, SCREEN_PROPERTY_SOURCE_POSITION, position) != 0 && + screen_get_event_property_iv(event, SCREEN_PROPERTY_POSITION, position) != 0) return; + screen_get_event_property_iv(event, SCREEN_PROPERTY_TOUCH_ID, &id); + touch_event_count += 1; + status_dirty = 1; + phase = type == SCREEN_EVENT_MTOUCH_TOUCH ? POCKET_TOUCH_DOWN + : type == SCREEN_EVENT_MTOUCH_RELEASE ? POCKET_TOUCH_UP + : POCKET_TOUCH_MOVE; + pocket_input_touch(&input, phase, id, (float)position[0], (float)position[1]); +} + +static void handle_trackpad(screen_event_t event) +{ + int displacement[2] = {0, 0}; + int position[2] = {0, 0}; + int buttons = 0; + int primary; + screen_get_event_property_iv(event, SCREEN_PROPERTY_DISPLACEMENT, displacement); + screen_get_event_property_iv(event, SCREEN_PROPERTY_POSITION, position); + screen_get_event_property_iv(event, SCREEN_PROPERTY_BUTTONS, &buttons); + trackpad_event_count += 1; + last_trackpad_dx = displacement[0]; + last_trackpad_dy = displacement[1]; + last_trackpad_x = position[0]; + last_trackpad_y = position[1]; + last_trackpad_buttons = buttons; + status_dirty = 1; + /* Integer joystick displacement: every non-zero event is one focus pulse. */ + pocket_input_relative(&input, (float)displacement[0], (float)displacement[1]); + primary = buttons != 0; + if (primary && !input.primary_down) trackpad_click_count += 1; + pocket_input_primary(&input, primary); +} + +static void handle_screen_event(bps_event_t *event) +{ + screen_event_t screen_event = screen_event_get_event(event); + int type = SCREEN_EVENT_NONE; + if (screen_event == NULL || + screen_get_event_property_iv(screen_event, SCREEN_PROPERTY_TYPE, &type) != 0) return; + switch (type) { + case SCREEN_EVENT_KEYBOARD: + handle_keyboard(screen_event); + break; + case SCREEN_EVENT_MTOUCH_TOUCH: + case SCREEN_EVENT_MTOUCH_MOVE: + case SCREEN_EVENT_MTOUCH_RELEASE: + handle_touch(screen_event, type); + break; + case SCREEN_EVENT_JOYSTICK: + handle_trackpad(screen_event); + break; + default: + break; + } +} + +static void handle_navigator_event(bps_event_t *event) +{ + int code = bps_event_get_code(event); + switch (code) { + case NAVIGATOR_EXIT: + app_shutdown = 1; + break; + case NAVIGATOR_WINDOW_ACTIVE: + app_active = 1; + set_status("running", "window active"); + break; + case NAVIGATOR_WINDOW_INACTIVE: + app_active = 0; + set_status("inactive", "window inactive"); + break; + case NAVIGATOR_ORIENTATION_CHECK: + navigator_orientation_check_response(event, false); + break; + case NAVIGATOR_SYSKEY_PRESS: { + int key = navigator_event_get_syskey_key(event); + const char *id = navigator_event_get_syskey_id(event); + int handled = key == NAVIGATOR_SYSKEY_SEND; + if (handled) pocket_input_pulse(&input, POCKET_BTN_CIRCLE); + if (id != NULL) navigator_syskey_press_response(id, handled != 0); + status_dirty = 1; + break; + } + default: + break; + } +} + +static void handle_event(bps_event_t *event) +{ + int domain; + if (event == NULL) return; + domain = bps_event_get_domain(event); + if (domain == screen_get_domain()) handle_screen_event(event); + else if (domain == navigator_get_domain()) handle_navigator_event(event); +} + +static int render_frame(void) +{ + PocketInputSample sample; + PocketRuntimeInput frame; + unsigned long action_sequence; + + pocket_input_sample(&input, &sample); + frame.buttons = sample.buttons; + frame.touch_down = sample.touch_down; + frame.touch_x = (int)(sample.touch_x * POCKET_LOGICAL_WIDTH / (surface_width > 0 ? surface_width : 1)); + frame.touch_y = (int)(sample.touch_y * POCKET_LOGICAL_HEIGHT / (surface_height > 0 ? surface_height : 1)); + frame.touch_hit = sample.touch_down + ? pocket_runtime_hit_test_bounds((float)frame.touch_x, (float)frame.touch_y) + : 0; + if (!pocket_runtime_tick(&frame)) { + set_error(pocket_runtime_error()); + return 0; + } + if (!pocket_runtime_gl_render(surface_width, surface_height) || + !eglSwapBuffers(egl_display, egl_surface)) { + set_error("OpenGL ES frame presentation failed"); + return 0; + } + frame_count += 1; + action_sequence = pocket_runtime_action_sequence(); + if (action_sequence != last_action_sequence) { + last_action_sequence = action_sequence; + set_status("action", pocket_runtime_action_name()); + } + write_status(); + return 1; +} + +int main(int argc, char **argv) +{ + Asset java_script = {NULL, 0}; + Asset pack = {NULL, 0}; + bps_event_t *event = NULL; + int exit_code = EXIT_FAILURE; + (void)argc; + + initialize_executable_directory(argv == NULL ? NULL : argv[0]); + pocket_input_init(&input, 1.0f); + write_status(); + if (bps_initialize() != BPS_SUCCESS) { + set_error("bps_initialize failed"); + goto cleanup; + } + if (!initialize_graphics()) goto cleanup_bps; + if (screen_request_events(screen_context) != BPS_SUCCESS || + navigator_request_events(0) != BPS_SUCCESS) { + set_error("BPS screen or navigator event registration failed"); + goto cleanup_graphics; + } + if (!read_asset("app.js", &java_script) || !read_asset("app.pak", &pack)) { + set_error("cannot read packaged app.js or app.pak"); + goto cleanup_graphics; + } + if (!pocket_runtime_boot( + (const char *)java_script.bytes, + java_script.length, + pack.bytes, + pack.length, + POCKET_LOGICAL_WIDTH, + POCKET_LOGICAL_HEIGHT + )) { + set_error(pocket_runtime_error()); + goto cleanup_graphics; + } + runtime_ready = 1; + if (!pocket_runtime_gl_initialize()) { + set_error("PocketJS GLES2 backend initialization failed"); + goto cleanup_runtime; + } + set_status("running", "Hero mounted"); + write_status(); + + while (!app_shutdown) { + int timeout = app_active ? 0 : -1; + if (bps_get_event(&event, timeout) != BPS_SUCCESS) { + set_error("bps_get_event failed"); + break; + } + handle_event(event); + if (app_active) { + do { + event = NULL; + if (bps_get_event(&event, 0) != BPS_SUCCESS) { + set_error("bps_get_event drain failed"); + app_shutdown = 1; + break; + } + handle_event(event); + } while (event != NULL && !app_shutdown && app_active); + if (!app_shutdown && !render_frame()) app_shutdown = 1; + } else { + write_status(); + } + } + exit_code = status_stage[0] == 'e' ? EXIT_FAILURE : EXIT_SUCCESS; + +cleanup_runtime: + if (runtime_ready) { + pocket_runtime_gl_shutdown(); + pocket_runtime_shutdown(); + runtime_ready = 0; + } +cleanup_graphics: + destroy_graphics(); +cleanup_bps: + bps_shutdown(); +cleanup: + free(pack.bytes); + free(java_script.bytes); + if (exit_code == EXIT_SUCCESS) set_status("stopped", "navigator exit"); + write_status(); + return exit_code; +} diff --git a/hosts/iphone2g/pocket_input.c b/hosts/iphone2g/pocket_input.c new file mode 100644 index 00000000..7707bea2 --- /dev/null +++ b/hosts/iphone2g/pocket_input.c @@ -0,0 +1,115 @@ +#include "pocket_input.h" + +#include "pocket_spec.h" + +#include + +void pocket_input_init(PocketInputState *state, float relative_threshold) +{ + memset(state, 0, sizeof(*state)); + state->relative_threshold = relative_threshold > 0.0f ? relative_threshold : 1.0f; + state->touch_id = -1; +} + +void pocket_input_button(PocketInputState *state, uint32_t button, int down, int repeat) +{ + if (button == 0) return; + if (down) { + state->held_keys |= button; + if (!repeat) state->pressed |= button; + } else { + state->held_keys &= ~button; + } +} + +void pocket_input_pulse(PocketInputState *state, uint32_t button) +{ + state->pressed |= button; +} + +void pocket_input_relative(PocketInputState *state, float delta_x, float delta_y) +{ + const float threshold = state->relative_threshold; + state->relative_x += delta_x; + state->relative_y += delta_y; + if (state->relative_x <= -threshold) { + state->pressed |= POCKET_BTN_LEFT; + state->relative_x = 0.0f; + } else if (state->relative_x >= threshold) { + state->pressed |= POCKET_BTN_RIGHT; + state->relative_x = 0.0f; + } + if (state->relative_y <= -threshold) { + state->pressed |= POCKET_BTN_UP; + state->relative_y = 0.0f; + } else if (state->relative_y >= threshold) { + state->pressed |= POCKET_BTN_DOWN; + state->relative_y = 0.0f; + } +} + +void pocket_input_primary(PocketInputState *state, int down) +{ + if (down && !state->primary_down) { + state->held_primary = POCKET_BTN_CIRCLE; + state->pressed |= POCKET_BTN_CIRCLE; + } else if (!down && state->primary_down) { + state->held_primary = 0; + } + state->primary_down = down != 0; +} + +void pocket_input_touch( + PocketInputState *state, + PocketTouchPhase phase, + int id, + float x, + float y +) +{ + switch (phase) { + case POCKET_TOUCH_DOWN: + /* The first contact is tracked; a second finger never becomes input. + * The same id going down again before the release was sampled simply + * continues the contact. */ + if (state->touch_id < 0 || state->touch_id == id) { + state->touch_id = id; + state->touch_down = 1; + state->touch_latched = 1; + state->touch_x = x; + state->touch_y = y; + } + break; + case POCKET_TOUCH_MOVE: + if (state->touch_id == id) { + state->touch_x = x; + state->touch_y = y; + } + break; + case POCKET_TOUCH_UP: + /* Only the down edge latches: a release is reported at the next sample + * and never re-arms a down frame. */ + if (state->touch_id == id) { + state->touch_x = x; + state->touch_y = y; + state->touch_down = 0; + } + break; + case POCKET_TOUCH_CANCEL: + state->touch_id = -1; + state->touch_down = 0; + state->touch_latched = 0; + break; + } +} + +void pocket_input_sample(PocketInputState *state, PocketInputSample *out) +{ + out->buttons = state->held_keys | state->held_primary | state->pressed; + out->touch_down = state->touch_down || state->touch_latched; + out->touch_x = state->touch_x; + out->touch_y = state->touch_y; + state->pressed = 0; + state->touch_latched = 0; + if (!state->touch_down) state->touch_id = -1; +} diff --git a/hosts/iphone2g/pocket_input.h b/hosts/iphone2g/pocket_input.h new file mode 100644 index 00000000..d25b5d46 --- /dev/null +++ b/hosts/iphone2g/pocket_input.h @@ -0,0 +1,82 @@ +#ifndef POCKET_INPUT_H +#define POCKET_INPUT_H + +#include + +/* + * Host-side input state for native hosts with a keyboard, a relative pointing + * device (trackpad, trackball), and one tracked touch contact. Platform event + * callbacks feed it through the functions below; the frame loop samples it + * exactly once per guest turn. The module is plain C with no platform headers + * so it compiles with the host compiler for the unit test in + * tests/fixtures/pocket-input-test.c. + * + * Semantics the two BlackBerry Classic hosts share: + * - a key down produces one press edge (platform key repeats do not) and + * holds the button until the key goes up; + * - relative motion accumulates per axis; crossing the threshold emits one + * d-pad pulse in that direction and resets that axis; + * - the relative device's primary button is the press button (CIRCLE), with + * its own held state so it cannot release a key that holds the same bit; + * - one contact is tracked from its DOWN to its UP; other contacts are + * ignored; a contact that went down and up between two samples still + * reports exactly one down sample (the latch), and a release is reported + * at the very next sample. + */ + +typedef enum { + POCKET_TOUCH_DOWN, + POCKET_TOUCH_MOVE, + POCKET_TOUCH_UP, + POCKET_TOUCH_CANCEL +} PocketTouchPhase; + +typedef struct { + uint32_t held_keys; + uint32_t held_primary; + uint32_t pressed; + float relative_x; + float relative_y; + float relative_threshold; + int primary_down; + int touch_id; + int touch_down; + int touch_latched; + float touch_x; + float touch_y; +} PocketInputState; + +typedef struct { + uint32_t buttons; + int touch_down; + float touch_x; + float touch_y; +} PocketInputSample; + +void pocket_input_init(PocketInputState *state, float relative_threshold); + +/* A platform key already mapped onto a portable button bit (0 = unmapped). */ +void pocket_input_button(PocketInputState *state, uint32_t button, int down, int repeat); + +/* A one-shot press edge for inputs without a release event (system keys). */ +void pocket_input_pulse(PocketInputState *state, uint32_t button); + +/* Relative pointing motion; d-pad pulses on threshold crossings. */ +void pocket_input_relative(PocketInputState *state, float delta_x, float delta_y); + +/* The relative device's primary button, level-triggered. */ +void pocket_input_primary(PocketInputState *state, int down); + +/* One contact with a platform id and position in any consistent unit. */ +void pocket_input_touch( + PocketInputState *state, + PocketTouchPhase phase, + int id, + float x, + float y +); + +/* The per-frame sample; clears press edges and the touch latch. */ +void pocket_input_sample(PocketInputState *state, PocketInputSample *out); + +#endif diff --git a/hosts/iphone2g/pocket_runtime.c b/hosts/iphone2g/pocket_runtime.c index 857df472..7ad62356 100644 --- a/hosts/iphone2g/pocket_runtime.c +++ b/hosts/iphone2g/pocket_runtime.c @@ -1,6 +1,7 @@ #include "pocket_runtime.h" #include "pocket_core.h" +#include "pocket_spec.h" #include "quickjs.h" #include @@ -17,7 +18,6 @@ #define POCKET_RASTER_DENSITY 1 #endif #define POCKETJS_SIMULATION_HZ 60 -#define POCKETJS_ANALOG_CENTER 32896 #define POCKETJS_ACTION_NAME_CAPACITY 64 #if defined(POCKET_RUNTIME_REPORT_BOOT_STAGE) @@ -629,7 +629,9 @@ int pocket_runtime_boot( return 1; } -int pocket_runtime_frame_ticks( +/* One guest turn (frame call + job drain), then `tick_count` core ticks. */ +static int run_frame( + uint32_t buttons, int touch_down, int touch_x, int touch_y, @@ -673,8 +675,8 @@ int pocket_runtime_frame_ticks( } } JSValue arguments[4] = { - JS_NewInt32(context, 0), - JS_NewInt32(context, POCKETJS_ANALOG_CENTER), + JS_NewUint32(context, buttons), + JS_NewInt32(context, POCKET_ANALOG_CENTER), touch_array, hit_array, }; @@ -695,6 +697,28 @@ int pocket_runtime_frame_ticks( return 1; } +int pocket_runtime_tick(const PocketRuntimeInput *input) { + if (input == 0) return 0; + return run_frame( + input->buttons, + input->touch_down, + input->touch_x, + input->touch_y, + input->touch_hit, + 1 + ); +} + +int pocket_runtime_frame_ticks( + int touch_down, + int touch_x, + int touch_y, + int touch_hit, + unsigned int tick_count +) { + return run_frame(0, touch_down, touch_x, touch_y, touch_hit, tick_count); +} + int pocket_runtime_frame(int touch_down, int touch_x, int touch_y, int touch_hit) { /* The original iPhone host presents at 30 Hz and advances two 60 Hz ticks. */ return pocket_runtime_frame_ticks(touch_down, touch_x, touch_y, touch_hit, 2); @@ -763,6 +787,11 @@ int pocket_runtime_gl_initialize(void) { return ui_gl_initialize() != 0; } +void pocket_runtime_gl_reset(void) { + if (runtime == 0 || context == 0) return; + ui_gl_reset_resources(); +} + int pocket_runtime_gl_render(int width, int height) { if (runtime == 0 || context == 0 || runtime_failed) return 0; if (width <= 0 || height <= 0) return 0; diff --git a/hosts/iphone2g/pocket_runtime.h b/hosts/iphone2g/pocket_runtime.h index a0e2f2e5..765125d0 100644 --- a/hosts/iphone2g/pocket_runtime.h +++ b/hosts/iphone2g/pocket_runtime.h @@ -13,6 +13,28 @@ int pocket_runtime_boot( int height ); /* `pack` is borrowed by QuickJS and must remain valid until shutdown. */ +/* + * One guest turn followed by exactly one core tick — the frame contract + * (docs/RUNTIMES.md, law 3). Hosts call it once per presented frame with the + * portable button mask (pocket_spec.h) and the sampled touch contact in + * logical pixels; `touch_hit` is the host-resolved bounds hit for that + * contact (pocket_runtime_hit_test_bounds) or zero. + */ +typedef struct { + uint32_t buttons; + int touch_down; + int touch_x; + int touch_y; + int touch_hit; +} PocketRuntimeInput; +int pocket_runtime_tick(const PocketRuntimeInput *input); + +/* + * Legacy frame entry points for the original iPhone host, which presents at + * 30 Hz and advances two core ticks per guest turn, and for the Windows CE + * host's tick-count form. They pass an empty button mask. New hosts call + * pocket_runtime_tick. + */ int pocket_runtime_frame(int touch_down, int touch_x, int touch_y, int touch_hit); int pocket_runtime_frame_ticks( int touch_down, @@ -44,13 +66,14 @@ unsigned long pocket_runtime_damage_pixels(void); int pocket_runtime_damage_bounds(int *bounds); /* - * Hardware path. `pocket_runtime_gl_initialize` needs a current OpenGL ES 1.1 - * context and returns zero if the GPU pipeline cannot be established, which is - * the host's signal to keep using the software rasterizer above. + * Hardware path. `pocket_runtime_gl_initialize` needs a current OpenGL ES + * context matching the core backend selected at compile time and returns zero + * if the GPU pipeline cannot be established. * `pocket_runtime_gl_render` draws the current retained tree into the bound * framebuffer; the CPU never rasterizes a pixel on this path. */ int pocket_runtime_gl_initialize(void); +void pocket_runtime_gl_reset(void); int pocket_runtime_gl_render(int width, int height); void pocket_runtime_gl_shutdown(void); diff --git a/hosts/iphone2g/pocket_spec.h b/hosts/iphone2g/pocket_spec.h new file mode 100644 index 00000000..b794591f --- /dev/null +++ b/hosts/iphone2g/pocket_spec.h @@ -0,0 +1,26 @@ +/* Generated by contracts/spec/gen-c.ts from contracts/spec/spec.ts. + * Do not edit: run `bun contracts/spec/gen-c.ts` and commit the result; + * tests/contract.ts byte-compares this file against the generator. */ +#ifndef POCKET_SPEC_H +#define POCKET_SPEC_H + +/* Portable button bitmask — identical on every host. Native hosts map + * their platform key codes onto these bits; the guest only sees the mask. */ +#define POCKET_BTN_SELECT 0x0001U +#define POCKET_BTN_START 0x0008U +#define POCKET_BTN_UP 0x0010U +#define POCKET_BTN_RIGHT 0x0020U +#define POCKET_BTN_DOWN 0x0040U +#define POCKET_BTN_LEFT 0x0080U +#define POCKET_BTN_LTRIGGER 0x0100U +#define POCKET_BTN_RTRIGGER 0x0200U +#define POCKET_BTN_TRIANGLE 0x1000U +#define POCKET_BTN_CIRCLE 0x2000U +#define POCKET_BTN_CROSS 0x4000U +#define POCKET_BTN_SQUARE 0x8000U + +/* frame(buttons, analog): analog packs a stick as (x << 8) | y, each axis + * 0..255 with 128 = center. Hosts without a stick pass this value. */ +#define POCKET_ANALOG_CENTER 0x8080U + +#endif diff --git a/hosts/iphone2g/rust_eh_personality.c b/hosts/iphone2g/rust_eh_personality.c new file mode 100644 index 00000000..b85bba15 --- /dev/null +++ b/hosts/iphone2g/rust_eh_personality.c @@ -0,0 +1,12 @@ +#include + +/* + * The no-std Rust core is built with panic=abort, but its objects still + * reference the unwinding personality symbol on targets whose system + * libraries expect one. Defining it here keeps the native link free of an + * unwinder; reaching it would already be a fatal defect, so it aborts. + */ +__attribute__((noreturn)) void rust_eh_personality(void) +{ + abort(); +} diff --git a/package.json b/package.json index 6841920b..809c7bd3 100644 --- a/package.json +++ b/package.json @@ -31,18 +31,22 @@ "apps/iphone4s-demo", "apps/ipodtouch-demo", "apps/meizu-m8-demo", + "apps/blackberry-classic-demo", "apps/nsengine", "hosts/apple", "hosts/iphone2g", "hosts/iphone4s", "hosts/ipodtouch", "hosts/meizu-m8", + "hosts/blackberry-android", + "hosts/blackberry-qnx", "hosts/web", "docs/APPLE.md", "docs/IPHONE2G.md", "docs/IPHONE4S.md", "docs/IPODTOUCH.md", "docs/MEIZU_M8.md", + "docs/BLACKBERRY_CLASSIC.md", "assets/brand", "assets/fonts", "assets/images/logo.png", @@ -205,6 +209,8 @@ "iphone4s": "bun tools/iphone4s.ts", "ipodtouch": "bun tools/ipodtouch.ts", "meizu-m8": "bun tools/meizu-m8.ts", + "blackberry-android": "bun tools/blackberry-android.ts", + "blackberry-qnx": "bun tools/blackberry-qnx.ts", "vita:art": "bun tools/generate-vita-livearea.ts", "vita:art:check": "bun tools/generate-vita-livearea.ts --check", "e2e:vita": "bun tests/e2e/vita3k.ts", diff --git a/site/assets/blog/blackberry-classic-hero-720.png b/site/assets/blog/blackberry-classic-hero-720.png new file mode 100644 index 00000000..9f8b4bd0 Binary files /dev/null and b/site/assets/blog/blackberry-classic-hero-720.png differ diff --git a/site/content/blog/blackberry-classic.md b/site/content/blog/blackberry-classic.md new file mode 100644 index 00000000..d6e2db56 --- /dev/null +++ b/site/content/blog/blackberry-classic.md @@ -0,0 +1,491 @@ +A real BlackBerry Classic on a wooden desk, its square screen running the PocketJS Hero demo: a PocketJS header with 60 FPS / 42 NODES / 9 DRAWS counters and 'ONE RUST CORE · ONE JSX APP', the headline 'JSX on Classic.', body text 'Flexbox, springs and baked type — running as a native BlackBerry 10 app.', a blue 'CLICK OR TAP' button, 'Count: 4', and 'Reactive on real hardware.' Below the square screen are the physical QWERTY keyboard and the tool belt with its optical trackpad. + +

The Hero demo running as a native BlackBerry 10 application on a real Classic — the square 720×720 screen above the tool belt and the physical keyboard.

+ +The BlackBerry Classic is the only machine we have ported to that will run the same PocketJS guest through **two completely different native stacks**. + +One is QNX-native: a BlackBerry 10 Core Native application that asks `libscreen` for a window, gets an OpenGL ES 2 context through EGL, and drives every frame from a BPS event loop. The other is the Android Runtime: an Android 4.3 (API 18) APK, a `GLSurfaceView` Activity sitting on one `armeabi-v7a` JNI library. + +The two paths diverge entirely **below** the QuickJS bridge. Above it, they mount the same guest bundle, the same no-std Rust UI core (with its GLES2 DrawList backend), the same QuickJS bridge (`hosts/iphone2g/pocket_runtime.c` — the filename is a historical accident; the iPhone 2G/4S and Meizu M8 hosts link it too), against one private device profile: **720×720 physical pixels, 360×360 logical, raster density 2, a fixed 60 Hz simulation clock, `input.buttons` + `input.touch` + `text.glyphs.baked`**. + +They differ in only three things: how the process is packaged, how it is installed, and how it is fed input. + +This post is about those three things, and what we ran into along the way: a phone that took security to the point where, in 2026, native development is nearly impossible; a community root project that pushed that door back open; the "everything is the network" way a BlackBerry talks to a computer; and a genuinely archaeological pair of protocols — **management and updates over HTTPS + CGI + XML/form-data, file management over SMB/CIFS** — so you can mount the phone's filesystem on your computer and manage it like local files. + +## The machine + + + BLACKBERRY CLASSIC · SQC100 / Q20 · DECEMBER 2014 + + + Screen3.5″ · 720×720 · ~294 ppi · 1:1 square + + SoCMSM8960 Snapdragon S4 Plus · 2×1.5 GHz Krait · Adreno 225 + + Memory2 GB RAM · 16 GB + microSD + + OSBlackBerry 10.3.3 · QNX-based (reports QNX 8.0.0) + + Inputphysical QWERTY + tool belt (optical trackpad + Menu/Back/Send/End) + + + PocketJS360×360 @density 2 → 720×720 · 60 Hz · input.buttons + input.touch + One device, one guest, two native stacks: QNX unsigned BAR (rooted) · Android Runtime v1-signed APK (stock) + + +The BlackBerry Classic (model SQC100, codename Q20) shipped in December 2014 as BlackBerry looking back in the all-touch era: it put the familiar physical shape from around 2011 back on. A full physical QWERTY keyboard, a navigation strip above it called the **tool belt** (Menu / Back / Send / End, with an optical trackpad in the middle), and a **square** screen. + +- **Screen**: 3.5 inches, **720×720**, about 294 ppi. The square screen is a BlackBerry keyboard-phone tradition — the display gives way to the keyboard, so its width equals its height. For us, square means the logical viewport is a square 360×360, scaled at raster density 2 to 720×720. +- **SoC**: Qualcomm Snapdragon S4 Plus **MSM8960**, dual-core 1.5 GHz Krait, Adreno 225 GPU. +- **Memory / storage**: 2 GB RAM, 16 GB storage, microSD slot. +- **OS**: BlackBerry 10.3, built on QNX; the unit we verified runs **10.3.3.3216**, the last generation of BB10 firmware. + +In the family of PocketJS targets the Classic is not weak — it has far more compute than the PSP or the Symbian E7. What makes it hard was never performance. It is **what you are allowed to do to get code onto it**. + +## Two stacks, one guest + +First, the two paths, side by side. + + + One guest · the fork is entirely below the QuickJS bridge + + + One guest bundle + Solid·JSX · no-std Rust core · GLES2 DrawList · QuickJS bridge + + + QNX-native host + Android Runtime host + + libscreen window + EGL / GLES2 + BPS event loop · navigator/screen + nativepackager -devMode → unsigned BAR + rooted Classicnative ELF process + GLSurfaceView Activity (API 18) + JNI .so · KeyEvent / MotionEvent + apksigner v1 → signed APK + stock ClassicAndroid runtime process + + + + + + + + 720×720 GLES2 present · portable button mask + touch snapshot + Above the QuickJS bridge: one core, renderer, fonts, reactivity — byte-for-byte identical + Two separate targets only because the target id is baked into guest and host and checked at boot. + + +| | QNX-native host (`hosts/blackberry-qnx`) | Android Runtime host (`hosts/blackberry-android`) | +| --- | --- | --- | +| Process | BlackBerry 10 Core Native ELF: `libscreen` window, EGL, OpenGL ES 2, BPS event loop | Android 4.3 (API 18) APK: a `GLSurfaceView` Activity over one `armeabi-v7a` JNI library | +| Package | **unsigned** development BAR (`blackberry-nativepackager -devMode`) | v1-signed APK | +| Install prerequisite | **a rooted Classic**: a stock device accepts an unsigned BAR only with a debug token, and the token-issuing service is gone | **a stock Classic**: with "allow other sources" enabled, BB10.3 sideloads APKs from the file manager | +| Input source | libscreen keyboard, multi-touch, `SCREEN_EVENT_JOYSTICK` trackpad events; navigator system keys | Android `KeyEvent`, touch `MotionEvent`, generic-motion / trackball events | +| Toolchain | a digest-pinned BBNDK Docker image (compile, package, deploy) | Android SDK Platform 18 + Build-Tools 35.0.0 + NDK r23c, unpacked at `setup`; JDK 17 in Docker | +| Device status | **verified on real hardware** | **no device result yet** | + +The one sentence that matters here: **they differ only below the QuickJS bridge**. + +`apps/blackberry-classic-demo` is the Hero wrapper both hosts build. The profile module (`tools/blackberry-classic-profile.ts`) registers `blackberry-qnx-dev` and `blackberry-android-dev` as two targets with an **identical display, capabilities, and host ABI (both 9)**. Why two targets and not one? Because the target id is compiled into the guest and into the native host and checked at boot — two different process paths each have to carry an identity that matches. + +The Rust core is `pocketjs-symbian-core` (under `engine/symbian` — another historical name): the no-std C-ABI build of `pocketjs-core` plus the GLES2 DrawList backend the Nokia E7, iPhone 2G/4S, and Meizu M8 hosts all link. Both Classic hosts build it with the `bare-platform` feature. The QNX side uses a hand-written target spec (`armv7-qnx-eabi.json`: ARMv7, VFPv3, soft-float ABI, PIC, `build-std`); the Android side uses the stock `armv7-linux-androideabi`. + +In other words, **the code this port actually adds is very thin**: two host processes (one `main.c`, one `PocketActivity.java` + `runtime.c`), one shared input state machine both of them feed (`pocket_input.c`), the glue for two toolchains, and one shared profile. The core, the renderer, the fonts, the reactivity — not a byte of it changed for BlackBerry. + +The guest side is thinner still — it is an ordinary Solid component that knows nothing about whether QNX or Android is underneath it: + +```tsx +import Hero from "../hero/app.tsx"; +import { reportAppAction } from "@pocketjs/framework/host"; + +// The same guest bundle is mounted by both Classic hosts; nothing in here is +// allowed to branch on which host is running it. +export default function BlackBerryClassicHero() { + return ( + reportAppAction("hero_press", count)} + /> + ); +} +``` + +That `reportAppAction("hero_press", …)` writes each press into a line of status inside the device sandbox — from a tap in TSX, to a record on the device, through an entire native stack, but the same code runs on both sides. + +## A phone that carved distrust into every layer + +BlackBerry's seriousness about security is the kind that stops a developer who just wants to run a demo dead in their tracks. + +On BB10, a native `.bar` application has only two legal ways onto a stock device: + +- **Release**: sign the BAR with keys issued by BlackBerry's signing authority (RDK/PBDT, later the BlackBerry ID token). Signing is online — you trade for it against BlackBerry's servers. +- **Development**: put the device in Development Mode and install a **debug token** — a credential signed by BlackBerry's servers, **bound to the device PIN and valid for 30 days**. Only then will the device run an unsigned `-devMode` BAR. When the token expires, unsigned apps stop running and you have to go ask the servers for a new one. + +The design was coherent for its time: even a developer's local debugging needs a time-limited, device-bound, officially blessed pass, and malware has almost nowhere to stand. But it has one fatal premise for its era — **BlackBerry's online services have to be alive**. + +They are not. BlackBerry gave notice back in September 2020: **after January 4, 2022**, the whole set of legacy services for BB7-and-earlier, BB10, and PlayBook OS 2.1-and-earlier would shut down. That did not just cut off data, calls, and texts — **the signing authority, debug-token issuance, BlackBerry ID, BlackBerry Link, and BlackBerry Blend all went with it**. + +So in 2026 a stock Classic is caught in a deadlock: to install an unsigned development BAR you need a debug token, and the server that issues tokens went dark four years ago. Release signing is the same — that path leads to a server that no longer exists. **Through official channels, you cannot install a single application you wrote onto a stock BlackBerry 10 device.** + +### The project that reopened the door + +Then, not long ago, someone pushed the door back open. + + is a project called **"BlackBerry 10 root and more"**, credited to **Oleksandr** (handle `bb10root`), with **guizmox** and **sw7ft** among those who contributed and supported it. It uses known vulnerabilities in BB10 to get **root** on the device and — the part that matters most to us — **bypasses the BAR signature check so unsigned `.bar` files install directly** (one of its routes exploits the `install_apk` command path skipping the bar signature check, plus packages with certain prefixes skipping verification). It has publicly verified 10.3.3.3216 — the firmware on the unit in our hands. + +Our QNX-native host reaches the device exactly this way. `blackberry-nativepackager -devMode` produces an unsigned BAR, `blackberry-deploy` pushes it to a rooted Classic, installs it, and launches it. No debug token, no signing authority, not a single living BlackBerry server anywhere in the loop. + +The people who do this kind of work usually get nothing back for it. They reverse-engineer a platform its own maker has sentenced to death, to serve a small group of people still tinkering with these old machines. And yet it is exactly that work that lets a square-screened 2014 phone run freshly written code in 2026. + +**A salute to the developers who put this work in.** Without bb10.root.sx the QNX half of this post would not exist at all — it would be stuck forever at "the build passes, but it installs on no real device." + +(The other half — the Android Runtime host — needs no root. BB10.3 already lets you sideload APKs from the file manager once "allow other sources" is on. That is one reason we keep both paths: one native path for rooted devices, one Android-compatibility path for stock ones.) + +## Everything is the network: how the phone talks to a computer + +Plug the Classic into USB expecting a storage disk, and what shows up on the computer instead is **a network adapter**. + +This is BlackBerry 10's default way of talking to a computer: the device's USB function enumerates as a CDC-NCM Ethernet adapter (USB vendor `0x0fca` — Research In Motion). In Development Mode the device puts itself at the link-local address **`169.254.0.1`** and the computer takes `169.254.0.2`. Everything after that — device info, installing apps, backup, OS updates, debugging — **all runs over that USB-Ethernet link, over HTTP/HTTPS**. ("Connect to Windows" mode switches the adapter to RNDIS so Windows' native driver can take over; the standard/Mac mode is CDC-NCM/ECM.) + +Our deploy tool follows exactly that path. On Linux, `tools/blackberry-qnx.ts` first uses `udevadm` to find the adapter whose vendor is `0fca` and whose driver is `cdc_ncm`, confirms it carries a `169.254.x` link-local route, and only then pushes the BAR; if the route is missing it simply prints `sudo ip address replace 169.254.0.2/16 dev …` for you to add. The `blackberry-deploy` that does the real work runs inside the BBNDK image with `--network host`, reaches the device's development service over `169.254.0.1`, and `-installApp -launchApp` installs and launches. + +**Debugging is the network, too.** Turn on Development Mode and the device starts a **`qconnDoor`** service **listening on TCP 4455** with challenge-response authentication; `blackberry-connect` / `blackberry-deploy` all connect to the device IP, and SSH runs on port 22 where only the unprivileged `devuser` account can log in. That underlying channel is a binary protocol over TCP with an RSA-1024 challenge and an AES-128-CBC session (in the reverse-engineered tooling the permission handshake is literally named `QCONNDOOR_PERMISSIONS`). No serial cable, no adb — to a computer, a BlackBerry is first of all a host on the network. + +## BlackBerry Link, and a little protocol archaeology + +To manage the files on the device, the official tool is **BlackBerry Link**. It is nice, but for us in 2026 on an Apple Silicon Mac it is basically a relic: + +- **Link only ships for Windows and macOS.** The macOS build stopped at **1.2.1 (April 2014)**, requires OS X 10.7+, and is **32-bit Intel**. Apple stopped running 32-bit binaries as of macOS Catalina (10.15) — so on today's macOS 26.6 on Apple Silicon **it simply will not launch**. The Windows build went a little further, to 1.2.3.56 (June 2014), and stopped there. +- **Even if you have an old machine that can run Link, its servers are gone.** Per BlackBerry's own EOL notice, after January 4, 2022 the "download system is no longer available," and Link/Blend/Desktop Manager/BlackBerry World were reduced to "limited functionality." Anything that depends on the official servers — downloads, updates — is dead. + +Fortunately the community left reverse-engineered tools behind. The archetype is **Sachesi** — a cross-platform tool by **Sacha Refshauge (GitHub `xsacha`)**, GPL-3.0, formerly "Dingleberry," open-sourced in May 2014. It searches, downloads, and extracts firmware; installs and uninstalls `.bar`; backs up and restores; wipes; reboots; reads device info — and it needs **no Development Mode**. It is written in Qt and still builds and runs from source today on Apple Silicon with a current Qt — which is how we read its source and saw the protocol clearly. + +And once you do see the protocol clearly, something interesting shows up: **BlackBerry uses two completely different protocols for "device management" and "file management."** + +### Management and updates: HTTPS + CGI + XML + +Device info, installing apps, backup, OS updates — this class of operation runs over an **HTTPS + CGI** interface: requests go to `/cgi-bin/*.cgi` on the device, and **every response is XML rooted at ``**. A few of the key endpoints: + +- `discovery.cgi` (plaintext HTTP:80): returns `` — PIN, model, `OsType`, `PlatformVersion`, `DeveloperModeEnabled`, and so on. +- `login.cgi` (HTTPS:443): a challenge-response login. The device answers with an `` (`Salt`, `Challenge`, iteration count `ICount`); the client runs **iterated SHA-512** (hashing `counter ∥ salt ∥ password` repeatedly, folding the challenge in on the last round) and sends the result back. +- `dynamicProperties.cgi`: `POST` a `Get Dynamic Properties=Get Dynamic Properties` form and get back the list of every application on the device (os/radio/application), the battery level, `HardwareID`, and more. +- `update.cgi`: this is how a `.bar` gets installed. First `POST` an `application/x-www-form-urlencoded` `mode=bar&size=` to start the install; the device answers ``; then the client POSTs the **raw bytes of the `.bar` directly as the body**, and the device drives it with a run of `` (each with a `` and a percentage), finishing with ``. + +Laid out, a single `.bar` install looks like this (endpoints, fields, and XML shapes taken from Sachesi's implementation): + +```text +# 1) Start the install: form-encoded, declaring the mode and total size +POST /cgi-bin/update.cgi Content-Type: application/x-www-form-urlencoded +mode=bar&size=4193095 + → + +# 2) POST the raw .bar bytes as the body (note: not multipart) +POST /cgi-bin/update.cgi?type=bar Content-Type: application/octet-stream +<…raw .bar bytes…> + +# 3) The device drives progress with a run of XML until it finishes + → InProgress42 + → InProgress88 + → Success +``` + +(One small correction so "form-data" doesn't mislead you: **the kickoff request is form-encoded, but the actual body is a raw octet-stream, not multipart.** The skeleton of "HTTPS + CGI + XML responses + a form kickoff" is right.) These tools also spoof their `User-Agent` as `QNXWebClient/1.0` and **deliberately ignore TLS certificate errors** — because the device uses a self-signed certificate. The login step (`login.cgi`) is a challenge-response: the device returns an `` carrying a `Salt` and iteration count, and the client hashes `counter ∥ salt ∥ password` with **iterated SHA-512**, folds the challenge in on the last round, and sends the result back. + +### File management: SMB/CIFS, mountable directly + +For "manage the files on the device," BlackBerry switches to a completely different protocol: **SMB/CIFS**. The device runs an SMB service (it identifies as **Samba 3.0.x** — and note, it runs on **QNX**, not Linux; people who sniffed the traffic years ago saw Samba and assumed Linux, which is wrong) listening on ports **139/445**, exporting a few shares: + +- **`media`**: internal user storage; +- **`removeable_sdcard`** (yes, the device misspells "removable"): present only when an SD card is inserted; +- **`certs`**: certificates. + +The username is set on the device under **Settings → Storage and Access → "Identification on Network"**, and the password is the **"Wi-Fi storage password"** there. It works over both USB and Wi-Fi. + +Which means something genuinely nice: **you can mount the BlackBerry's filesystem straight onto your computer and manage it like local files.** On macOS, `smb://169.254.0.1/media` in Finder connects; on Linux: + +```sh +sudo mount -t cifs //169.254.0.1/media /mnt/bb \ + -o user=,password=,vers=1.0,sec=ntlm +``` + +(Because the other end is SMB1-era Samba 3.0.37 and modern kernels disable SMB1 by default, you have to add `vers=1.0` explicitly.) A 2014 phone, and its filesystem is just a drive on your desktop. + +## MSC, and the MTP that was never chosen + +BlackBerry also supports USB Mass Storage (MSC), but sparingly: **it exposes only the external SD card as an MSC block device**, never the internal storage. + +And there is a knock-on limit: **once the SD card is enumerated as MSC, the SMB file management above can no longer mount that card.** The reason is not BlackBerry but MSC itself — **MSC is fundamentally a "dumb" block-device protocol** (Bulk-Only Transport + SCSI commands) that hands the computer **exclusive block-level ownership** of the whole device. The filesystem layer can have only one owner: either the computer has it mounted or the phone does, never both. So the moment MSC is on, the card has to be unmounted on the phone side, and the phone's apps — and the SMB path — can no longer see it. + +The period comparison is the interesting part. On most Android devices of that era, **MTP** was already the norm — and MTP works at the **file-object** layer, not the block layer, so a phone can expose files to the computer while it keeps reading and writing the same storage itself, and it can expose internal storage without switching to a block device. BlackBerry had a more modern option available and **did not pick MTP**, going instead with "SMB for internal storage + MSC for the SD card only." Why exactly, I could not find an official statement; but the result is that to fully manage this device's files you go over the network with SMB, not by plugging in USB as a thumb drive. + +## QNX's graphics, and a different design philosophy + +To understand why this phone is "everything is the network, everything is a service," you have to start with the QNX underneath it. + +QNX is a **microkernel** real-time operating system, built in 1980 by Quantum Software Systems (Dan Dodge and Gordon Bell) in Kanata, Canada (first commercial release in 1982, renamed QNX in 1984). It goes the opposite way from the **monolithic** kernels you know, Linux/XNU: **the kernel itself (`procnto`) is tiny**, doing only a few things — CPU scheduling, **synchronous message passing** (`MsgSend`/`MsgReceive`/`MsgReply`), interrupt redirection, timers. The filesystem, the network stack, device drivers, **and even the graphics** are all ordinary **user-space processes**. + +This has two consequences. First, **isolation**: when a driver crashes, only a user-space process crashed — it can be restarted, the kernel is untouched. This is exactly why QNX runs in cars, medical devices, industrial control, and routers — places that "can't die" (over 275 million vehicles run QNX today; it is exactly what RIM wanted — Harman bought QNX in 2004, RIM bought it from Harman in April 2010 — which is how there came to be a PlayBook and BB10). Second, **everything is a message**: opening a device, reading a file, asking for a display buffer — underneath, all of it is sending a message to some user-space **service process**. QNX registers those services as **pathnames** in one unified namespace (a resource manager), so it is very "Unix" at heart, but implemented as client-server message round-trips: + +```c +// Client: send a message, then block until the server replies. That is almost +// the whole of QNX IPC. +MsgSend(server_conn, &request, sizeof request, &reply, sizeof reply); + +// Server (in another process): receive, do the work, reply. +int rcvid = MsgReceive(channel, &request, sizeof request, NULL); +/* …do the work… (open/read/write on a device is exactly this round-trip) */ +MsgReply(rcvid, EOK, &reply, sizeof reply); +``` + +The `open()`/`read()` you write in POSIX get translated by the C library into a `MsgSend` like this, sent to the user-space service that registered the matching pathname. The kernel's only job is to move the message from one process to another — even the filesystem and the network card live outside it. + +Graphics is the same model. BB10's windowing and composition is handled by the **Screen graphics subsystem** (`libscreen`) — `screen` is itself a user-space service (it exposes pathnames under `/dev/screen`, exactly the resource-manager pattern) that owns the displays, the display pipelines, and composition. Applications are its clients: + +- An app creates a `screen_context_t`, a `screen_window_t`, allocates buffers (`screen_create_window_buffers`), and then either draws into them with the CPU and calls `screen_post_window`, or binds the buffers to EGL and draws with OpenGL ES — **our QNX host takes the latter**: `libscreen` window + EGL + GLES2. +- What actually stacks all the windows into the final image is the `screen` **compositor** service, using hardware display layers/overlays where it can and falling back to GPU composition when it can't. (One detail that happens to apply to us: in the official docs, when the whole screen has only one fullscreen application, Screen **bypasses composition entirely** — and our host is exactly one fullscreen takeover application.) +- And a very BB10 concept: **window groups** (`screen_create_window_group` / `screen_join_window_group`). One process can embed **another process's window** inside its own. Video, Cascades child windows, **and even the entire Android runtime's window** are composited into the picture this way — the system shell (the **navigator** process) owns top-level composition, and applications "join" its group. + + + QNX SCREEN · every window is an off-screen buffer; compositing is a service's job + + + Window group · navigator (shell) owns top-level composition · each client draws into its own buffer + + native app windowour QNX host · EGL/GLES2 + Cascades scenescene graph · own render thread + Android runtime windowall of Android = one QNX process + + + + + screen compositor service + owns displays & pipelines · composites via HW overlays or GPU · bypasses composition for one fullscreen app + + + 720×720 panelone final frame + Clients draw only into their own buffer; combining them into one screen is the screen service's job. + + +Our host takes the most direct path in there: ask `screen` for a window, bind its buffer to EGL, and then draw purely with GLES2. Condensed, the bring-up looks like this: + +```c +/* 1) Ask the screen service for a context; EGL gets a GLES2 context */ +screen_create_context(&screen_ctx, SCREEN_APPLICATION_CONTEXT); +egl_display = eglGetDisplay(EGL_DEFAULT_DISPLAY); +eglInitialize(egl_display, NULL, NULL); +eglBindAPI(EGL_OPENGL_ES_API); +eglChooseConfig(egl_display, config_attrs, &config, 1, &n); +egl_context = eglCreateContext(egl_display, config, EGL_NO_CONTEXT, + (EGLint[]){ EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE }); + +/* 2) Create the window, open our own window group, configure it as one + 720×720 double-buffered GLES2 target */ +screen_create_window(&screen_win, screen_ctx); +screen_create_window_group(screen_win, group_name); +screen_set_window_property_iv(screen_win, SCREEN_PROPERTY_FORMAT, &format); +screen_set_window_property_iv(screen_win, SCREEN_PROPERTY_USAGE, &(int){ SCREEN_USAGE_OPENGL_ES2 }); +screen_set_window_property_iv(screen_win, SCREEN_PROPERTY_BUFFER_SIZE, (int[]){ 720, 720 }); +screen_create_window_buffers(screen_win, 2); + +/* 3) Bind that window buffer as an EGL surface; from here it's just GLES2 */ +egl_surface = eglCreateWindowSurface(egl_display, config, screen_win, NULL); +eglMakeCurrent(egl_display, egl_surface, egl_surface, egl_context); +eglSwapInterval(egl_display, 1); /* follow vsync */ +``` + +Notice the "window" is, all the way through, **a buffer you asked a service for** — not a slab of video memory the kernel handed you. That is exactly what the resource-manager model looks like when applied to graphics. + +In this architecture, events arrive through **BPS** (BlackBerry Platform Services): a C library that folds screen, navigator, sensors, and the rest into a **single event queue**. Our host uses it in the plainest possible way — `bps_initialize()`, register for screen and navigator events, then loop on `bps_get_event`. Window activate/deactivate, orientation, exit, and system keys all arrive as events from the navigator shell process. (System state lives in a separate mechanism, **PPS** — a pile of readable/writable "objects" under `/pps`, where writing publishes and reading subscribes.) + +Our main loop is plain, too — **drain everything currently queued, then run exactly one frame**: + +```c +while (!app_shutdown) { + int timeout = app_active ? 0 : -1; /* foreground: take without blocking; background: block for a wake */ + bps_get_event(&event, timeout); + handle_event(event); /* screen (touch/keys/trackpad) + navigator (lifecycle/system keys) */ + + if (app_active) { + do { /* drain everything still queued this instant… */ + bps_get_event(&event, 0); + handle_event(event); + } while (event != NULL); + render_frame(); /* …then advance exactly one 60 Hz tick, then eglSwapBuffers */ + } +} +``` + +This design philosophy is quite unlike the other mobile systems of the era: + +- **iOS / Android** are built on monolithic kernels (XNU / Linux), the display driver lives in the kernel, the app's main thread drives UIKit / the view system directly, and a system compositor (iOS's render server, Android's SurfaceFlinger) puts it all on screen. +- **QNX / BB10** breaks all of that into message round-trips between user-space services; graphics is "just another service," and cross-process **window-group composition** is a first-class citizen. It carries the **isolation-and-determinism** genes it built up in cars and medical devices straight into a phone. +- Even BB10's own native UI framework, **Cascades** (from TAT, the Swedish design house RIM acquired, built on Qt/QML), follows the same bent: **it draws on a separate rendering thread, over a retained scene graph**, so a busy app thread never stalls the animation. Together with bezel gestures, Peek/Flow, and Active Frames, BB10's interaction itself grows out of "the shell owns the edges, rendering owns its own thread." + +What is striking is how naturally PocketJS lands on this structure. Our core is already **one pure frame function that must return quickly**, with the host owning the event pump — the same shape QNX wants ("drain the events, run a frame, return"), as naturally as it did on Symbian's active object. QNX wants to be asked politely, and PocketJS's host only knows how to ask politely. + +### While we're here: the Android compatibility layer + +This is also why the Classic can have a second stack at all. BB10's **Android runtime** is essentially **an entire Android userspace (Dalvik + frameworks) running as a QNX process**; its window is composited into the picture by `screen` through the window groups above, alongside native apps. It grew up over the course of BB10: Android 2.3.3 on PlayBook OS 2.0, up to 4.2.2 on BB10.2 (from 10.2.1 you could also sideload `.apk` straight from the file manager, and native C/C++ apps were supported), and Android 4.3 (API 18) on BB10.3, which also preloaded the Amazon Appstore. There is no Google Play Services; earlier Android apps had to be repackaged as `.bar` first (the community called the tool `apk2bar`). + +Our Android host is exactly an **API-18 APK**, aimed at that 4.3 runtime. So the same guest reaches the screen two ways: one path is QNX-native, asking `screen` for a window directly; the other is an Android app, hosted by the Android runtime and then composited by `screen` — **one compositor, one square screen, two completely different ways of getting there.** + +## The key in the middle: from wheel to trackpad + + + The key under your thumb · from wheel to trackpad + + + + 1999side track wheel850 + 2006trackballPearl 8100 + 2009optical trackpadCurve 8520 + 2013all-touch · removedZ10 / Q10 + 2014Classic brings it backQ20 + + The Classic tool belt — four keys, one optical trackpad in the middle + + + Send + Menu + opticaltrackpad + Back + End + + A tiny optical mouse reporting relative displacement — to native apps, that's SCREEN_EVENT_JOYSTICK DISPLACEMENT. + + +BlackBerry's identity is bound, in large part, to **the navigation key under your thumb**. Its lineage is worth a moment: + +- **Side track wheel**: the earliest BlackBerrys (the 850 in 1999 through the 8700 series around 2005) put a wheel on the side of the body — thumb-scroll, press to confirm. This is the ancestor of the "scroll wheel" the title refers to. +- **Trackball**: the Pearl 8100 in 2006 moved a small rolling ball to the front, four-way plus press; the Curve 8300 and Bold 9000 used it. Nice, but prone to dirt and wear. +- **Optical trackpad**: from the Curve 8520 in 2009, an **optical sensor** replaced the trackball — like a tiny optical mouse, no moving parts, sensing the finger's relative motion directly; the Bold 9700 and 9900 carried it on. +- On BB10's all-touch phones (Z10, Q10, Passport…) the key was removed entirely. +- Then the **Classic (Q20, December 2014)**: it **deliberately put the tool belt back** — Menu / Back / Send / End, with an **optical trackpad** in the middle, a nod to the Bold 9900 era. BB10.3.1 added trackpad support to a system that was never designed for one: **there is no global cursor** (only the browser and Maps get a pointer); everywhere else, a blue focus highlight moves cell by cell through the Cascades UI. + +For us, this trackpad is an interesting engineering problem, because **it is a relative pointing device**: it gives you displacement deltas, not coordinates, and certainly not discrete "up/down/left/right." Our guest, meanwhile, lives in a d-pad / button world (`input.buttons`). So each host has to turn continuous relative motion into discrete focus movement: + +- **The QNX host**: the trackpad arrives in `libscreen` as **`SCREEN_EVENT_JOYSTICK`** events carrying `SCREEN_PROPERTY_DISPLACEMENT` (displacement) and buttons. The displacement is an integer, every event is one "notch," so the host feeds it in with a threshold of 1 — every non-zero event is one d-pad pulse in its direction; a click (`buttons != 0`) becomes `CIRCLE`. +- **The Android host**: the runtime delivers the trackpad as generic-motion scroll axes or trackball deltas — continuous floats — so the host feeds them in with a threshold of 0.35 and a pulse fires only once the running sum crosses it. This mapping is **provisional** — we haven't yet watched, on a device, whether the Classic's Android runtime presents the trackpad as scroll, trackball, or a pointer. + +Both hosts hand their deltas to the same few lines, in the input state machine they share (`hosts/iphone2g/pocket_input.c`); only the threshold differs: + +```c +/* Relative motion → one d-pad pulse per threshold crossing; the axis resets + after a pulse, so the remainder of a big move can never flip the next one. + QNX: integer displacement, threshold 1. Android: float deltas, 0.35. */ +void pocket_input_relative(PocketInputState *state, float delta_x, float delta_y) +{ + const float threshold = state->relative_threshold; + state->relative_x += delta_x; + state->relative_y += delta_y; + if (state->relative_x <= -threshold) { state->pressed |= POCKET_BTN_LEFT; state->relative_x = 0.0f; } + else if (state->relative_x >= threshold) { state->pressed |= POCKET_BTN_RIGHT; state->relative_x = 0.0f; } + if (state->relative_y <= -threshold) { state->pressed |= POCKET_BTN_UP; state->relative_y = 0.0f; } + else if (state->relative_y >= threshold) { state->pressed |= POCKET_BTN_DOWN; state->relative_y = 0.0f; } +} +``` + +(The `POCKET_BTN_*` bits are not typed by hand in either host: `pocket_spec.h` is generated from `contracts/spec/spec.ts`, the same table the Rust core and the JS runtime are generated from, and the contract test refuses to let it drift.) + +This echoes a long-standing PocketJS attitude toward input. The repo already has a **hardware-neutral incremental-input contract** — `RelativeAxis` / `onAxisDelta` (`vapor/host/input.ts`) — a device-agnostic ABI for **incremental controls** like a Playdate crank or a rotary encoder. In that worldview the trackpad is "just another relative axis." The Hero demo only needs buttons, so we collapse the axis down to d-pad pulses rather than exposing `RelativeAxis` to the guest; but the bloodline is the same: **never let a device concept cross the boundary into the guest.** + +So the side wheel of 1999 and the optical trackpad of 2014 are, in PocketJS's eyes, the same thing — **a relative motion sensor hiding under your thumb** — exactly the way it sees a Playdate crank. + +## Input: collapsing two sets of hardware into one contract + +Whether it is QNX's `SCREEN_EVENT_*` or Android's `KeyEvent`/`MotionEvent`, none of it is **allowed across the QuickJS bridge**. The guest sees only one portable button mask and one touch snapshot. Each host translates the physical input into that contract: + +| Physical input | Portable input | +| --- | --- | +| trackpad movement | discrete d-pad focus pulses — one per threshold crossing of the accumulated motion (QNX: integer `SCREEN_PROPERTY_DISPLACEMENT`, threshold 1; Android: scroll-axis/trackball deltas, threshold 0.35 — provisional, see above) | +| trackpad click | the press button (`CIRCLE`), held while the button is down, tracked apart from the keys | +| Enter/Return, d-pad center | the press button | +| arrow keys | d-pad; a key down is one press edge, auto-repeat does not press again | +| Space | `START` | +| Menu | `TRIANGLE` | +| Send (QNX navigator system key) | a one-shot press edge; End and Back stay with the system | +| touchscreen | one tracked contact (a second finger never becomes input), divided into 360×360 logical coordinates, with the host-resolved bounds hit fact | + +Both translations land in the same place: `pocket_input.c`, a small state machine the QNX BPS callbacks and the Android JNI callbacks both feed, and which the frame loop samples exactly once per guest turn. It is plain C with no platform headers, so it is unit-tested with the host compiler — key edges, trackpad pulses, the click, and the touch latch all have a scenario in `tests/fixtures/pocket-input-test.c`. + +For these two hosts, `pocket_runtime.c` also grew two entry points: `pocket_runtime_tick` — **exactly one guest turn followed by one core tick**, taking the button mask, the sampled contact, and its hit fact (the older `pocket_runtime_frame*` calls stay for the original iPhone host, whose 30 Hz presentation advances two core ticks per guest turn) — and `pocket_runtime_gl_reset`, which drops the old GL resources after the platform recreates the GL context so the backend can re-initialize — a must on Android, because `GLSurfaceView` recreates the context on pause/resume. + +One frame's worth of input is fed in like this — sample the state machine into "a button mask + a touch snapshot," run one tick, then let the GPU draw: + +```c +static int render_frame(void) { + PocketInputSample sample; + PocketRuntimeInput frame; + pocket_input_sample(&input, &sample); // held + one-frame edges; latch consumed here + + frame.buttons = sample.buttons; + frame.touch_down = sample.touch_down; + frame.touch_x = (int)(sample.touch_x * POCKET_LOGICAL_WIDTH / surface_width); // 720 → 360 logical + frame.touch_y = (int)(sample.touch_y * POCKET_LOGICAL_HEIGHT / surface_height); + frame.touch_hit = sample.touch_down ? pocket_runtime_hit_test_bounds(frame.touch_x, frame.touch_y) : 0; + + pocket_runtime_tick(&frame); // one guest turn + one core tick + pocket_runtime_gl_render(surface_width, surface_height); // GPU draws the retained tree; the CPU touches no pixel + eglSwapBuffers(egl_display, egl_surface); + return 1; +} +``` + +The press edges and the touch latch inside that state machine are the cure for the following trap. There is a problem here shared with the Meizu M8 post: **an event stream and a sampled state are two different things**. The trackpad hands you a run of displacement events, but the guest samples only once per 60 Hz; a quick press-and-release can happen entirely between two samples. So the host has to **latch** an edge like a press until at least one frame has observed it. Touch is the same: a tap's down and up can fall in the same inter-frame gap — and the latch has to fire **only on the down**, never on the release, or a long press would hand the guest one more "down" frame after the finger had already left. (The first cut of the QNX host did exactly that; a reviewer caught it, and it is now the kind of mistake the unit test refuses.) + +## The translation seam: the host owns the pump, the guest owns the UI + + + Translation seam · host owns pump and presentation, guest owns state and its UI + + + Modern app model (guest) + The two host pumps + Solid signals + TSXdeclare relationships + + PocketJS retained treelayout · focus · hit test + + GLES2 DrawListGPU draws all of 720×720 + + eglSwapBuffers / GLSurfaceView720×720 present · no stretch + + Input returns through the same seam · no OS concept crosses the boundary + QNX SCREEN_* / navigatorAndroid KeyEvent / MotionEvent + + host adaptercoords · edges · one tick + + frame inputbuttons + touch, no HWND/BPS + + app speaks only state + desired UI + host speaks only pump + present + + +Put the two paths side by side and PocketJS's role on BlackBerry is the same as it was on Windows CE: **it does not replace the OS event loop; it sits inside it.** + +- In the QNX host, `bps_get_event` is the pump. It drains the screen/navigator events, normalizes them into one frame input, lets the guest take one tick, and presents 720×720 with `eglSwapBuffers`. +- In the Android host, `GLSurfaceView`'s `onDrawFrame` is the pump. Each frame, the JNI layer folds the accumulated key/touch/relative events into the same contract under one mutex and drives one guest tick. + +On both sides, Solid, the app code, and the Rust core **never know** that `screen_window_t`, BPS, `GLSurfaceView`, or `MotionEvent` exist. And in reverse, the host never learns what "a button" or "a component" means. Each side of the boundary owns half the world: **the host owns the pump and the presentation, the guest owns the state and the UI it wants.** + +This is also why adding a new target costs so little. The M8 turned Windows CE into a whole phone platform; we go the other way, bringing a self-contained modern UI runtime and asking the OS for only the smallest surface that will hold it. QNX and the Android Runtime each provide only that smallest surface. + +## Why bother + +PocketJS's whole bet is that **one guest, one core, dropped onto machines of every shape, changes only a thin layer of host**. The BlackBerry Classic pushes that bet to a new extreme: it runs **the same guest on the same phone through two unrelated native stacks** — once as a QNX-native application asking `screen` for a window, once as an Android 4.3 app hosted by the compatibility layer. The two paths split entirely below the QuickJS bridge and are byte-for-byte identical above it. It is probably the cleanest proof we have of "draw the boundary right and the machine becomes swappable." + +And the machine itself is a specimen about trust. BlackBerry carved security into every layer: even a developer's local debugging needs a time-limited, device-bound, officially blessed pass; files go over challenge-response CGI or password-protected SMB; an app is either signed or holds a token. It was impregnable in its day, and the price was this — when the servers behind it went dark, the whole device closed its door to new code. It was the community, not the vendor, that pushed the door back open. + +So the last word of this post goes to those people: to **bb10.root.sx**'s Oleksandr, and to guizmox, sw7ft, and everyone who re-rooted a platform its maker had already condemned; to **Sachesi**'s Sacha Refshauge, who reversed the official tools' protocol into a program that still compiles and runs today; to everyone still making firmware, writing tools, and keeping documentation for these square-screened phones. What you did is far heavier than this port — you are the reason a 2014 BlackBerry can still light up a freshly written frame in 2026. + +Respect. + +--- + +*Further reading: QNX's [System Architecture](https://www.qnx.com/developers/docs/7.1/com.qnx.doc.neutrino.sys_arch/topic/kernel.html) on the Neutrino microkernel and its [message passing](https://www.qnx.com/developers/docs/6.5.0SP1.update/com.qnx.doc.neutrino_sys_arch/ipc.html); the [Screen Graphics Subsystem](https://www.qnx.com/developers/docs/8.0/com.qnx.doc.screen/topic/manual/cscreen_appDevelopment.html) developer guide, including [composition](https://www.qnx.com/developers/docs/8.0/com.qnx.doc.screen/topic/manual/cscreen_composition.html) and [window groups](http://www.qnx.com/developers/docs/7.0.0/com.qnx.doc.screen/topic/manual/cscreen_windowing-groups.html); the [PPS](https://www.qnx.com/developers/docs/7.1/com.qnx.doc.neutrino.sys_arch/topic/pps.html) service; RIM's own [GoodCitizen](https://github.com/blackberry/NDK-Samples/blob/master/GoodCitizen/main.c) sample for BPS, screen, and the navigator. The BB10 root project lives at [bb10.root.sx](https://bb10.root.sx), and [Sachesi](https://github.com/xsacha/Sachesi) is the community tool whose source made the device protocol legible. BlackBerry's [End of Life FAQ](https://www.blackberry.com/us/en/support/devices/end-of-life) records the January 4, 2022 shutdown. PocketJS's Classic hosts are documented in [`docs/BLACKBERRY_CLASSIC.md`](https://github.com/pocket-stack/pocketjs/blob/main/docs/BLACKBERRY_CLASSIC.md).* diff --git a/site/nav.ts b/site/nav.ts index ae542da5..e4d6a8be 100644 --- a/site/nav.ts +++ b/site/nav.ts @@ -56,6 +56,14 @@ export interface BlogPost { } export const BLOG_POSTS: BlogPost[] = [ + { + slug: "blackberry-classic", + title: "One Square Screen, Two Native Stacks: PocketJS on the BlackBerry Classic", + date: "2026-08-19", + description: + "The same PocketJS guest, two native stacks on one phone: a BlackBerry 10 Core Native BAR over libscreen/EGL/GLES2 on a rooted Classic, and an Android 4.3 (API 18) APK through the BB10 Android runtime on a stock one — diverging only below the QuickJS bridge. Inside: QNX's microkernel and Screen compositor, the dead signing servers a community root project reopened, BlackBerry's two-protocol PC link (HTTPS+CGI+XML to manage, SMB/CIFS to mount files), why MSC only ever exposed the SD card, and the optical trackpad mapped as just another relative axis.", + author: { name: "HalfSweet", url: "https://github.com/HalfSweet" }, + }, { slug: "pocketjs-on-windows-ce", title: "From Message Pump to Multitouch: Windows CE, PocketJS, and the Meizu M8", diff --git a/tests/blackberry-classic.test.ts b/tests/blackberry-classic.test.ts new file mode 100644 index 00000000..35b9d119 --- /dev/null +++ b/tests/blackberry-classic.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { POCKET_TARGETS } from "../contracts/spec/platforms.ts"; +import { checkAppTypes } from "../framework/compiler/app-check.ts"; +import { verifyPlanHash } from "../framework/src/manifest/plan.ts"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { packageIdentity, renderTemplate } from "../tools/native-host-build.ts"; +import { + BLACKBERRY_ANDROID_DEV_TARGET_ID, + BLACKBERRY_CLASSIC_DEV_CONTRACTS, + BLACKBERRY_CLASSIC_HOST_ABI, + BLACKBERRY_CLASSIC_LOGICAL_VIEWPORT, + BLACKBERRY_CLASSIC_PHYSICAL_VIEWPORT, + BLACKBERRY_CLASSIC_RASTER_DENSITY, + BLACKBERRY_QNX_DEV_TARGET_ID, + resolveBlackBerryClassicBuildPlan, +} from "../tools/blackberry-classic-profile.ts"; + +const repository = join(import.meta.dir, ".."); +const manifestPath = join(repository, "apps/blackberry-classic-demo/pocket.json"); +const targets = [BLACKBERRY_QNX_DEV_TARGET_ID, BLACKBERRY_ANDROID_DEV_TARGET_ID] as const; + +function manifest(): Record { + return JSON.parse(readFileSync(manifestPath, "utf8")); +} + +describe("private BlackBerry Classic profiles", () => { + test("register both hosts privately with one square density-2 contract", () => { + for (const target of targets) { + expect(POCKET_TARGETS).not.toHaveProperty(target); + expect(BLACKBERRY_CLASSIC_DEV_CONTRACTS.targets[target]).toEqual({ + hostAbi: BLACKBERRY_CLASSIC_HOST_ABI, + platform: target === BLACKBERRY_QNX_DEV_TARGET_ID + ? "blackberry10-qnx" + : "blackberry10-android", + form: "takeover", + display: { + physicalViewport: BLACKBERRY_CLASSIC_PHYSICAL_VIEWPORT, + logicalViewports: [BLACKBERRY_CLASSIC_LOGICAL_VIEWPORT], + presentations: ["native"], + rasterDensity: BLACKBERRY_CLASSIC_RASTER_DENSITY, + }, + capabilities: ["input.buttons", "input.touch", "text.glyphs.baked"], + }); + } + }); + + test("resolve the Hero demo to the exact device plan for each host", () => { + for (const target of targets) { + const plan = resolveBlackBerryClassicBuildPlan(manifest(), target); + expect(plan.target).toEqual({ id: target, hostAbi: BLACKBERRY_CLASSIC_HOST_ABI }); + expect(plan.viewport).toEqual({ + logical: BLACKBERRY_CLASSIC_LOGICAL_VIEWPORT, + physical: BLACKBERRY_CLASSIC_PHYSICAL_VIEWPORT, + presentation: "native", + rasterDensity: BLACKBERRY_CLASSIC_RASTER_DENSITY, + policy: "fixed", + }); + expect(plan.features).toEqual({ + "input.buttons": true, + "input.touch": true, + "text.glyphs.baked": true, + }); + expect(plan.companions).toEqual([]); + expect(plan.app.output).toBe("blackberry-classic-main"); + expect(verifyPlanHash(plan)).toBe(true); + } + }); + + test("derive the platform package identity from the plan, not from a second copy", () => { + const plan = resolveBlackBerryClassicBuildPlan(manifest(), BLACKBERRY_QNX_DEV_TARGET_ID); + const inputs = extractHostBuildInputs(plan, { expectedTarget: BLACKBERRY_QNX_DEV_TARGET_ID }); + expect(inputs.app).toEqual({ + id: "dev.pocket-stack.blackberry-classic-demo", + title: "PocketJS: BlackBerry Classic Hero", + version: "0.1.1", + }); + expect(packageIdentity(inputs.app)).toEqual({ + packageId: "dev.pocket_stack.blackberry_classic_demo", + version: "0.1.1", + versionCode: 1001, + title: "PocketJS: BlackBerry Classic Hero", + }); + expect(() => packageIdentity({ ...inputs.app, id: "dev.9bad.segment" })).toThrow("package name"); + expect(() => packageIdentity({ ...inputs.app, version: "1.2" })).toThrow("major.minor.patch"); + expect(packageIdentity({ ...inputs.app, version: "2.34.5-rc.1" }).versionCode).toBe(2_034_005); + expect(renderTemplate("@POCKET_ID@", { ID: "a.b" })).toBe("a.b"); + expect(() => renderTemplate("@POCKET_ID@", {})).toThrow("@POCKET_ID@"); + }); + + test("reject capabilities and viewports the Classic hosts do not implement", () => { + const needsIme = manifest(); + needsIme.engine.capabilities.requires.push("input.ime"); + expect(() => resolveBlackBerryClassicBuildPlan(needsIme, BLACKBERRY_QNX_DEV_TARGET_ID)) + .toThrow("input.ime"); + + const stretched = manifest(); + stretched.app.viewport.fixed.logical = [320, 480]; + expect(() => resolveBlackBerryClassicBuildPlan(stretched, BLACKBERRY_ANDROID_DEV_TARGET_ID)) + .toThrow("320x480"); + }); + + test("type-check the explicit Solid and PocketJS imports of the demo", () => { + const result = checkAppTypes({ + entry: join(repository, "apps/blackberry-classic-demo/main.tsx"), + tsconfigPath: join(repository, "tsconfig.json"), + declarationFiles: [join(repository, "framework/src/jsx.d.ts")], + }); + expect( + result.diagnostics + .filter((diagnostic) => diagnostic.category === "error") + .map((diagnostic) => diagnostic.message), + ).toEqual([]); + expect(result.ok).toBe(true); + }); +}); + +describe("BlackBerry Classic toolchain pins", () => { + const qnx = JSON.parse( + readFileSync(join(repository, "tools/cli/blackberry-qnx-toolchain.json"), "utf8"), + ); + const android = JSON.parse( + readFileSync(join(repository, "tools/cli/blackberry-android-toolchain.json"), "utf8"), + ); + + test("both hosts build against the same QuickJS revision and Rust nightly", () => { + expect(qnx.quickjs.revision).toMatch(/^[0-9a-f]{40}$/); + expect(android.quickjs).toEqual(qnx.quickjs); + expect(android.rust.toolchain).toBe(qnx.rust.toolchain); + expect(android.app.manifest).toBe("apps/blackberry-classic-demo/pocket.json"); + expect(qnx.app.manifest).toBe("apps/blackberry-classic-demo/pocket.json"); + }); + + test("the QNX host pins the BBNDK image by digest and ships its Rust target", () => { + expect(qnx.image.digest).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(qnx.image.platform).toBe("linux/amd64"); + expect(qnx.qnx.architecture).toBe("armle-v7"); + expect(existsSync(join(repository, qnx.rust.target))).toBe(true); + }); + + test("the Android host pins API 18, the last Jelly Bean NDK, and the v1-only ABI", () => { + expect(android.android).toMatchObject({ + apiLevel: 18, + ndkVersion: "23.2.8568313", + abi: "armeabi-v7a", + clangTarget: "armv7a-linux-androideabi18", + }); + expect(android.android.packages.map((pkg: { id: string }) => pkg.id)).toEqual([ + "platforms;android-18", + "build-tools;35.0.0", + "ndk;23.2.8568313", + ]); + for (const pkg of android.android.packages) { + for (const archive of Object.values(pkg.archives) as { asset: string; sha1: string }[]) { + expect(archive.asset).toMatch(/\.zip$/); + expect(archive.sha1).toMatch(/^[0-9a-f]{40}$/); + } + } + expect(android.javaImage).toMatch(/@sha256:[0-9a-f]{64}$/); + expect(android.rust.target).toBe("armv7-linux-androideabi"); + }); +}); diff --git a/tests/contract.ts b/tests/contract.ts index d133b93d..9f1c9bb6 100644 --- a/tests/contract.ts +++ b/tests/contract.ts @@ -8,7 +8,10 @@ // (c) Regenerates package.json's exports block from the subpath registry // (framework/compiler/subpaths.ts) and byte-compares: the npm surface // can never drift from the one declaration. Fix = `bun tools/gen-exports.ts`. +// (d) Regenerates hosts/iphone2g/pocket_spec.h (the C input constants native +// hosts include) from spec.ts and byte-compares. Fix = `bun contracts/spec/gen-c.ts`. +import { generateC } from "../contracts/spec/gen-c.ts"; import { generateRust } from "../contracts/spec/gen-rust.ts"; import { withGeneratedExports } from "../tools/gen-exports.ts"; import { @@ -47,6 +50,16 @@ check( "run `bun contracts/spec/gen-rust.ts` and commit the result", ); +// ---- (d) generated pocket_spec.h is in sync ----------------------------------- + +const specHPath = new URL("../hosts/iphone2g/pocket_spec.h", import.meta.url).pathname; +const committedHeader = await Bun.file(specHPath).text().catch(() => null); +check( + committedHeader !== null && committedHeader === generateC(), + "hosts/iphone2g/pocket_spec.h matches spec.ts", + "run `bun contracts/spec/gen-c.ts` and commit the result", +); + // ---- (c) package.json exports match the subpath registry --------------------- const pkgPath = new URL("../package.json", import.meta.url).pathname; diff --git a/tests/fixtures/plans/portable-psp.plan.json b/tests/fixtures/plans/portable-psp.plan.json index 1d5c7142..9297e694 100644 --- a/tests/fixtures/plans/portable-psp.plan.json +++ b/tests/fixtures/plans/portable-psp.plan.json @@ -2,6 +2,7 @@ "app": { "id": "dev.pocket-stack.telemetry", "title": "Pocket Telemetry", + "version": "0.1.0", "entry": "app/main.tsx", "output": "main", "framework": "solid" @@ -29,5 +30,5 @@ "text.glyphs.baked": true }, "companions": [], - "planHash": "sha256:e3a257d89114161e6faecb37249f3cba37f444034a951c80a8ffcaed5a593ddf" + "planHash": "sha256:fc32497b58bf5b9fab7c827ba8f6073cd749cb92c6cec6d37829fca7cf7e2085" } diff --git a/tests/fixtures/plans/portable-vita.plan.json b/tests/fixtures/plans/portable-vita.plan.json index 1528e858..8398f55f 100644 --- a/tests/fixtures/plans/portable-vita.plan.json +++ b/tests/fixtures/plans/portable-vita.plan.json @@ -2,6 +2,7 @@ "app": { "id": "dev.pocket-stack.telemetry", "title": "Pocket Telemetry", + "version": "0.1.0", "entry": "app/main.tsx", "output": "main", "framework": "solid" @@ -29,5 +30,5 @@ "text.glyphs.baked": true }, "companions": [], - "planHash": "sha256:5b2ab23a3d3b54e0ef54bdd706092cd8350561d978f6cb1280a0c72afa647e96" + "planHash": "sha256:ce59ea6fe09e18d80c3c1df83915487a4f58d082b2ff11b59c95b7d214a3a8ba" } diff --git a/tests/fixtures/pocket-input-test.c b/tests/fixtures/pocket-input-test.c new file mode 100644 index 00000000..dbb242ff --- /dev/null +++ b/tests/fixtures/pocket-input-test.c @@ -0,0 +1,140 @@ +/* Behavioural test for hosts/iphone2g/pocket_input.c, compiled and run by + * tests/pocket-input.test.ts with the host compiler. Every scenario is one + * host event sequence followed by the frame samples the guest would see. */ +#include "../../hosts/iphone2g/pocket_input.h" +#include "../../hosts/iphone2g/pocket_spec.h" + +#include +#include + +static int failures; + +static void expect(int condition, const char *label) +{ + if (condition) return; + failures += 1; + fprintf(stderr, "FAIL %s\n", label); +} + +static PocketInputSample sample(PocketInputState *state) +{ + PocketInputSample out; + pocket_input_sample(state, &out); + return out; +} + +static void keyboard_edges(void) +{ + PocketInputState s; + pocket_input_init(&s, 1.0f); + pocket_input_button(&s, POCKET_BTN_CIRCLE, 1, 0); + expect(sample(&s).buttons == POCKET_BTN_CIRCLE, "key down is pressed and held on the first sample"); + pocket_input_button(&s, POCKET_BTN_CIRCLE, 1, 1); /* platform auto-repeat */ + expect(sample(&s).buttons == POCKET_BTN_CIRCLE, "a held key stays held across samples"); + pocket_input_button(&s, POCKET_BTN_CIRCLE, 0, 0); + expect(sample(&s).buttons == 0, "key up releases the button"); + pocket_input_button(&s, 0, 1, 0); + expect(sample(&s).buttons == 0, "an unmapped key (0) is ignored"); + pocket_input_pulse(&s, POCKET_BTN_CIRCLE); + expect(sample(&s).buttons == POCKET_BTN_CIRCLE, "a pulse presses for one sample"); + expect(sample(&s).buttons == 0, "a pulse does not hold"); +} + +static void relative_axis(void) +{ + PocketInputState s; + pocket_input_init(&s, 1.0f); /* integer displacements: a pulse per event */ + pocket_input_relative(&s, 3.0f, 0.0f); + expect(sample(&s).buttons == POCKET_BTN_RIGHT, "positive x displacement pulses RIGHT once"); + expect(sample(&s).buttons == 0, "a relative pulse does not hold"); + pocket_input_relative(&s, -1.0f, 0.0f); + expect(sample(&s).buttons == POCKET_BTN_LEFT, "the remainder of a large move does not flip the next pulse"); + pocket_input_relative(&s, 0.0f, -1.0f); + pocket_input_relative(&s, 0.0f, 2.0f); + expect(sample(&s).buttons == (POCKET_BTN_UP | POCKET_BTN_DOWN), "each event is its own pulse"); + + pocket_input_init(&s, 0.35f); /* fractional deltas accumulate */ + pocket_input_relative(&s, 0.2f, 0.0f); + expect(sample(&s).buttons == 0, "sub-threshold motion does not pulse"); + pocket_input_relative(&s, 0.2f, 0.0f); + expect(sample(&s).buttons == POCKET_BTN_RIGHT, "accumulated motion pulses once"); + pocket_input_relative(&s, 0.2f, 0.0f); + expect(sample(&s).buttons == 0, "a pulse resets the axis"); + pocket_input_relative(&s, -0.2f, 0.3f); + pocket_input_relative(&s, 0.0f, 0.1f); + expect(sample(&s).buttons == POCKET_BTN_DOWN, "opposite motion cancels; the other axis pulses DOWN"); +} + +static void primary_button(void) +{ + PocketInputState s; + pocket_input_init(&s, 1.0f); + pocket_input_primary(&s, 1); + expect(sample(&s).buttons == POCKET_BTN_CIRCLE, "primary down presses CIRCLE"); + pocket_input_primary(&s, 1); + expect(sample(&s).buttons == POCKET_BTN_CIRCLE, "primary level holds CIRCLE without a second edge"); + pocket_input_primary(&s, 0); + expect(sample(&s).buttons == 0, "primary up releases CIRCLE"); + pocket_input_button(&s, POCKET_BTN_CIRCLE, 1, 0); + sample(&s); + pocket_input_primary(&s, 1); + pocket_input_primary(&s, 0); + expect(sample(&s).buttons == POCKET_BTN_CIRCLE, "a click cannot release a key that holds the same bit"); +} + +static void touch_contact(void) +{ + PocketInputState s; + PocketInputSample out; + pocket_input_init(&s, 1.0f); + + /* A tap that goes down and up between two samples is still one press. */ + pocket_input_touch(&s, POCKET_TOUCH_DOWN, 0, 10.0f, 20.0f); + pocket_input_touch(&s, POCKET_TOUCH_UP, 0, 11.0f, 21.0f); + out = sample(&s); + expect(out.touch_down == 1 && out.touch_x == 11.0f && out.touch_y == 21.0f, "tap between samples reports one down sample"); + expect(sample(&s).touch_down == 0, "the sample after a tap is up"); + + /* A long press: the release is reported at the very next sample. */ + pocket_input_touch(&s, POCKET_TOUCH_DOWN, 0, 1.0f, 1.0f); + expect(sample(&s).touch_down == 1, "contact down"); + pocket_input_touch(&s, POCKET_TOUCH_MOVE, 0, 2.0f, 3.0f); + out = sample(&s); + expect(out.touch_down == 1 && out.touch_x == 2.0f && out.touch_y == 3.0f, "move updates the held contact"); + pocket_input_touch(&s, POCKET_TOUCH_UP, 0, 2.0f, 3.0f); + expect(sample(&s).touch_down == 0, "release is reported immediately, not one frame later"); + + /* A second finger never becomes input. */ + pocket_input_touch(&s, POCKET_TOUCH_DOWN, 0, 5.0f, 5.0f); + sample(&s); + pocket_input_touch(&s, POCKET_TOUCH_DOWN, 1, 50.0f, 50.0f); + pocket_input_touch(&s, POCKET_TOUCH_MOVE, 1, 60.0f, 60.0f); + out = sample(&s); + expect(out.touch_down == 1 && out.touch_x == 5.0f, "a second contact does not move the tracked one"); + pocket_input_touch(&s, POCKET_TOUCH_UP, 1, 60.0f, 60.0f); + expect(sample(&s).touch_down == 1, "a second contact's release does not lift the tracked one"); + pocket_input_touch(&s, POCKET_TOUCH_UP, 0, 5.0f, 5.0f); + expect(sample(&s).touch_down == 0, "the tracked contact's release lifts"); + pocket_input_touch(&s, POCKET_TOUCH_MOVE, 1, 70.0f, 70.0f); + expect(sample(&s).touch_down == 0, "a stray move from an untracked contact is not a press"); + + /* Cancel drops everything, and the next contact is tracked again. */ + pocket_input_touch(&s, POCKET_TOUCH_DOWN, 0, 1.0f, 1.0f); + pocket_input_touch(&s, POCKET_TOUCH_CANCEL, 0, 0.0f, 0.0f); + expect(sample(&s).touch_down == 0, "cancel clears the latch"); + pocket_input_touch(&s, POCKET_TOUCH_DOWN, 0, 9.0f, 9.0f); + expect(sample(&s).touch_down == 1, "a new contact after cancel is tracked"); +} + +int main(void) +{ + keyboard_edges(); + relative_axis(); + primary_button(); + touch_contact(); + if (failures != 0) { + fprintf(stderr, "%d pocket_input expectation(s) failed\n", failures); + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/tests/host-build-inputs.test.ts b/tests/host-build-inputs.test.ts index 3a0d030b..ab6bc0c6 100644 --- a/tests/host-build-inputs.test.ts +++ b/tests/host-build-inputs.test.ts @@ -20,6 +20,7 @@ describe("custom host build boundary", () => { const plan = portablePlan(); expect(extractHostBuildInputs(plan, { expectedTarget: "psp" })).toEqual({ appOutput: "main", + app: { id: "dev.pocket-stack.telemetry", title: "Pocket Telemetry", version: "0.1.0" }, target: "psp", hostAbi: 1, viewport: { @@ -46,6 +47,9 @@ describe("custom host build boundary", () => { embedApp: false, })).toEqual({ POCKETJS_APP_OUTPUT: "main", + POCKETJS_APP_ID: "dev.pocket-stack.telemetry", + POCKETJS_APP_TITLE: "Pocket Telemetry", + POCKETJS_APP_VERSION: "0.1.0", POCKETJS_EMBED_APP: "0", POCKETJS_OUTPUT_DIR: "/tmp/pocket", POCKETJS_TARGET: "psp", diff --git a/tests/npm-package.test.ts b/tests/npm-package.test.ts index df614444..f3af8ec7 100644 --- a/tests/npm-package.test.ts +++ b/tests/npm-package.test.ts @@ -66,18 +66,22 @@ describe("published npm artifacts", () => { "apps/iphone4s-demo", "apps/ipodtouch-demo", "apps/meizu-m8-demo", + "apps/blackberry-classic-demo", "apps/nsengine", "hosts/apple", "hosts/iphone2g", "hosts/iphone4s", "hosts/ipodtouch", "hosts/meizu-m8", + "hosts/blackberry-android", + "hosts/blackberry-qnx", "hosts/web", "docs/APPLE.md", "docs/IPHONE2G.md", "docs/IPHONE4S.md", "docs/IPODTOUCH.md", "docs/MEIZU_M8.md", + "docs/BLACKBERRY_CLASSIC.md", "assets/brand", "assets/fonts", "assets/images/logo.png", @@ -170,11 +174,18 @@ describe("published npm artifacts", () => { "apps/hero/app.tsx", "apps/iphone2g-demo/pocket.json", "apps/iphone4s-demo/pocket.json", + "apps/blackberry-classic-demo/pocket.json", "hosts/iphone2g/device_tool.c", "hosts/iphone2g/armv6-apple-ios.json", "hosts/iphone4s/armv7-apple-ios.json", + "hosts/blackberry-android/app/AndroidManifest.xml", + "hosts/blackberry-android/app/jni/runtime.c", + "hosts/blackberry-qnx/main.c", + "hosts/blackberry-qnx/bar-descriptor.xml", + "hosts/blackberry-qnx/armv7-qnx-eabi.json", "docs/IPHONE2G.md", "docs/IPHONE4S.md", + "docs/BLACKBERRY_CLASSIC.md", "assets/images/logo.png", "assets/images/spinner-00.svg", "assets/images/spinner-01.svg", @@ -211,6 +222,9 @@ describe("published npm artifacts", () => { "engine/crates/pocket-ui-surface/Cargo.toml", "engine/crates/pocket-ui-surface/src/lib.rs", "tools/cli/symbian-toolchain.json", + "tools/cli/blackberry-android-toolchain.json", + "tools/cli/blackberry-qnx-toolchain.json", + "tools/blackberry-qnx/build.sh", "tools/symbian/coda-usb-probe.c", "tools/symbian/Dockerfile.dockerignore", "engine/pocket3d/crates/pocket3d-vita/Cargo.toml", diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index 583775e7..89e7a076 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -314,6 +314,7 @@ describe("semantic resolution", () => { expect(result.plan.app).toEqual({ id: "dev.pocket-stack.telemetry", title: "Pocket Telemetry", + version: "0.1.0", entry: "app/main.tsx", output: "main", framework: "solid", @@ -399,6 +400,7 @@ describe("semantic resolution", () => { // note is dynamic-only. A new demo missing here fails the test on // purpose. const expected: Record = { + "blackberry-classic-demo": [false, false, false, true], // built by the private blackberry-{qnx,android}-dev profiles; macos-app also admits its fixed 360x360 buttons+glyphs contract cafe: [true, true, false, true], cards: [true, true, false, true], chrome: [true, true, false, true], diff --git a/tests/pocket-input.test.ts b/tests/pocket-input.test.ts new file mode 100644 index 00000000..705b98a1 --- /dev/null +++ b/tests/pocket-input.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const repository = join(import.meta.dir, ".."); + +describe("native host input state machine", () => { + test("keyboard edges, relative-axis pulses, the primary button, and the touch latch", () => { + const compiler = Bun.which("cc"); + expect(compiler, "cc is required to build the pocket_input test").toBeTruthy(); + if (!compiler) return; + const root = mkdtempSync(join(tmpdir(), "pocketjs-pocket-input-")); + try { + const executable = join(root, "pocket-input-test"); + const compiled = Bun.spawnSync([ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-I", + join(repository, "hosts/iphone2g"), + join(repository, "hosts/iphone2g/pocket_input.c"), + join(repository, "tests/fixtures/pocket-input-test.c"), + "-o", + executable, + ], { cwd: repository }); + expect(compiled.exitCode, compiled.stderr.toString()).toBe(0); + if (process.platform === "darwin") { + const xattr = Bun.which("xattr"); + if (xattr) Bun.spawnSync([xattr, "-d", "com.apple.provenance", executable]); + } + const ran = Bun.spawnSync([executable], { cwd: repository }); + expect(ran.exitCode, ran.stderr.toString()).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/test-suite.test.ts b/tests/test-suite.test.ts index 572b9c93..12bd5b6c 100644 --- a/tests/test-suite.test.ts +++ b/tests/test-suite.test.ts @@ -53,6 +53,17 @@ describe("declared test suite", () => { expect(meizuM8Tests.filter((file) => !declared.has(file))).toEqual([]); }); + test("runs every BlackBerry Classic test in the CI unit stage", () => { + const declared = unitTestFiles(); + const blackberryTests = readdirSync(join(repository, "tests")) + .filter((file) => /^blackberry-.*\.test\.ts$/.test(file)) + .map((file) => `tests/${file}`) + .sort(); + + expect(blackberryTests).not.toHaveLength(0); + expect(blackberryTests.filter((file) => !declared.has(file))).toEqual([]); + }); + test("runs every iPhone 4S test in the CI unit stage", () => { const declared = unitTestFiles(); const iphone4sTests = readdirSync(join(repository, "tests")) diff --git a/tools/blackberry-android.ts b/tools/blackberry-android.ts new file mode 100644 index 00000000..b10a724a --- /dev/null +++ b/tools/blackberry-android.ts @@ -0,0 +1,700 @@ +import { createHash } from "node:crypto"; +import { + cpSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + BLACKBERRY_ANDROID_DEV_TARGET_ID, + resolveBlackBerryClassicBuildPlan, +} from "./blackberry-classic-profile.ts"; +import { + buildGuestBundle, + ensureQuickJsCheckout, + type GuestBundle, + type GuestBundleRequest, + mustRunCommand, + packageIdentity, + type PackageIdentity, + printCheck, + quickJsCheckout, + quickJsCheckoutStatus, + readGuestBundle, + renderTemplate, + runCommand, + sha256File, + xmlEscape, +} from "./native-host-build.ts"; + +const LABEL = "PocketJS BlackBerry Android"; +const repository = fileURLToPath(new URL("..", import.meta.url)); +const command = Bun.argv[2] ?? "doctor"; +const toolchain = JSON.parse( + readFileSync( + join(repository, "tools/cli/blackberry-android-toolchain.json"), + "utf8", + ), +) as { + readonly toolchainVersion: string; + readonly cachePath: string; + readonly javaImage: string; + readonly quickjs: { + readonly version: string; + readonly repository: string; + readonly revision: string; + }; + readonly rust: { + readonly toolchain: string; + readonly target: string; + }; + readonly android: { + readonly apiLevel: number; + readonly platformVersion: string; + readonly buildToolsVersion: string; + readonly ndkVersion: string; + readonly abi: string; + readonly clangTarget: string; + readonly repository: string; + readonly packages: readonly SdkPackage[]; + }; + readonly app: { + readonly manifest: string; + readonly output: string; + }; +}; + +interface SdkArchive { + readonly asset: string; + /** The checksum Google publishes in repository2-3.xml (what sdkmanager checks). */ + readonly sha1: string; +} + +/** One SDK component: the archive per host OS and where it unpacks. */ +interface SdkPackage { + readonly id: string; + readonly path: string; + readonly archives: Readonly>>; +} + +/** + * The NDK ships one LLVM prebuilt per host operating system. Linux x86-64 is + * the verified host; the macOS prebuilt is x86-64 as well and runs under + * Rosetta on Apple silicon. + */ +function ndkHostTag(): string { + switch (process.platform) { + case "linux": + return "linux-x86_64"; + case "darwin": + return "darwin-x86_64"; + default: + throw new Error(`${LABEL}: no NDK r23c prebuilt for host ${process.platform}`); + } +} + +const cache = join(homedir(), ".cache/pocket-stack", toolchain.cachePath); +const sdk = process.env.POCKETJS_ANDROID_SDK_ROOT ?? join(cache, "sdk"); +const buildTools = join(sdk, "build-tools", toolchain.android.buildToolsVersion); +const ndk = join(sdk, "ndk", toolchain.android.ndkVersion); +const llvm = join(ndk, "toolchains/llvm/prebuilt", ndkHostTag(), "bin"); +const clang = join(llvm, `${toolchain.android.clangTarget}-clang`); +const readelf = join(llvm, "llvm-readelf"); +const androidJar = join( + sdk, + "platforms", + `android-${toolchain.android.apiLevel}`, + "android.jar", +); +const appHost = join(repository, "hosts/blackberry-android/app"); +const build = join(repository, ".pocket-build/blackberry-android"); +const staging = join(build, "staging"); +const appOutput = join(repository, toolchain.app.output); +const signing = join(cache, "signing"); +/* One local key signs every APK; Android upgrades an installed package only + * when the new APK carries the same signing identity, so a key generated by + * an earlier version of this tool keeps being used under its old name. */ +const keystoreName = existsSync(join(signing, "blackberry-android-probe.jks")) + ? "blackberry-android-probe.jks" + : "blackberry-classic.jks"; +const keystore = join(signing, keystoreName); +const quickJs = quickJsCheckout(join(cache, "sources/quickjs-rs")); +const guest: GuestBundleRequest = { + label: LABEL, + repository, + target: BLACKBERRY_ANDROID_DEV_TARGET_ID, + resolvePlan: (manifest) => + resolveBlackBerryClassicBuildPlan(manifest, BLACKBERRY_ANDROID_DEV_TARGET_ID), + manifestPath: join(repository, toolchain.app.manifest), + planPath: join(repository, ".pocket/blackberry-android/app.plan.json"), + outputDirectory: join(repository, "dist/blackberry-android/guest"), +}; + +function run(program: string, args: readonly string[]) { + return runCommand(program, args, repository); +} + +function mustRun( + program: string, + args: readonly string[], + cwd = repository, + env: NodeJS.ProcessEnv = process.env, +): string { + return mustRunCommand(LABEL, program, args, cwd, env); +} + +function dockerJava(args: readonly string[]): string { + const uid = process.getuid?.() ?? 1000; + const gid = process.getgid?.() ?? 1000; + return mustRun("docker", [ + "run", + "--rm", + "--user", + `${uid}:${gid}`, + "-e", + "HOME=/tmp", + "-v", + `${repository}:/repo:ro`, + "-v", + `${sdk}:/android-sdk:ro`, + "-v", + `${build}:/build`, + "-v", + `${signing}:/signing`, + toolchain.javaImage, + ...args, + ]); +} + +function javaImagePresent(): boolean { + return run("docker", ["image", "inspect", toolchain.javaImage]).exitCode === 0; +} + +/** + * Checks the target's std directory in the pinned toolchain's sysroot. `rustup + * target list --toolchain X` would install a missing X on the spot, which a + * doctor must not do. + */ +function rustTargetInstalled(): boolean { + const sysroot = run("rustup", ["run", toolchain.rust.toolchain, "rustc", "--print", "sysroot"]); + if (sysroot.exitCode !== 0) return false; + return existsSync( + join(sysroot.stdout.trim(), "lib/rustlib", toolchain.rust.target, "lib"), + ); +} + +function checkPath(label: string, path: string): boolean { + return printCheck(label, existsSync(path), path); +} + +function doctor(): void { + const rust = run("rustup", ["run", toolchain.rust.toolchain, "rustc", "--version"]); + const quickjs = quickJsCheckoutStatus(quickJs.root, toolchain.quickjs); + const sdkChecks = [ + checkPath(`Android SDK Platform ${toolchain.android.apiLevel}`, androidJar), + checkPath(`NDK ${toolchain.android.ndkVersion} clang`, clang), + checkPath("NDK llvm-readelf", readelf), + checkPath("aapt2", join(buildTools, "aapt2")), + checkPath("aapt", join(buildTools, "aapt")), + checkPath("d8", join(buildTools, "d8")), + checkPath("zipalign", join(buildTools, "zipalign")), + checkPath("apksigner", join(buildTools, "apksigner")), + ]; + const checks = [ + ...sdkChecks, + printCheck("Java image", javaImagePresent(), toolchain.javaImage), + printCheck( + "Rust nightly", + rust.exitCode === 0, + rust.stdout.trim() || toolchain.rust.toolchain, + ), + printCheck( + "Rust Android target", + rustTargetInstalled(), + `${toolchain.rust.target} on ${toolchain.rust.toolchain}`, + ), + printCheck("pinned QuickJS", quickjs.ok, quickjs.detail), + ]; + if (sdkChecks.some((ok) => !ok)) { + console.log( + `Run \`bun blackberry-android setup\` to unpack the pinned SDK archives into ${sdk}, ` + + `or point POCKETJS_ANDROID_SDK_ROOT at an SDK that already holds ` + + `${toolchain.android.packages.map((pkg) => pkg.id).join(", ")}.`, + ); + } + if (checks.some((ok) => !ok)) process.exitCode = 1; + else console.log(`[ok] toolchain: ${toolchain.toolchainVersion}`); +} + +function requireToolchain(): void { + doctor(); + if (process.exitCode) { + throw new Error(`${LABEL}: toolchain is incomplete; see the doctor report above`); + } +} + +async function sha1File(path: string): Promise { + const hash = createHash("sha1"); + for await (const chunk of Bun.file(path).stream()) hash.update(chunk); + return hash.digest("hex"); +} + +/** + * Unpacks the pinned SDK archives (the same files `sdkmanager` installs) into + * the SDK root, one host-OS archive per component; nothing is downloaded for + * a component whose directory already exists. + */ +async function installSdkPackages(): Promise { + const downloads = join(cache, "downloads"); + for (const pkg of toolchain.android.packages) { + const target = join(sdk, pkg.path); + if (existsSync(target)) continue; + const archive = + pkg.archives[process.platform as "linux" | "darwin"] ?? pkg.archives.any; + if (!archive) { + throw new Error(`${LABEL}: ${pkg.id} has no archive for host ${process.platform}`); + } + mkdirSync(downloads, { recursive: true }); + const download = join(downloads, archive.asset); + if (!existsSync(download) || (await sha1File(download)) !== archive.sha1) { + const url = `${toolchain.android.repository}${archive.asset}`; + console.log(`${LABEL}: downloading ${url}`); + const response = await fetch(url); + if (!response.ok || response.body === null) { + throw new Error(`${LABEL}: ${url} failed (${response.status})`); + } + const partial = `${download}.part`; + const sink = Bun.file(partial).writer(); + let received = 0; + for await (const chunk of response.body) { + sink.write(chunk); + received += chunk.byteLength; + } + await sink.end(); + renameSync(partial, download); + console.log(`${LABEL}: ${archive.asset} ${received} bytes`); + const digest = await sha1File(download); + if (digest !== archive.sha1) { + rmSync(download, { force: true }); + throw new Error( + `${LABEL}: ${archive.asset} sha1 ${digest} does not match the pinned ${archive.sha1}`, + ); + } + } + const scratch = mkdtempSync(join(downloads, "unpack-")); + try { + mustRun("unzip", ["-q", download, "-d", scratch]); + const entries = readdirSync(scratch).filter((name) => !name.startsWith(".")); + if (entries.length !== 1) { + throw new Error( + `${LABEL}: ${archive.asset} unpacked to ${entries.length} entries, expected one directory`, + ); + } + mkdirSync(dirname(target), { recursive: true }); + renameSync(join(scratch, entries[0]), target); + console.log(`${LABEL}: ${pkg.id} -> ${target}`); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + } +} + +async function setup(): Promise { + await installSdkPackages(); + if (!javaImagePresent()) mustRun("docker", ["pull", toolchain.javaImage]); + ensureQuickJsCheckout(LABEL, quickJs.root, toolchain.quickjs); + if (!rustTargetInstalled()) { + mustRun("rustup", [ + "target", + "add", + toolchain.rust.target, + "--toolchain", + toolchain.rust.toolchain, + ]); + } + doctor(); +} + +function ensureKeystore(): void { + mkdirSync(signing, { recursive: true }); + if (existsSync(keystore)) return; + dockerJava([ + "keytool", + "-genkeypair", + "-noprompt", + "-keystore", + `/signing/${keystoreName}`, + "-storepass", + "android", + "-alias", + "androiddebugkey", + "-keypass", + "android", + "-dname", + "CN=PocketJS BlackBerry Classic,O=PocketJS,C=HK", + "-keyalg", + "RSA", + "-keysize", + "2048", + "-validity", + "10000", + ]); +} + +/** javac → jar → d8 for PocketActivity, into staging/classes.dex. */ +function compileActivity(): void { + mkdirSync(join(build, "classes"), { recursive: true }); + mkdirSync(join(build, "dex"), { recursive: true }); + dockerJava([ + "javac", + "-encoding", + "UTF-8", + "-source", + "7", + "-target", + "7", + "-bootclasspath", + `/android-sdk/platforms/android-${toolchain.android.apiLevel}/android.jar`, + "-d", + "/build/classes", + "/repo/hosts/blackberry-android/app/src/dev/pocketstack/blackberry/PocketActivity.java", + ]); + dockerJava(["jar", "cf", "/build/classes.jar", "-C", "/build/classes", "."]); + dockerJava([ + `/android-sdk/build-tools/${toolchain.android.buildToolsVersion}/d8`, + "--min-api", + String(toolchain.android.apiLevel), + "--output", + "/build/dex", + "/build/classes.jar", + ]); + copyFileSync(join(build, "dex/classes.dex"), join(staging, "classes.dex")); +} + +/** + * aapt2 → zipalign → apksigner over staging/ (classes.dex + lib/) plus the + * guest assets. Android 4.3 verifies only the JAR (v1) signature scheme, so + * every newer scheme is disabled explicitly. + */ +function packageApk(identity: PackageIdentity, resources: string, assets: string): { + readonly signature: string; + readonly badging: string; +} { + const compiled = join(build, "app-res.zip"); + mustRun(join(buildTools, "aapt2"), ["compile", "--dir", resources, "-o", compiled]); + const manifest = join(build, "AndroidManifest.xml"); + writeFileSync( + manifest, + renderTemplate(readFileSync(join(appHost, "AndroidManifest.xml"), "utf8"), { + PACKAGE: identity.packageId, + VERSION_CODE: identity.versionCode, + VERSION_NAME: identity.version, + }), + ); + const unsigned = join(build, "app-unsigned.apk"); + mustRun(join(buildTools, "aapt2"), [ + "link", + "-o", + unsigned, + "--manifest", + manifest, + "-I", + androidJar, + "-A", + assets, + "--min-sdk-version", + String(toolchain.android.apiLevel), + "--target-sdk-version", + String(toolchain.android.apiLevel), + compiled, + ]); + mustRun("zip", ["-q", "-r", unsigned, "classes.dex", "lib"], staging); + const aligned = join(build, "app-aligned.apk"); + mustRun(join(buildTools, "zipalign"), ["-f", "-p", "4", unsigned, aligned]); + ensureKeystore(); + dockerJava([ + `/android-sdk/build-tools/${toolchain.android.buildToolsVersion}/apksigner`, + "sign", + "--ks", + `/signing/${keystoreName}`, + "--ks-key-alias", + "androiddebugkey", + "--ks-pass", + "pass:android", + "--key-pass", + "pass:android", + "--min-sdk-version", + String(toolchain.android.apiLevel), + "--v1-signing-enabled", + "true", + "--v2-signing-enabled", + "false", + "--v3-signing-enabled", + "false", + "--v4-signing-enabled", + "false", + "--out", + "/build/app-signed.apk", + "/build/app-aligned.apk", + ]); + mkdirSync(dirname(appOutput), { recursive: true }); + copyFileSync(join(build, "app-signed.apk"), appOutput); + const signature = dockerJava([ + `/android-sdk/build-tools/${toolchain.android.buildToolsVersion}/apksigner`, + "verify", + "--verbose", + "--print-certs", + "--min-sdk-version", + String(toolchain.android.apiLevel), + "/build/app-signed.apk", + ]); + const badging = mustRun(join(buildTools, "aapt"), ["dump", "badging", appOutput]); + return { signature, badging }; +} + +function resetBuild(): void { + rmSync(build, { recursive: true, force: true }); + mkdirSync(join(staging, "lib", toolchain.android.abi), { recursive: true }); +} + +function buildRustCore(): string { + const rustTarget = join(build, "rust"); + mustRun( + "rustup", + [ + "run", + toolchain.rust.toolchain, + "cargo", + "build", + "--release", + "--locked", + "--target", + toolchain.rust.target, + "--features", + "bare-platform", + "--target-dir", + rustTarget, + ], + join(repository, "engine/symbian"), + { + ...process.env, + CARGO_PROFILE_RELEASE_LTO: "false", + CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER: clang, + }, + ); + const library = join(rustTarget, toolchain.rust.target, "release/libpocketjs_symbian_core.a"); + if (!existsSync(library)) { + throw new Error(`${LABEL}: Rust core archive is absent: ${library}`); + } + return library; +} + +function buildQuickJs(): string { + const objects = join(build, "objects/quickjs"); + mkdirSync(objects, { recursive: true }); + const flags = [ + "-std=gnu11", + "-O2", + "-fPIC", + "-funsigned-char", + "-fno-strict-aliasing", + "-ffunction-sections", + "-fdata-sections", + "-D_GNU_SOURCE", + `-DCONFIG_VERSION="${toolchain.quickjs.version}"`, + `-I${quickJs.source}`, + ]; + const objectPaths: string[] = []; + for (const source of ["cutils.c", "dtoa.c", "libregexp.c", "libunicode.c", "quickjs.c"]) { + const object = join(objects, source.replace(/\.c$/, ".o")); + mustRun(clang, [...flags, "-c", join(quickJs.source, source), "-o", object]); + objectPaths.push(object); + } + const staticFunctions = join(objects, "static-functions.o"); + mustRun(clang, [...flags, "-c", quickJs.staticFunctions, "-o", staticFunctions]); + objectPaths.push(staticFunctions); + const library = join(build, "libquickjs.a"); + mustRun(join(llvm, "llvm-ar"), ["rcs", library, ...objectPaths]); + return library; +} + +function buildNativeLibrary(bundle: GuestBundle, quickJsLibrary: string, coreLibrary: string): string { + const objects = join(build, "objects"); + const cFlags = [ + "-std=gnu11", + "-Os", + "-fPIC", + "-fno-strict-aliasing", + "-ffunction-sections", + "-fdata-sections", + "-fvisibility=hidden", + "-Wall", + "-Wextra", + "-Werror", + "-Wno-unused-parameter", + ]; + const portableRuntime = join(objects, "pocket_runtime.o"); + mustRun(clang, [ + ...cFlags, + `-DPOCKETJS_TARGET_ID="${bundle.inputs.target}"`, + `-DPOCKETJS_HOST_ABI=${bundle.inputs.hostAbi}`, + `-DPOCKET_RASTER_DENSITY=${bundle.inputs.viewport.rasterDensity}`, + `-I${join(repository, "hosts/iphone2g")}`, + `-I${quickJs.source}`, + "-c", + join(repository, "hosts/iphone2g/pocket_runtime.c"), + "-o", + portableRuntime, + ]); + const androidRuntime = join(objects, "android_runtime.o"); + mustRun(clang, [ + ...cFlags, + `-DPOCKET_LOGICAL_WIDTH=${bundle.inputs.viewport.logical[0]}`, + `-DPOCKET_LOGICAL_HEIGHT=${bundle.inputs.viewport.logical[1]}`, + `-I${join(repository, "hosts/iphone2g")}`, + "-c", + join(appHost, "jni/runtime.c"), + "-o", + androidRuntime, + ]); + const sharedObjects = ["pocket_input", "rust_eh_personality"].map((name) => { + const object = join(objects, `${name}.o`); + mustRun(clang, [ + ...cFlags, + `-I${join(repository, "hosts/iphone2g")}`, + "-c", + join(repository, `hosts/iphone2g/${name}.c`), + "-o", + object, + ]); + return object; + }); + const nativeLibrary = join(staging, "lib", toolchain.android.abi, "libpocketjs.so"); + /* No -landroid: the library needs nothing beyond GLESv2/log/dl/m/c, and + * --no-undefined turns any missing native symbol into a link failure. */ + mustRun(clang, [ + "-shared", + "-Wl,--build-id=none", + "-Wl,--gc-sections", + "-Wl,--exclude-libs,ALL", + "-Wl,--no-undefined", + androidRuntime, + portableRuntime, + ...sharedObjects, + quickJsLibrary, + coreLibrary, + "-o", + nativeLibrary, + "-lGLESv2", + "-llog", + "-ldl", + "-lm", + ]); + return nativeLibrary; +} + +function buildApp(): void { + requireToolchain(); + const bundle = readGuestBundle(guest); + resetBuild(); + const coreLibrary = buildRustCore(); + const quickJsLibrary = buildQuickJs(); + const nativeLibrary = buildNativeLibrary(bundle, quickJsLibrary, coreLibrary); + compileActivity(); + + const assets = join(build, "assets"); + mkdirSync(assets, { recursive: true }); + copyFileSync(bundle.javaScript, join(assets, "app.js")); + copyFileSync(bundle.pack, join(assets, "app.pak")); + const identity = packageIdentity(bundle.inputs.app); + const resources = join(build, "resources"); + cpSync(join(appHost, "res"), resources, { recursive: true }); + writeFileSync( + join(resources, "values/strings.xml"), + renderTemplate(readFileSync(join(appHost, "res/values/strings.xml"), "utf8"), { + /* Android string resources also need apostrophes escaped. */ + TITLE: xmlEscape(identity.title).replace(/'/g, "\\'"), + }), + ); + mkdirSync(join(resources, "drawable"), { recursive: true }); + copyFileSync( + join(repository, "assets/images/logo.png"), + join(resources, "drawable/icon.png"), + ); + const { signature, badging } = packageApk(identity, resources, assets); + for (const marker of [ + `package: name='${identity.packageId}' versionCode='${identity.versionCode}' versionName='${identity.version}'`, + `sdkVersion:'${toolchain.android.apiLevel}'`, + ]) { + if (!badging.includes(marker)) { + throw new Error(`${LABEL}: APK badging is missing ${marker}`); + } + } + const receipt = { + schema: 1, + toolchain: toolchain.toolchainVersion, + planHash: bundle.plan.planHash, + package: identity, + target: bundle.inputs.target, + hostAbi: bundle.inputs.hostAbi, + viewport: bundle.inputs.viewport, + apk: { + path: toolchain.app.output, + bytes: readFileSync(appOutput).byteLength, + sha256: sha256File(appOutput), + }, + guest: { + javaScript: sha256File(bundle.javaScript), + pack: sha256File(bundle.pack), + }, + nativeLibrary: { + bytes: readFileSync(nativeLibrary).byteLength, + sha256: sha256File(nativeLibrary), + elf: mustRun(readelf, ["-h", "-A", "-d", nativeLibrary]), + }, + quickjs: { + version: toolchain.quickjs.version, + revision: toolchain.quickjs.revision, + }, + rust: toolchain.rust, + signature, + badging, + }; + const receiptPath = join(dirname(appOutput), "pocketjs-blackberry-classic.receipt.json"); + writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); + console.log(`${LABEL}: Hero APK -> ${appOutput}`); + console.log(`SHA-256: ${receipt.apk.sha256}`); + console.log(`Receipt: ${receiptPath}`); +} + +switch (command) { + case "doctor": + doctor(); + break; + case "setup": + await setup(); + break; + case "build-demo": + buildGuestBundle(guest); + break; + case "build-app": + buildApp(); + break; + case "build": + buildGuestBundle(guest); + buildApp(); + break; + default: + throw new Error( + "usage: bun tools/blackberry-android.ts ", + ); +} diff --git a/tools/blackberry-classic-profile.ts b/tools/blackberry-classic-profile.ts new file mode 100644 index 00000000..5d562b2a --- /dev/null +++ b/tools/blackberry-classic-profile.ts @@ -0,0 +1,82 @@ +import { + POCKET_CAPABILITIES, + definePlatformContractRegistry, + defineTargetRegistry, +} from "../contracts/spec/platforms.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; + +/** + * Private exact-device profiles for the BlackBerry Classic (SQC100). + * + * The Classic has two PocketJS hosts that differ only below the QuickJS + * bridge: `hosts/blackberry-qnx` is a BlackBerry 10 Core Native application, + * `hosts/blackberry-android` runs inside the BlackBerry 10 Android Runtime. + * Both present the same 720×720 panel, take the same keyboard, trackpad, and + * touch input through the portable button and touch contracts, and mount the + * same guest bundle shape, so they share one display and capability contract + * and one host ABI. They are registered as two targets because the target id + * is compiled into the guest and the native host and checked at boot. + */ +export const BLACKBERRY_QNX_DEV_TARGET_ID = "blackberry-qnx-dev"; +export const BLACKBERRY_ANDROID_DEV_TARGET_ID = "blackberry-android-dev"; +export type BlackBerryClassicTargetId = + | typeof BLACKBERRY_QNX_DEV_TARGET_ID + | typeof BLACKBERRY_ANDROID_DEV_TARGET_ID; + +export const BLACKBERRY_CLASSIC_HOST_ABI = 9; +export const BLACKBERRY_CLASSIC_LOGICAL_VIEWPORT = [360, 360] as const; +export const BLACKBERRY_CLASSIC_PHYSICAL_VIEWPORT = [720, 720] as const; +export const BLACKBERRY_CLASSIC_RASTER_DENSITY = 2; + +const CLASSIC_DISPLAY = { + physicalViewport: BLACKBERRY_CLASSIC_PHYSICAL_VIEWPORT, + logicalViewports: [BLACKBERRY_CLASSIC_LOGICAL_VIEWPORT], + presentations: ["native"], + rasterDensity: BLACKBERRY_CLASSIC_RASTER_DENSITY, +} as const; + +const CLASSIC_CAPABILITIES = [ + "input.buttons", + "input.touch", + "text.glyphs.baked", +] as const; + +export const BLACKBERRY_CLASSIC_DEV_CONTRACTS = definePlatformContractRegistry( + POCKET_CAPABILITIES, + defineTargetRegistry({ + [BLACKBERRY_QNX_DEV_TARGET_ID]: { + hostAbi: BLACKBERRY_CLASSIC_HOST_ABI, + platform: "blackberry10-qnx", + form: "takeover", + display: CLASSIC_DISPLAY, + capabilities: CLASSIC_CAPABILITIES, + }, + [BLACKBERRY_ANDROID_DEV_TARGET_ID]: { + hostAbi: BLACKBERRY_CLASSIC_HOST_ABI, + platform: "blackberry10-android", + form: "takeover", + display: CLASSIC_DISPLAY, + capabilities: CLASSIC_CAPABILITIES, + }, + }), +); + +export function resolveBlackBerryClassicBuildPlan( + input: unknown, + target: BlackBerryClassicTargetId, +): ResolvedBuildPlan { + const resolution = validateAndResolveBuildPlan( + input, + { target }, + BLACKBERRY_CLASSIC_DEV_CONTRACTS, + ); + if (!resolution.ok) { + throw new Error( + `pocket blackberry-classic: manifest did not resolve for ${target}: ${resolution.diagnostics + .map((diagnostic) => `${diagnostic.path || "/"}: ${diagnostic.message}`) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/blackberry-qnx.ts b/tools/blackberry-qnx.ts new file mode 100644 index 00000000..6e5a5b0b --- /dev/null +++ b/tools/blackberry-qnx.ts @@ -0,0 +1,591 @@ +import { createHash, randomBytes } from "node:crypto"; +import { + copyFileSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { HostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { + BLACKBERRY_QNX_DEV_TARGET_ID, + resolveBlackBerryClassicBuildPlan, +} from "./blackberry-classic-profile.ts"; +import { + buildGuestBundle, + ensureQuickJsCheckout, + type GuestBundleRequest, + mustRunCommand, + packageIdentity, + printCheck, + quickJsCheckoutStatus, + readGuestBundle, + renderTemplate, + runCommand, + sha256File, + xmlEscape, +} from "./native-host-build.ts"; + +const LABEL = "PocketJS BlackBerry QNX"; +const repository = fileURLToPath(new URL("..", import.meta.url)); +const command = Bun.argv[2] ?? "doctor"; +const toolchain = JSON.parse( + readFileSync( + join(repository, "tools/cli/blackberry-qnx-toolchain.json"), + "utf8", + ), +) as { + readonly toolchainVersion: string; + readonly cachePath: string; + readonly image: { + readonly name: string; + readonly digest: string; + readonly platform: string; + }; + readonly qnx: { + readonly apiLevel: string; + readonly hostVersion: string; + readonly compiler: string; + readonly architecture: string; + readonly dynamicLoader: string; + }; + readonly quickjs: { + readonly version: string; + readonly repository: string; + readonly revision: string; + }; + readonly rust: { + readonly toolchain: string; + readonly target: string; + }; + readonly app: { + readonly manifest: string; + readonly binary: string; + readonly bar: string; + }; +}; + +const image = `${toolchain.image.name}@${toolchain.image.digest}`; +const cache = join(homedir(), ".cache/pocket-stack", toolchain.cachePath); +const quickJsRoot = join(cache, "sources/quickjs-rs"); +const nativeBuild = join(repository, ".pocket-build/blackberry-qnx/runtime"); +const rustTarget = join(cache, "build/rust-target"); +const outputBar = join(repository, toolchain.app.bar); +const outputReceipt = join(dirname(outputBar), "build-receipt.json"); +const rustTargetSpec = join(repository, toolchain.rust.target); +const rustTargetName = basename(toolchain.rust.target, ".json"); +const guest: GuestBundleRequest = { + label: LABEL, + repository, + target: BLACKBERRY_QNX_DEV_TARGET_ID, + resolvePlan: (manifest) => + resolveBlackBerryClassicBuildPlan(manifest, BLACKBERRY_QNX_DEV_TARGET_ID), + manifestPath: join(repository, toolchain.app.manifest), + planPath: join(repository, ".pocket/blackberry-qnx/blackberry-classic.plan.json"), + outputDirectory: join(repository, "dist/blackberry-qnx/guest"), +}; + +function run(program: string, args: readonly string[]) { + return runCommand(program, args, repository); +} + +function mustRun( + program: string, + args: readonly string[], + cwd = repository, + env: NodeJS.ProcessEnv = process.env, +): string { + return mustRunCommand(LABEL, program, args, cwd, env); +} + +function imagePresent(): boolean { + return run("docker", ["image", "inspect", image]).exitCode === 0; +} + +function doctor(): void { + const docker = run("docker", ["version", "--format", "{{.Client.Version}}"]).exitCode === 0; + const rust = run("rustup", ["run", toolchain.rust.toolchain, "rustc", "--version"]); + const quickjs = quickJsCheckoutStatus(quickJsRoot, toolchain.quickjs); + const checks = [ + printCheck("Docker", docker, "docker client and daemon"), + printCheck("pinned BBNDK image", imagePresent(), image), + printCheck( + "Rust nightly", + rust.exitCode === 0, + rust.stdout.trim() || toolchain.rust.toolchain, + ), + printCheck("pinned QuickJS", quickjs.ok, quickjs.detail), + printCheck("QNX Rust target", existsSync(rustTargetSpec), rustTargetSpec), + ]; + if (imagePresent()) { + const qnx = run("docker", [ + "run", + "--rm", + "--network", + "none", + "--platform", + toolchain.image.platform, + "--entrypoint", + "/bin/bash", + image, + "-lc", + `qcc -V 2>&1 | grep -q '${toolchain.qnx.compiler}' && ` + + `blackberry-nativepackager -version 2>&1 | grep -q 'version 1.11'`, + ]); + checks.push( + printCheck( + "QNX compiler and BAR packager", + qnx.exitCode === 0, + `${toolchain.qnx.compiler}; blackberry-nativepackager 1.11`, + ), + ); + } + if (checks.some((ok) => !ok)) process.exitCode = 1; + else console.log(`[ok] toolchain: ${toolchain.toolchainVersion}`); +} + +function ensureImage(): void { + if (imagePresent()) return; + mustRun("docker", ["pull", "--platform", toolchain.image.platform, image]); +} + +function setup(): void { + ensureImage(); + ensureQuickJsCheckout(LABEL, quickJsRoot, toolchain.quickjs); + doctor(); +} + +function buildRustCore(): string { + mkdirSync(rustTarget, { recursive: true }); + mustRun( + "rustup", + [ + "run", + toolchain.rust.toolchain, + "cargo", + "build", + "--release", + "--locked", + "--features", + "bare-platform", + "--target", + rustTargetSpec, + "-Z", + "json-target-spec", + "-Z", + "build-std=core,alloc,compiler_builtins", + "-Z", + "build-std-features=compiler-builtins-mem", + ], + join(repository, "engine/symbian"), + { + ...process.env, + CARGO_PROFILE_RELEASE_LTO: "false", + CARGO_TARGET_DIR: rustTarget, + }, + ); + const library = join( + rustTarget, + `${rustTargetName}/release/libpocketjs_symbian_core.a`, + ); + if (!existsSync(library)) { + throw new Error(`${LABEL}: Rust core archive is absent: ${library}`); + } + return library; +} + +function dockerBuild(buildId: string, inputs: HostBuildInputs): void { + const uid = process.getuid?.() ?? 1000; + const gid = process.getgid?.() ?? 1000; + mustRun("docker", [ + "run", + "--rm", + "--network", + "none", + "--platform", + toolchain.image.platform, + "--user", + `${uid}:${gid}`, + "-e", + "HOME=/tmp", + "-e", + `POCKET_BUILD_ID=${buildId}`, + "-e", + `POCKETJS_TARGET_ID=${inputs.target}`, + "-e", + `POCKETJS_HOST_ABI=${inputs.hostAbi}`, + "-e", + `POCKET_RASTER_DENSITY=${inputs.viewport.rasterDensity}`, + "-e", + `POCKET_LOGICAL_WIDTH=${inputs.viewport.logical[0]}`, + "-e", + `POCKET_LOGICAL_HEIGHT=${inputs.viewport.logical[1]}`, + "-e", + `QNX_COMPILER=${toolchain.qnx.compiler}`, + "-e", + `QUICKJS_VERSION=${toolchain.quickjs.version}`, + "-v", + `${repository}:/repo:ro`, + "-v", + `${nativeBuild}:/build`, + "--entrypoint", + "/bin/bash", + image, + "/repo/tools/blackberry-qnx/build.sh", + ]); +} + +function readBarEntry(bar: string, entry: string): Buffer { + const result = Bun.spawnSync({ + cmd: ["unzip", "-p", bar, entry], + cwd: repository, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + throw new Error( + `${LABEL}: cannot read ${entry} from BAR: ${result.stderr.toString().trim()}`, + ); + } + return Buffer.from(result.stdout); +} + +function buildRuntime(): void { + if (!imagePresent()) { + throw new Error( + `${LABEL}: pinned BBNDK image is absent; run \`bun blackberry-qnx setup\``, + ); + } + ensureQuickJsCheckout(LABEL, quickJsRoot, toolchain.quickjs); + const bundle = readGuestBundle(guest); + const coreLibrary = buildRustCore(); + const buildId = randomBytes(16).toString("hex"); + rmSync(nativeBuild, { recursive: true, force: true }); + mkdirSync(join(nativeBuild, "staging"), { recursive: true }); + mkdirSync(dirname(outputBar), { recursive: true }); + + cpSync( + join(quickJsRoot, "libquickjs-sys"), + join(nativeBuild, "quickjs-rs/libquickjs-sys"), + { recursive: true }, + ); + mustRun("patch", [ + "-d", + join(nativeBuild, "quickjs-rs"), + "-p1", + "-i", + join(repository, "tools/blackberry-qnx/quickjs-qnx.patch"), + ]); + copyFileSync(coreLibrary, join(nativeBuild, "libpocketjs_symbian_core.a")); + copyFileSync(bundle.javaScript, join(nativeBuild, "staging/app.js")); + copyFileSync(bundle.pack, join(nativeBuild, "staging/app.pak")); + copyFileSync( + join(repository, "assets/images/logo.png"), + join(nativeBuild, "staging/icon.png"), + ); + const identity = packageIdentity(bundle.inputs.app); + writeFileSync( + join(nativeBuild, "staging/bar-descriptor.xml"), + renderTemplate( + readFileSync(join(repository, "hosts/blackberry-qnx/bar-descriptor.xml"), "utf8"), + { + ID: identity.packageId, + TITLE: xmlEscape(identity.title), + VERSION: identity.version, + BUILD_ID: identity.versionCode, + }, + ), + ); + + dockerBuild(buildId, bundle.inputs); + const builtBar = join(nativeBuild, "pocketjs-blackberry-classic-hero.bar"); + const executable = join(nativeBuild, `staging/${toolchain.app.binary}`); + const elf = readFileSync( + join(nativeBuild, "pocketjs-classic.readelf.txt"), + "utf8", + ); + const symbols = readFileSync( + join(nativeBuild, "pocketjs-classic.symbols.txt"), + "utf8", + ); + for (const marker of [ + "Machine: ARM", + toolchain.qnx.dynamicLoader, + "libbps.so.3", + "libscreen.so.1", + "libEGL.so.1", + "libGLESv2.so.1", + ]) { + if (!elf.includes(marker)) { + throw new Error(`${LABEL}: linked ELF is missing ${marker}`); + } + } + for (const symbol of [ + " main", + " pocket_runtime_boot", + " pocket_runtime_tick", + " ui_gl_render", + ]) { + if (!symbols.includes(symbol)) { + throw new Error(`${LABEL}: linked ELF is missing${symbol}`); + } + } + const manifest = readBarEntry(builtBar, "META-INF/MANIFEST.MF").toString("utf8"); + for (const marker of [ + "Package-Architecture: armle-v7", + "Application-Development-Mode: true", + "Entry-Point-Type: Qnx/Elf", + "Entry-Point-System-Actions: run_native", + `Package-Name: ${identity.packageId}`, + `Package-Version: ${identity.version}.${identity.versionCode}`, + "Archive-Asset-Name: native/app.js", + "Archive-Asset-Name: native/app.pak", + ]) { + if (!manifest.includes(marker)) { + throw new Error(`${LABEL}: BAR manifest is missing ${marker}`); + } + } + const embeddedExecutable = readBarEntry( + builtBar, + `native/${toolchain.app.binary}`, + ); + if ( + createHash("sha256").update(embeddedExecutable).digest("hex") !== + sha256File(executable) + ) { + throw new Error(`${LABEL}: BAR embedded a different native executable`); + } + + copyFileSync(builtBar, outputBar); + const receipt = { + schemaVersion: 1, + toolchainVersion: toolchain.toolchainVersion, + buildId, + hostContract: bundle.inputs, + package: identity, + compilerImage: image, + qnx: toolchain.qnx, + rustToolchain: toolchain.rust.toolchain, + rustTarget: toolchain.rust.target, + quickJsRevision: toolchain.quickjs.revision, + quickJsVersion: toolchain.quickjs.version, + quickJsPatchSha256: sha256File( + join(repository, "tools/blackberry-qnx/quickjs-qnx.patch"), + ), + guestJavaScriptSha256: sha256File(bundle.javaScript), + guestPackSha256: sha256File(bundle.pack), + coreLibrarySha256: sha256File(coreLibrary), + executableSha256: sha256File(executable), + executableBytes: lstatSync(executable).size, + barSha256: sha256File(outputBar), + barBytes: lstatSync(outputBar).size, + elf, + }; + writeFileSync(outputReceipt, `${JSON.stringify(receipt, null, 2)}\n`); + console.log(`${LABEL}: Hero BAR -> ${outputBar}`); + console.log(`SHA-256: ${receipt.barSha256}`); + console.log(`Receipt: ${outputReceipt}`); +} + +function build(): void { + buildGuestBundle(guest); + buildRuntime(); +} + +function deviceAddress(): string { + const address = process.env.POCKETJS_BLACKBERRY_DEVICE?.trim(); + if (!address) { + throw new Error( + `${LABEL}: set POCKETJS_BLACKBERRY_DEVICE to the Classic development IP`, + ); + } + return address; +} + +function deviceCredentials(): string[] { + const args = ["-device", deviceAddress()]; + const password = process.env.POCKETJS_BLACKBERRY_PASSWORD; + if (password !== undefined && password !== "") { + args.push("-password", password); + } + return args; +} + +/** + * The Classic's USB network function (vendor 0fca, cdc_ncm) on Linux. Other + * hosts return undefined and skip the route check below. + */ +function blackberryUsbInterface(): string | undefined { + const networkDevices = "/sys/class/net"; + if (!existsSync(networkDevices)) return undefined; + let fallback: string | undefined; + for (const name of readdirSync(networkDevices).sort()) { + const info = run("udevadm", [ + "info", + "--query=property", + join(networkDevices, name), + ]); + if ( + info.exitCode !== 0 || + !info.stdout.includes("ID_VENDOR_ID=0fca") || + !info.stdout.includes("ID_NET_DRIVER=cdc_ncm") + ) { + continue; + } + fallback ??= name; + const carrier = join(networkDevices, name, "carrier"); + if (existsSync(carrier) && readFileSync(carrier, "utf8").trim() === "1") { + return name; + } + } + return fallback; +} + +function requireDeviceRoute(): void { + const address = deviceAddress(); + if (!address.startsWith("169.254.")) return; + const usb = blackberryUsbInterface(); + if (!usb) return; + const route = run("ip", ["route", "get", address]); + if ( + route.exitCode === 0 && + route.stdout.includes(`dev ${usb}`) && + route.stdout.includes("src 169.254.") + ) { + return; + } + throw new Error( + `${LABEL}: ${usb} is the connected BlackBerry USB interface but has no link-local IPv4 route. ` + + `Run \`sudo ip address replace 169.254.0.2/16 dev ${usb}\`, then retry.`, + ); +} + +function runBlackBerryDeploy(args: readonly string[], mountArtifacts = false): string { + requireDeviceRoute(); + const uid = process.getuid?.() ?? 1000; + const gid = process.getgid?.() ?? 1000; + const dockerArgs = [ + "run", + "--rm", + "--network", + "host", + "--platform", + toolchain.image.platform, + "--user", + `${uid}:${gid}`, + "-e", + "HOME=/tmp", + ]; + if (mountArtifacts) { + dockerArgs.push("-v", `${dirname(outputBar)}:/artifacts:ro`); + } + dockerArgs.push( + "--entrypoint", + "/home/admin/bin/bbndk/host_10_3_1_12/linux/x86/usr/bin/blackberry-deploy", + image, + ...args, + ); + const result = run("docker", dockerArgs); + if (result.exitCode !== 0) { + const detail = [result.stdout.trim(), result.stderr.trim()] + .filter(Boolean) + .join("\n"); + throw new Error( + `${LABEL}: blackberry-deploy failed (${result.exitCode})${ + detail ? `:\n${detail}` : "" + }`, + ); + } + return result.stdout.trim(); +} + +function requireBar(): void { + if (!existsSync(outputBar)) { + throw new Error(`${LABEL}: Hero BAR is absent; run \`bun blackberry-qnx build\``); + } +} + +function manifestField(name: string): string { + requireBar(); + const manifest = readBarEntry(outputBar, "META-INF/MANIFEST.MF").toString("utf8"); + const match = manifest.match(new RegExp(`^${name}: (.+)$`, "m")); + if (!match) { + throw new Error(`${LABEL}: BAR manifest has no ${name}`); + } + return match[1].trim(); +} + +function deviceInfo(): void { + console.log( + runBlackBerryDeploy(["-listDeviceInfo", ...deviceCredentials()]), + ); +} + +function install(): void { + requireBar(); + console.log( + runBlackBerryDeploy( + [ + "-installApp", + "-launchApp", + ...deviceCredentials(), + "-package", + `/artifacts/${basename(outputBar)}`, + ], + true, + ), + ); +} + +function deviceStatus(): void { + console.log( + runBlackBerryDeploy([ + "-getFile", + "data/pocketjs-qnx.status", + "-", + ...deviceCredentials(), + "-package-name", + manifestField("Package-Name"), + "-package-id", + manifestField("Package-Id"), + ]), + ); +} + +switch (command) { + case "doctor": + doctor(); + break; + case "setup": + setup(); + break; + case "build-demo": + buildGuestBundle(guest); + break; + case "build-runtime": + buildRuntime(); + break; + case "build": + build(); + break; + case "device-info": + deviceInfo(); + break; + case "install": + install(); + break; + case "device-status": + deviceStatus(); + break; + default: + throw new Error( + "usage: bun tools/blackberry-qnx.ts ", + ); +} diff --git a/tools/blackberry-qnx/build.sh b/tools/blackberry-qnx/build.sh new file mode 100755 index 00000000..857a2c11 --- /dev/null +++ b/tools/blackberry-qnx/build.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${POCKET_BUILD_ID:?missing POCKET_BUILD_ID}" +: "${POCKETJS_TARGET_ID:?missing POCKETJS_TARGET_ID}" +: "${POCKETJS_HOST_ABI:?missing POCKETJS_HOST_ABI}" +: "${POCKET_RASTER_DENSITY:?missing POCKET_RASTER_DENSITY}" +: "${POCKET_LOGICAL_WIDTH:?missing POCKET_LOGICAL_WIDTH}" +: "${POCKET_LOGICAL_HEIGHT:?missing POCKET_LOGICAL_HEIGHT}" +: "${QNX_COMPILER:?missing QNX_COMPILER}" +: "${QUICKJS_VERSION:?missing QUICKJS_VERSION}" + +qcc="$QNX_HOST/usr/bin/qcc" +ar="$QNX_HOST/usr/bin/ntoarmv7-ar" +readelf="$QNX_HOST/usr/bin/ntoarmv7-readelf" +nm="$QNX_HOST/usr/bin/ntoarmv7-nm" +objects=/build/objects +quickjs_objects="$objects/quickjs" +staging=/build/staging +quickjs=/build/quickjs-rs/libquickjs-sys/embed/quickjs +static_functions=/build/quickjs-rs/libquickjs-sys/embed/static-functions.c + +mkdir -p "$quickjs_objects" "$staging" + +qnx_target=(-V"$QNX_COMPILER") +quickjs_flags=( + "${qnx_target[@]}" + -std=gnu11 + -O2 + -fPIC + -funsigned-char + -fno-strict-aliasing + -ffunction-sections + -fdata-sections + -D_GNU_SOURCE + -DCONFIG_VERSION=\""$QUICKJS_VERSION"\" + -I"$quickjs" + -Wno-unused-parameter +) + +quickjs_object_paths=() +for source in cutils.c dtoa.c libregexp.c libunicode.c quickjs.c; do + object="$quickjs_objects/${source%.c}.o" + "$qcc" "${quickjs_flags[@]}" -c "$quickjs/$source" -o "$object" + quickjs_object_paths+=("$object") +done +static_object="$quickjs_objects/static-functions.o" +"$qcc" "${quickjs_flags[@]}" -c "$static_functions" -o "$static_object" +quickjs_object_paths+=("$static_object") +"$ar" rcs /build/libquickjs.a "${quickjs_object_paths[@]}" + +first_party_flags=( + "${qnx_target[@]}" + -std=gnu11 + -Os + -fPIE + -fno-strict-aliasing + -ffunction-sections + -fdata-sections + -Wall + -Wextra + -Werror + -Wno-unused-parameter +) + +"$qcc" "${first_party_flags[@]}" \ + -DPOCKETJS_TARGET_ID=\""$POCKETJS_TARGET_ID"\" \ + -DPOCKETJS_HOST_ABI="$POCKETJS_HOST_ABI" \ + -DPOCKET_RASTER_DENSITY="$POCKET_RASTER_DENSITY" \ + -I/repo/hosts/iphone2g \ + -I"$quickjs" \ + -c /repo/hosts/iphone2g/pocket_runtime.c \ + -o "$objects/pocket_runtime.o" + +for shared in pocket_input rust_eh_personality; do + "$qcc" "${first_party_flags[@]}" \ + -I/repo/hosts/iphone2g \ + -c "/repo/hosts/iphone2g/$shared.c" \ + -o "$objects/$shared.o" +done + +"$qcc" "${first_party_flags[@]}" \ + -DPOCKET_BUILD_ID=\""$POCKET_BUILD_ID"\" \ + -DPOCKET_LOGICAL_WIDTH="$POCKET_LOGICAL_WIDTH" \ + -DPOCKET_LOGICAL_HEIGHT="$POCKET_LOGICAL_HEIGHT" \ + -I/repo/hosts/iphone2g \ + -c /repo/hosts/blackberry-qnx/main.c \ + -o "$objects/main.o" + +"$qcc" "${qnx_target[@]}" \ + -pie \ + -Wl,-z,relro \ + -Wl,-z,now \ + -Wl,--gc-sections \ + -Wl,--no-undefined \ + -o "$staging/pocketjs-classic" \ + "$objects/main.o" \ + "$objects/pocket_runtime.o" \ + "$objects/pocket_input.o" \ + "$objects/rust_eh_personality.o" \ + /build/libquickjs.a \ + /build/libpocketjs_symbian_core.a \ + -lbps \ + -lscreen \ + -lEGL \ + -lGLESv2 \ + -lm + +"$readelf" -h -l -A -d "$staging/pocketjs-classic" > /build/pocketjs-classic.readelf.txt +"$nm" -g "$staging/pocketjs-classic" > /build/pocketjs-classic.symbols.txt + +cd "$staging" +blackberry-nativepackager \ + -package /build/pocketjs-blackberry-classic-hero.bar \ + -devMode \ + -configuration Device-Release \ + bar-descriptor.xml diff --git a/tools/blackberry-qnx/quickjs-qnx.patch b/tools/blackberry-qnx/quickjs-qnx.patch new file mode 100644 index 00000000..187a22dc --- /dev/null +++ b/tools/blackberry-qnx/quickjs-qnx.patch @@ -0,0 +1,21 @@ +diff --git a/libquickjs-sys/embed/quickjs/quickjs.c b/libquickjs-sys/embed/quickjs/quickjs.c +index dc296a7..3796ead 100644 +--- a/libquickjs-sys/embed/quickjs/quickjs.c ++++ b/libquickjs-sys/embed/quickjs/quickjs.c +@@ -97,6 +97,6 @@ + #if !defined(__EMSCRIPTEN__) + #define CONFIG_ATOMICS + #endif +-#if defined(__PSP__) || defined(__vita__) ++#if defined(__PSP__) || defined(__vita__) || defined(__QNXNTO__) + #undef CONFIG_ATOMICS + #endif +@@ -2172,6 +2172,8 @@ static size_t js_def_malloc_usable_size(const void *ptr) + return 0; + #elif defined(__vita__) + return malloc_usable_size((void *)ptr); ++#elif defined(__QNXNTO__) ++ return 0; + #elif defined(__linux__) || defined(__GLIBC__) + return malloc_usable_size((void *)ptr); + #else diff --git a/tools/cli/blackberry-android-toolchain.json b/tools/cli/blackberry-android-toolchain.json new file mode 100644 index 00000000..c9ece863 --- /dev/null +++ b/tools/cli/blackberry-android-toolchain.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 1, + "toolchainVersion": "blackberry-android-api18-v1", + "cachePath": "android", + "android": { + "apiLevel": 18, + "platformVersion": "4.3.1", + "buildToolsVersion": "35.0.0", + "ndkVersion": "23.2.8568313", + "abi": "armeabi-v7a", + "clangTarget": "armv7a-linux-androideabi18", + "repository": "https://dl.google.com/android/repository/", + "packages": [ + { + "id": "platforms;android-18", + "path": "platforms/android-18", + "archives": { + "any": { + "asset": "android-18_r03.zip", + "sha1": "e6b09b3505754cbbeb4a5622008b907262ee91cb" + } + } + }, + { + "id": "build-tools;35.0.0", + "path": "build-tools/35.0.0", + "archives": { + "linux": { + "asset": "build-tools_r35_linux.zip", + "sha1": "2cfaa0bbb2336e9ec18ed3ecea84fa2e2af607bc" + }, + "darwin": { + "asset": "build-tools_r35_macosx.zip", + "sha1": "93ab8ce91230e067b5add4bfa79919c52b27f072" + } + } + }, + { + "id": "ndk;23.2.8568313", + "path": "ndk/23.2.8568313", + "archives": { + "linux": { + "asset": "android-ndk-r23c-linux.zip", + "sha1": "e5053c126a47e84726d9f7173a04686a71f9a67a" + }, + "darwin": { + "asset": "android-ndk-r23c-darwin.zip", + "sha1": "1fc65d8f6083f3f5cd01e0cf97c6adc10f4f076f" + } + } + } + ] + }, + "javaImage": "eclipse-temurin:17-jdk-jammy@sha256:29467857e8bde40ab1f7befecbda0ea764b95afec1cc7f89aa90f7a766577e19", + "quickjs": { + "version": "2026-06-04", + "repository": "https://github.com/pocket-stack/quickjs-rs.git", + "revision": "ba5bdd0dc013518768e76cd9e05cd30ed53dd35b" + }, + "rust": { + "toolchain": "nightly-2026-07-02", + "target": "armv7-linux-androideabi" + }, + "app": { + "manifest": "apps/blackberry-classic-demo/pocket.json", + "output": "dist/blackberry-android/pocketjs-blackberry-classic.apk" + } +} diff --git a/tools/cli/blackberry-qnx-toolchain.json b/tools/cli/blackberry-qnx-toolchain.json new file mode 100644 index 00000000..2e311c2a --- /dev/null +++ b/tools/cli/blackberry-qnx-toolchain.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "toolchainVersion": "blackberry-qnx-10.3.1.995-v1", + "cachePath": "blackberry-qnx", + "image": { + "name": "accupara/bbndk", + "digest": "sha256:91268df2ead23a6fa9c17b600ffc6e04bf6b38eaa97f6a48c2f59ffc221ae6d2", + "platform": "linux/amd64" + }, + "qnx": { + "apiLevel": "10.3.1.995", + "hostVersion": "10.3.1.12", + "compiler": "4.8.3,gcc_ntoarmv7le", + "architecture": "armle-v7", + "dynamicLoader": "/usr/lib/ldqnx.so.2" + }, + "quickjs": { + "version": "2026-06-04", + "repository": "https://github.com/pocket-stack/quickjs-rs.git", + "revision": "ba5bdd0dc013518768e76cd9e05cd30ed53dd35b" + }, + "rust": { + "toolchain": "nightly-2026-07-02", + "target": "hosts/blackberry-qnx/armv7-qnx-eabi.json" + }, + "app": { + "manifest": "apps/blackberry-classic-demo/pocket.json", + "binary": "pocketjs-classic", + "bar": "dist/blackberry-qnx/pocketjs-blackberry-classic-hero.bar" + } +} diff --git a/tools/native-host-build.ts b/tools/native-host-build.ts new file mode 100644 index 00000000..76e52ebf --- /dev/null +++ b/tools/native-host-build.ts @@ -0,0 +1,299 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + extractHostBuildInputs, + type HostBuildInputs, +} from "../framework/src/manifest/host-build-inputs.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; + +/** + * Build steps shared by native host tools that embed a PocketJS guest next to + * the QuickJS bridge: process helpers, the pinned QuickJS checkout, the guest + * bundle built from a resolved plan, and the platform package identity derived + * from that plan. Used by the BlackBerry Classic tools today. + */ + +export interface CommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +export function runCommand( + program: string, + args: readonly string[], + cwd: string, + env: NodeJS.ProcessEnv = process.env, +): CommandResult { + const result = Bun.spawnSync({ + cmd: [program, ...args], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +export function mustRunCommand( + label: string, + program: string, + args: readonly string[], + cwd: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const result = runCommand(program, args, cwd, env); + if (result.exitCode !== 0) { + const detail = [result.stdout.trim(), result.stderr.trim()] + .filter(Boolean) + .join("\n"); + throw new Error( + `${label}: ${program} ${args.join(" ")} failed (${result.exitCode})${ + detail ? `:\n${detail}` : "" + }`, + ); + } + return result.stdout.trim(); +} + +export function sha256File(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +export function printCheck(label: string, ok: boolean, detail: string): boolean { + console.log(`${ok ? "[ok]" : "[missing]"} ${label}: ${detail}`); + return ok; +} + +export interface QuickJsPin { + readonly version: string; + readonly repository: string; + readonly revision: string; +} + +export interface QuickJsCheckout { + readonly root: string; + /** `libquickjs-sys/embed/quickjs` — the C sources both hosts compile. */ + readonly source: string; + readonly staticFunctions: string; +} + +export function quickJsCheckout(root: string): QuickJsCheckout { + return { + root, + source: join(root, "libquickjs-sys/embed/quickjs"), + staticFunctions: join(root, "libquickjs-sys/embed/static-functions.c"), + }; +} + +/** The checkout is usable only at the pinned revision with a clean tree. */ +export function quickJsCheckoutStatus( + root: string, + pin: QuickJsPin, +): { ok: boolean; detail: string } { + if (!existsSync(join(root, ".git"))) { + return { ok: false, detail: root }; + } + const revision = runCommand("git", ["-C", root, "rev-parse", "HEAD"], root); + const changes = runCommand( + "git", + ["-C", root, "status", "--porcelain=v1", "--untracked-files=all"], + root, + ); + const versionPath = join(quickJsCheckout(root).source, "VERSION"); + const version = existsSync(versionPath) + ? readFileSync(versionPath, "utf8").trim() + : ""; + const ok = + revision.exitCode === 0 && + revision.stdout.trim() === pin.revision && + changes.exitCode === 0 && + changes.stdout.trim() === "" && + version === pin.version; + return { + ok, + detail: `${root} (${revision.stdout.trim() || "missing"}, ${version || "no VERSION"})`, + }; +} + +export function ensureQuickJsCheckout( + label: string, + root: string, + pin: QuickJsPin, +): void { + const status = quickJsCheckoutStatus(root, pin); + if (status.ok) return; + if (existsSync(root)) { + throw new Error( + `${label}: refusing to replace an unverified QuickJS directory: ${status.detail}`, + ); + } + mkdirSync(dirname(root), { recursive: true }); + mustRunCommand( + label, + "git", + ["clone", "--filter=blob:none", "--no-checkout", pin.repository, root], + dirname(root), + ); + mustRunCommand( + label, + "git", + ["-C", root, "checkout", "--detach", pin.revision], + root, + ); + const verified = quickJsCheckoutStatus(root, pin); + if (!verified.ok) { + throw new Error(`${label}: QuickJS verification failed: ${verified.detail}`); + } +} + +export interface GuestBundle { + readonly plan: ResolvedBuildPlan; + readonly inputs: HostBuildInputs; + readonly javaScript: string; + readonly pack: string; +} + +export interface GuestBundleRequest { + readonly label: string; + readonly repository: string; + /** The private target id the manifest must resolve against. */ + readonly target: string; + /** Resolves the manifest to the plan for `target` (a profile module's resolver). */ + readonly resolvePlan: (manifest: unknown) => ResolvedBuildPlan; + readonly manifestPath: string; + /** Where the resolved plan is written for `tools/build.ts --plan`. */ + readonly planPath: string; + readonly outputDirectory: string; +} + +function locateGuestBundle( + request: GuestBundleRequest, + plan: ResolvedBuildPlan, +): GuestBundle { + const inputs = extractHostBuildInputs(plan, { expectedTarget: request.target }); + return { + plan, + inputs, + javaScript: join(request.outputDirectory, `${inputs.appOutput}.js`), + pack: join(request.outputDirectory, `${inputs.appOutput}.pak`), + }; +} + +export function currentGuestPlan(request: GuestBundleRequest): ResolvedBuildPlan { + return request.resolvePlan(JSON.parse(readFileSync(request.manifestPath, "utf8"))); +} + +/** Resolves the manifest for the target and compiles app.js + app.pak. */ +export function buildGuestBundle(request: GuestBundleRequest): GuestBundle { + const plan = currentGuestPlan(request); + mkdirSync(dirname(request.planPath), { recursive: true }); + rmSync(request.outputDirectory, { recursive: true, force: true }); + mkdirSync(request.outputDirectory, { recursive: true }); + writeFileSync(request.planPath, `${JSON.stringify(plan, null, 2)}\n`); + mustRunCommand( + request.label, + process.execPath, + [ + join(request.repository, "tools/build.ts"), + `--plan=${request.planPath}`, + `--project-root=${request.repository}`, + `--outdir=${request.outputDirectory}`, + ], + request.repository, + ); + const bundle = locateGuestBundle(request, plan); + if (!existsSync(bundle.javaScript) || !existsSync(bundle.pack)) { + throw new Error(`${request.label}: guest build did not emit app.js and app.pak`); + } + console.log(`${request.label}: guest bundle -> ${request.outputDirectory}`); + return bundle; +} + +/** Reads a previously built bundle and rejects it when the manifest moved on. */ +export function readGuestBundle(request: GuestBundleRequest): GuestBundle { + if (!existsSync(request.planPath)) { + throw new Error(`${request.label}: resolved plan is absent; run build-demo first`); + } + const stored = JSON.parse(readFileSync(request.planPath, "utf8")) as ResolvedBuildPlan; + if (stored.planHash !== currentGuestPlan(request).planHash) { + throw new Error(`${request.label}: resolved plan is stale; rerun build-demo`); + } + const bundle = locateGuestBundle(request, stored); + if (!existsSync(bundle.javaScript) || !existsSync(bundle.pack)) { + throw new Error( + `${request.label}: guest JavaScript or pack is absent; rerun build-demo`, + ); + } + return bundle; +} + +/** + * Platform package identity derived from the plan's manifest identity, so a + * host never carries a second hand-written copy of the app id or version. + */ +export interface PackageIdentity { + /** The manifest id with `-` replaced by `_` — a valid Android package name + * and BAR id; every other character must already be a legal segment. */ + readonly packageId: string; + /** The manifest version verbatim (Android versionName, BAR versionNumber). */ + readonly version: string; + /** `major * 1_000_000 + minor * 1_000 + patch` — a monotonic integer for + * Android versionCode and the BAR buildId, derived from the same version. */ + readonly versionCode: number; + readonly title: string; +} + +export function packageIdentity(app: HostBuildInputs["app"]): PackageIdentity { + const packageId = app.id.replace(/-/g, "_"); + if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/.test(packageId)) { + throw new Error( + `native host build: app id ${app.id} does not map onto a platform package name`, + ); + } + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(app.version); + if (!match) { + throw new Error(`native host build: app version ${app.version} is not major.minor.patch`); + } + const [major, minor, patch] = match.slice(1, 4).map(Number); + if (minor >= 1000 || patch >= 1000 || major >= 2000) { + throw new Error( + `native host build: app version ${app.version} exceeds the numeric version code range`, + ); + } + return { + packageId, + version: app.version, + versionCode: major * 1_000_000 + minor * 1_000 + patch, + title: app.title, + }; +} + +/** + * Fills `@POCKET_NAME@` placeholders in a platform descriptor template and + * rejects a template whose placeholders are not all known. + */ +export function renderTemplate( + template: string, + values: Readonly>, +): string { + const rendered = template.replace(/@POCKET_([A-Z_]+)@/g, (token, name: string) => { + if (!(name in values)) throw new Error(`native host build: template has no value for ${token}`); + return String(values[name]); + }); + return rendered; +} + +/** XML attribute/text escaping for values rendered into descriptors. */ +export function xmlEscape(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/tools/test.ts b/tools/test.ts index bbdb6ee5..3e1381cb 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -50,6 +50,8 @@ const SUITE: readonly Stage[] = [ "tests/iphone4s-profile.test.ts", "tests/ipodtouch-profile.test.ts", "tests/meizu-m8-profile.test.ts", + "tests/blackberry-classic.test.ts", + "tests/pocket-input.test.ts", "tests/ios-profile.test.ts", "tests/iphone2g-device-contract.test.ts", "tests/iphone2g-toolchain.test.ts",