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..4d641cfc --- /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`), 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 host takes the **sign** of the displacement, pulses the d-pad once in that direction (`pulse_trackpad`); a click (`buttons != 0`) becomes `CIRCLE`. +- **The Android host**: the runtime delivers the trackpad as generic-motion scroll axes or trackball deltas; the host **accumulates** them and, each time the running sum crosses a 0.35 threshold, emits a d-pad pulse and backs the accumulator off by one threshold (`pulse_relative_axis`). 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 mappings are only a few lines. On the QNX side the displacement is an integer and every event is one "notch," so we only look at the sign: + +```c +/* Sign of SCREEN_PROPERTY_DISPLACEMENT → a one-frame d-pad pulse */ +static void pulse_trackpad(int dx, int dy) { + if (dx < 0) pressed_buttons |= BTN_LEFT; + if (dx > 0) pressed_buttons |= BTN_RIGHT; + if (dy < 0) pressed_buttons |= BTN_UP; + if (dy > 0) pressed_buttons |= BTN_DOWN; +} +``` + +On the Android side the deltas are continuous floats, so we accumulate to a threshold before counting a notch: + +```c +/* Continuous scroll/trackball deltas → a pulse only once the threshold is crossed */ +static void pulse_relative_axis(float dx, float dy) { + const float threshold = 0.35f; + relative_x += dx; relative_y += dy; + if (relative_x <= -threshold) { pressed_buttons |= BTN_LEFT; relative_x += threshold; } + else if (relative_x >= threshold) { pressed_buttons |= BTN_RIGHT; relative_x -= threshold; } + if (relative_y <= -threshold) { pressed_buttons |= BTN_UP; relative_y += threshold; } + else if (relative_y >= threshold) { pressed_buttons |= BTN_DOWN; relative_y -= threshold; } +} +``` + +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 (QNX takes the sign of `SCREEN_PROPERTY_DISPLACEMENT`; Android accumulates scroll-axis/trackball deltas past a 0.35 threshold — provisional, see above) | +| trackpad click, Enter/Return, d-pad center | the press button (`CIRCLE`) | +| arrow keys | d-pad | +| Space | `START` | +| Menu | `TRIANGLE` | +| Send (QNX navigator system key) | the press button; End and Back stay with the system | +| touchscreen | one tracked contact, divided into 360×360 logical coordinates, with the host-resolved bounds hit fact | + +For these two hosts, `pocket_runtime.c` grew two new entry points: `pocket_runtime_frame_input_ticks` carries the button mask into the frame call (the earlier `pocket_runtime_frame_ticks` passed zero), and `pocket_runtime_gl_reset` drops the old GL resources after the platform recreates the GL context so the backend can re-initialize — the latter is a must on Android, because `GLSurfaceView` recreates the context on pause/resume. + +One frame's worth of input is fed in like this — collapse the physical input into "a button mask + a touch snapshot," run one tick, then let the GPU draw: + +```c +static int render_frame(void) { + uint32_t buttons = held_buttons | pressed_buttons; // held + one-frame edges + int touch_down = touch.down || touch.latched; // latch: a click in the gap still counts + int lx = touch.x * POCKET_LOGICAL_WIDTH / surface_width; // 720 → 360 logical + int ly = touch.y * POCKET_LOGICAL_HEIGHT / surface_height; + int hit = touch_down ? pocket_runtime_hit_test_bounds(lx, ly) : 0; + + pressed_buttons = 0; // the edge was consumed, clear it + pocket_runtime_frame_input_ticks(buttons, touch_down, lx, ly, hit, /*ticks=*/1); + 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; +} +``` + +That `pressed_buttons` (an edge that lasts exactly one frame) and `touch.latched` 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. + +## 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",