diff --git a/apps/hero/app.tsx b/apps/hero/app.tsx index 559efc93..291e5427 100644 --- a/apps/hero/app.tsx +++ b/apps/hero/app.tsx @@ -39,6 +39,7 @@ function Stat(props: { label: string; value: string; cls: string; largeLayout?: export interface HeroProps { actionLabel?: string; + compactHeadline?: boolean; deviceLabel?: string; headline?: string; largeLayout?: boolean; @@ -130,7 +131,7 @@ export default function Hero(props: HeroProps = {}) { ONE RUST CORE · ONE JSX APP - {props.headline ?? `JSX at ${TICKS_PER_SECOND} FPS.`} diff --git a/apps/nokia-n9-demo/app.tsx b/apps/nokia-n9-demo/app.tsx new file mode 100644 index 00000000..9a7fe4b6 --- /dev/null +++ b/apps/nokia-n9-demo/app.tsx @@ -0,0 +1,17 @@ +import { reportAppAction } from "@pocketjs/framework/host"; +import Hero from "../hero/app.tsx"; + +export default function NokiaN9Hero() { + return ( + reportAppAction("hero_tap", count)} + presentationHz={60} + runtimeLabel="RUST + QUICKJS + GLES2" + /> + ); +} diff --git a/apps/nokia-n9-demo/main.tsx b/apps/nokia-n9-demo/main.tsx new file mode 100644 index 00000000..d696e47c --- /dev/null +++ b/apps/nokia-n9-demo/main.tsx @@ -0,0 +1,5 @@ +// @title PocketJS: Nokia N9 Hero +import { mount } from "@pocketjs/framework/solid"; +import NokiaN9Hero from "./app.tsx"; + +mount(() => ); diff --git a/apps/nokia-n9-demo/pocket.json b/apps/nokia-n9-demo/pocket.json new file mode 100644 index 00000000..5da05153 --- /dev/null +++ b/apps/nokia-n9-demo/pocket.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.nokia-n9-demo", + "name": "pocketjs-nokia-n9-hero", + "title": "PocketJS: Nokia N9 Hero", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": [ + "input.touch", + "display.viewport.live", + "text.glyphs.baked" + ] + } + }, + "app": { + "entry": "apps/nokia-n9-demo/main.tsx", + "output": "nokia-n9-demo-main", + "framework": "solid", + "viewport": { + "dynamic": { + "default": [854, 480], + "min": [480, 480], + "max": [854, 854] + } + } + } +} diff --git a/docs/NOKIA_N9.md b/docs/NOKIA_N9.md new file mode 100644 index 00000000..cd30befe --- /dev/null +++ b/docs/NOKIA_N9.md @@ -0,0 +1,196 @@ +# Nokia N9 / MeeGo 1.2 Harmattan development + +PocketJS includes a private `nokia-n9-dev` target for the Nokia N9. **The +target uses the native 854×480 GLES2 surface, accepts touch input, and keeps one +guest alive while MeeGo Touch changes between 854×480 landscape and 480×854 +portrait layouts.** It remains outside `POCKET_TARGETS` until a physical N9 +passes the complete build, install, rotation, capture, touch, resume, and 60 Hz +acceptance sequence. + +The [Harmattan rotation guide](https://katastrophos.net/harmattan-dev/html/guide/html/Developer_Library_Developing_for_Harmattan_Controlling_rotation.html) +states that X11 remains landscape and recommends a MeeGo Touch wrapper for +OpenGL ES applications. The host follows that arrangement. + +The workflow does not flash the phone, install an alternate kernel, enable +Open Mode, or enable root SSH. Device installation uses Harmattan Developer +mode and its SDK Connectivity service. + +## Toolchain + +The build uses the Linux 64-bit Qt SDK 1.2.1 offline installer mirrored by +`n9.dy.fi`. `tools/cli/nokia-n9-toolchain.json` records both the community ZIP +SHA-256 and the enclosed Nokia installer's historical MD5 and SHA-256. **The +SDK is downloaded into the Pocket Stack cache and installed in a dedicated +Docker volume; it is not copied into the repository or npm package.** + +The isolated `linux/amd64` build environment contains: + +- the Harmattan MADDE target, qmake, ARMEL compiler, sysroot, and Debian tools; +- pinned Clang for the current QuickJS C sources; +- a pinned QuickJS source archive with Atomics disabled for the single-threaded + host; +- a read-only mount of the pinned `nightly-2026-07-02` Rust toolchain and Cargo + cache. Cargo runs offline inside the container and writes its target tree + below `dist/nokia-n9`. The core targets Cortex-A8 with Harmattan's + VFP-register argument convention. The Debian package architecture remains + `armel`, matching the SDK even though its system libraries carry the VFP + argument ELF attribute. + +Build commands run with networking disabled, the repository mounted read-only, +and only `dist/nokia-n9` writable. + +```sh +bun nokia-n9 setup --yes +bun nokia-n9 doctor +bun nokia-n9 build probe +bun nokia-n9 build app --manifest apps/nokia-n9-demo/pocket.json +``` + +**Identical source and pinned inputs produce the same content-derived build +id and embedded core, JavaScript, and pak hashes.** The SDK packaging tools +write archive timestamps, so the final `.deb` is not claimed to be +byte-for-byte reproducible. + +The probe is the first device artifact. It obtains an MWindow-managed GLES2 +surface, logs the GL version/vendor/renderer, accepts native touch events, and +follows Harmattan orientation changes without loading QuickJS or the PocketJS +core. It also logs swap interval, display visibility, task-switcher entry, and +touch sequence endings. If the probe demonstrates irreconcilable QGLWidget state +ownership on a firmware, the reserved fallback keeps the same MWindow +lifecycle shell and replaces only the viewport with a direct EGL surface; +QPainter and software presentation are not fallback paths. + +## Device preparation + +If Developer mode cannot install because Nokia's original package servers are +offline, install and enable N9 RepoMirror using its documented device-side +workflow. This repository does not install or configure third-party package +sources. + +On the phone: + +1. Enable **Settings > Security > Developer mode**. +2. Open **SDK Connectivity** and select USB or WLAN. +3. When using USB, select **SDK mode**, not Storage mode. +4. Keep the address and one-time password displayed by SDK Connectivity + visible for the initial pairing command. + +On the development machine: + +```sh +bun nokia-n9 pair --host 192.168.2.15 +bun nokia-n9 doctor --device +``` + +`pair` creates a dedicated 3072-bit RSA key under the shared Pocket Stack +cache because Harmattan's 2011 OpenSSH server predates Ed25519 support. The +selected host is stored for later device commands. +OpenSSH displays the device host-key fingerprint and requires confirmation; +the password is used only by `ssh-copy-id` and is not stored. `doctor --device` +reads the architecture, kernel, and Qt package version. **It does not read or +print the phone's serial number or IMEI.** It accepts the stock `RM696` +hostname, the `dfl61` Harmattan kernel family, ARMv7, `meego-nokia-version`, +and Qt 4 together; an N950 or non-Harmattan replacement OS is rejected. + +## Build, install, and launch + +The application build resolves `apps/nokia-n9-demo/pocket.json`, compiles its +JavaScript and pak, builds the no-std Rust core, compiles QuickJS, links the +shared Qt host, and creates an ARMEL Debian package. The package installs its +binary below `/opt//bin`, an 80×80 launcher icon below +`/usr/share/icons/hicolor`, and a matching Harmattan desktop entry. + +```sh +bun nokia-n9 deploy \ + dist/nokia-n9/pocketjs-nokia-n9-hero_0.1.0-1_armel.deb +bun nokia-n9 launch +bun nokia-n9 status +``` + +`deploy` transfers the package over SFTP to a build-specific directory, reads +back its SHA-256, and invokes `dpkg` through an interactive `devel-su` session. +It does not store the device root password. `launch` runs the installed MeeGo +Touch application through Harmattan's invoker. + +## Runtime behavior + +The MWindow owns orientation and display visibility. The QGLWidget remains the +native GLES2 viewport. On a direction change the host: + +1. stops the guest frame pump and clears all live touch contacts; +2. records the new quarter-turn and logical viewport; +3. resizes the Rust core; +4. calls the framework live-viewport hook without remounting the app; and +5. resumes after the MWindow rotation animation finishes. + +The GLES backend rotates vertices in its shader and rotates every scissor into +the physical surface coordinate space. **Hero state, focus, timers, and native +animations survive the resize.** Covered, minimized, and rotating windows do +not advance guest time, and missed background time is not replayed on resume. + +The runtime presents one 60 Hz core tick per swapped frame. The QGLWidget uses +swap interval 1 and a zero-delay event-loop pump; it does not approximate 60 Hz +with a 16 ms integer timer. + +## Status and capture + +The runtime atomically replaces a build-specific JSON status file once per +second. It records the target and ABI, PID, heartbeat, guest and presented +frames, orientation, logical and physical dimensions, GL fingerprint, context +generation, touch and Hero action receipts, errors, JavaScript/job/core/GL/swap +timings, and a rolling 600-frame timing window after a 120-frame warm-up. + +```sh +bun nokia-n9 status --require-action +bun nokia-n9 capture +``` + +`capture` creates a one-shot request. The runtime waits for the Hero's 24-frame +spinner cycle boundary, then reads the presented frame with `glReadPixels` into +bottom-up RGBA plus a JSON sidecar. The desktop tool verifies the build id, +downloads the bytes, rotates them back into logical orientation, and writes a +numbered raw RGBA, sidecar, and PNG below +`dist/nokia-n9/captures///`. + +## Physical acceptance + +Before running acceptance: + +1. Start the Hero in landscape and tap the blue control once. +2. Wait one second, then run `bun nokia-n9 capture` three times without + changing the screen. +3. Rotate to portrait and tap it again. The visible count must become 2 rather + than restarting at 1. +4. Wait one second, then run `bun nokia-n9 capture` three times without + changing the screen. +5. Rotate back to landscape and leave the app unobscured for at least ten + seconds. + +```sh +bun nokia-n9 accept +``` + +Acceptance requires: + +- a fresh status record for the current build and an advancing heartbeat; +- at least two completed touch sequences and two orientation transitions; +- a preserved `hero_tap` count of at least 2; +- GLES2 at 60 ticks per second; +- 600 active samples averaging 59–61 Hz, p95 at most 17.5 ms, and no frame at + or above 25 ms; and +- three byte-identical aligned captures in each orientation from one firmware + and GL fingerprint. + +Also perform twenty landscape/portrait cycles and one ten-second task-switcher +round trip. The app must preserve its count, stop ticking while covered, +resume without catch-up, and show no black frame, stale strip, stuck contact, +or runtime error. `accept` reports the automated gates; the rotation, +edge-swipe, resume, and visual-review checklist remains a physical observation. + +## Private-target boundary + +The source and host tests, cross-compiled Rust archive, build plan, package, +and offline capture conversion can be validated without a phone. **A successful +offline build does not satisfy the physical 60 Hz, orientation, compositor, +touch, or resume requirements.** Until those checks pass on the connected N9, +the target stays private and no production capability claim is made. diff --git a/engine/apple/examples/render_hero.rs b/engine/apple/examples/render_hero.rs index 64601af3..29eb1156 100644 --- a/engine/apple/examples/render_hero.rs +++ b/engine/apple/examples/render_hero.rs @@ -12,13 +12,10 @@ use std::ffi::CString; use pocket_apple::{ pocket_apple_create, pocket_apple_destroy, pocket_apple_eval_bundle, pocket_apple_frame, - pocket_apple_last_error, pocket_apple_load_pak, pocket_apple_render, + pocket_apple_last_error, pocket_apple_load_pak, pocket_apple_render, pocket_apple_set_identity, pocket_apple_set_tick_rate, PocketAppleFrame, }; -const WIDTH: u32 = 480; -const HEIGHT: u32 = 272; -const DENSITY: u32 = 2; const FRAMES: u32 = 180; fn last_error() -> String { @@ -29,8 +26,16 @@ fn last_error() -> String { } } -fn run_instance(bundle: &[u8], pak: &[u8], tick_hz: Option) -> (Vec, u32, u32, u64) { - let handle = pocket_apple_create(DENSITY, WIDTH, HEIGHT); +fn run_instance( + bundle: &[u8], + pak: &[u8], + tick_hz: Option, + host: Option<(&CString, u32)>, + width: u32, + height: u32, + density: u32, +) -> (Vec, u32, u32, u64) { + let handle = pocket_apple_create(density, width, height); assert!(!handle.is_null(), "create failed: {}", last_error()); assert_eq!( pocket_apple_load_pak(handle, pak.as_ptr(), pak.len()), @@ -38,6 +43,14 @@ fn run_instance(bundle: &[u8], pak: &[u8], tick_hz: Option) -> (Vec, u3 "load_pak failed: {}", last_error() ); + if let Some((id, abi)) = host { + assert_eq!( + pocket_apple_set_identity(handle, id.as_ptr(), abi), + 0, + "set_identity failed: {}", + last_error() + ); + } if let Some(hz) = tick_hz { assert_eq!( pocket_apple_set_tick_rate(handle, hz), @@ -106,25 +119,66 @@ static LOGGER: StderrLogger = StderrLogger; fn main() { let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Debug)); let args: Vec = std::env::args().collect(); - let bundle_path = args.get(1).map(String::as_str).unwrap_or("../dist/hero-main.js"); - let pak_path = args.get(2).map(String::as_str).unwrap_or("../dist/hero-main.pak"); + let bundle_path = args + .get(1) + .map(String::as_str) + .unwrap_or("../dist/hero-main.js"); + let pak_path = args + .get(2) + .map(String::as_str) + .unwrap_or("../dist/hero-main.pak"); let out_base = args.get(3).map(String::as_str).unwrap_or("/tmp/hero"); let bundle = std::fs::read(bundle_path).expect("read bundle"); let pak = std::fs::read(pak_path).expect("read pak"); - let tick_hz = std::env::var("POCKET_TICK_HZ") + let tick_hz = std::env::var("POCKET_TICK_HZ").ok().map(|raw| { + raw.parse::() + .expect("POCKET_TICK_HZ must be an integer") + }); + let width = std::env::var("POCKET_WIDTH") + .ok() + .map(|raw| raw.parse::().expect("POCKET_WIDTH must be an integer")) + .unwrap_or(480); + let height = std::env::var("POCKET_HEIGHT") + .ok() + .map(|raw| { + raw.parse::() + .expect("POCKET_HEIGHT must be an integer") + }) + .unwrap_or(272); + let density = std::env::var("POCKET_DENSITY") + .ok() + .map(|raw| { + raw.parse::() + .expect("POCKET_DENSITY must be an integer") + }) + .unwrap_or(2); + let host_id = std::env::var("POCKET_HOST_ID") .ok() - .map(|raw| raw.parse::().expect("POCKET_TICK_HZ must be an integer")); + .map(|id| CString::new(id).unwrap()); + let host_abi = std::env::var("POCKET_HOST_ABI").ok().map(|raw| { + raw.parse::() + .expect("POCKET_HOST_ABI must be an integer") + }); + let host = match (&host_id, host_abi) { + (Some(id), Some(abi)) => Some((id, abi)), + (None, None) => None, + _ => panic!("POCKET_HOST_ID and POCKET_HOST_ABI must be set together"), + }; - let (first, w, h, damage_a) = run_instance(&bundle, &pak, tick_hz); - let (second, _, _, damage_b) = run_instance(&bundle, &pak, tick_hz); + let (first, w, h, damage_a) = + run_instance(&bundle, &pak, tick_hz, host, width, height, density); + let (second, _, _, damage_b) = + run_instance(&bundle, &pak, tick_hz, host, width, height, density); - let non_blank = first.chunks_exact(4).any(|px| px[0] != 0 || px[1] != 0 || px[2] != 0); + let non_blank = first + .chunks_exact(4) + .any(|px| px[0] != 0 || px[1] != 0 || px[2] != 0); let deterministic = first == second; write_ppm(&format!("{out_base}-frame{FRAMES}.ppm"), &first, w, h); println!( - "rendered {FRAMES} frames at {w}x{h} (density {DENSITY}) | non_blank={non_blank} deterministic={deterministic} damage_px_a={damage_a} damage_px_b={damage_b}" + "rendered {FRAMES} frames at {w}x{h} (density {density}) | non_blank={non_blank} deterministic={deterministic} damage_px_a={damage_a} damage_px_b={damage_b}" ); if !non_blank || !deterministic { std::process::exit(1); diff --git a/engine/symbian/src/gl/es1.rs b/engine/symbian/src/gl/es1.rs index 26af4854..1819ff3d 100644 --- a/engine/symbian/src/gl/es1.rs +++ b/engine/symbian/src/gl/es1.rs @@ -48,7 +48,9 @@ unsafe extern "C" { fn glEnableClientState(array: GLenum); fn glLoadIdentity(); fn glMatrixMode(mode: GLenum); + fn glRotatef(angle: GLfloat, x: GLfloat, y: GLfloat, z: GLfloat); fn glShadeModel(mode: GLenum); + fn glTranslatef(x: GLfloat, y: GLfloat, z: GLfloat); fn glOrthof( left: GLfloat, right: GLfloat, @@ -114,12 +116,35 @@ impl Pipeline { pub(super) unsafe fn destroy(&mut self) {} /// Replace the vertex shader: pixel coordinates in, top-left origin. - pub(super) unsafe fn begin_frame(&self, logical_width: f32, logical_height: f32) { + pub(super) unsafe fn begin_frame( + &self, + logical_width: f32, + logical_height: f32, + quarter_turns: u32, + ) { + let rotated = quarter_turns & 1 != 0; + let oriented_width = if rotated { logical_height } else { logical_width }; + let oriented_height = if rotated { logical_width } else { logical_height }; glMatrixMode(GL_PROJECTION); glLoadIdentity(); - glOrthof(0.0, logical_width, logical_height, 0.0, -1.0, 1.0); + glOrthof(0.0, oriented_width, oriented_height, 0.0, -1.0, 1.0); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); + match quarter_turns & 3 { + 1 => { + glTranslatef(logical_height, 0.0, 0.0); + glRotatef(90.0, 0.0, 0.0, 1.0); + } + 2 => { + glTranslatef(logical_width, logical_height, 0.0); + glRotatef(180.0, 0.0, 0.0, 1.0); + } + 3 => { + glTranslatef(0.0, logical_width, 0.0); + glRotatef(270.0, 0.0, 0.0, 1.0); + } + _ => {} + } // The DrawList's UVs are already normalized, so the texture matrix must // contribute nothing. glMatrixMode(GL_TEXTURE); @@ -155,4 +180,6 @@ impl Pipeline { glDisableClientState(GL_TEXTURE_COORD_ARRAY); glDisableClientState(GL_COLOR_ARRAY); } + + pub(super) unsafe fn end_frame(&self) {} } diff --git a/engine/symbian/src/gl/es2.rs b/engine/symbian/src/gl/es2.rs index 2ee4680f..6c16250e 100644 --- a/engine/symbian/src/gl/es2.rs +++ b/engine/symbian/src/gl/es2.rs @@ -74,13 +74,22 @@ attribute vec2 a_position; attribute vec2 a_uv; attribute vec4 a_color; uniform vec2 u_viewport; +uniform int u_quarter_turns; varying mediump vec2 v_uv; varying lowp vec4 v_color; void main() { - vec2 ndc = vec2( + vec2 normal = vec2( a_position.x * 2.0 / u_viewport.x - 1.0, 1.0 - a_position.y * 2.0 / u_viewport.y ); + vec2 ndc = normal; + if (u_quarter_turns == 1) { + ndc = vec2(normal.y, -normal.x); + } else if (u_quarter_turns == 2) { + ndc = vec2(-normal.x, -normal.y); + } else if (u_quarter_turns == 3) { + ndc = vec2(-normal.y, normal.x); + } gl_Position = vec4(ndc, 0.0, 1.0); v_uv = a_uv; v_color = a_color; @@ -102,6 +111,7 @@ const ATTR_UV: &[u8] = b"a_uv\0"; const ATTR_COLOR: &[u8] = b"a_color\0"; const UNIFORM_VIEWPORT: &[u8] = b"u_viewport\0"; const UNIFORM_TEXTURE: &[u8] = b"u_texture\0"; +const UNIFORM_QUARTER_TURNS: &[u8] = b"u_quarter_turns\0"; unsafe fn compile_shader(kind: GLenum, source: &[u8]) -> Option { let shader = glCreateShader(kind); @@ -124,6 +134,7 @@ unsafe fn compile_shader(kind: GLenum, source: &[u8]) -> Option { pub(super) struct Pipeline { program: GLuint, viewport_uniform: GLint, + quarter_turns_uniform: GLint, } impl Pipeline { @@ -133,6 +144,7 @@ impl Pipeline { Self { program: 0, viewport_uniform: 0, + quarter_turns_uniform: 0, } } @@ -171,7 +183,11 @@ impl Pipeline { glGetUniformLocation(program, UNIFORM_VIEWPORT.as_ptr() as *const c_char); let texture_uniform = glGetUniformLocation(program, UNIFORM_TEXTURE.as_ptr() as *const c_char); - if viewport_uniform < 0 || texture_uniform < 0 { + let quarter_turns_uniform = glGetUniformLocation( + program, + UNIFORM_QUARTER_TURNS.as_ptr() as *const c_char, + ); + if viewport_uniform < 0 || texture_uniform < 0 || quarter_turns_uniform < 0 { glDeleteProgram(program); return None; } @@ -184,6 +200,7 @@ impl Pipeline { Some(Self { program, viewport_uniform, + quarter_turns_uniform, }) } @@ -193,9 +210,15 @@ impl Pipeline { } /// Bind the program and tell it the logical viewport for this frame. - pub(super) unsafe fn begin_frame(&self, logical_width: f32, logical_height: f32) { + pub(super) unsafe fn begin_frame( + &self, + logical_width: f32, + logical_height: f32, + quarter_turns: u32, + ) { glUseProgram(self.program); glUniform2f(self.viewport_uniform, logical_width, logical_height); + glUniform1i(self.quarter_turns_uniform, (quarter_turns & 3) as GLint); glActiveTexture(GL_TEXTURE0); } @@ -224,4 +247,8 @@ impl Pipeline { glDisableVertexAttribArray(1); glDisableVertexAttribArray(2); } + + pub(super) unsafe fn end_frame(&self) { + glUseProgram(0); + } } diff --git a/engine/symbian/src/gl/mod.rs b/engine/symbian/src/gl/mod.rs index 0b54bd27..a3157044 100644 --- a/engine/symbian/src/gl/mod.rs +++ b/engine/symbian/src/gl/mod.rs @@ -11,7 +11,7 @@ //! matrix stack and client arrays, for the original iPhone's OpenGL ES 1.1 //! MBX Lite). Exactly one is compiled in, chosen by the `gles1` feature, and //! both satisfy the same small interface: `new`, `destroy`, `begin_frame`, -//! `set_blend`, `bind_vertices`, `unbind_vertices`. +//! `set_blend`, `bind_vertices`, `unbind_vertices`, `end_frame`. #![cfg_attr(test, allow(dead_code))] @@ -49,6 +49,7 @@ const GL_TRIANGLES: GLenum = 0x0004; const GL_ARRAY_BUFFER: GLenum = 0x8892; const GL_DYNAMIC_DRAW: GLenum = 0x88e8; const GL_TEXTURE_2D: GLenum = 0x0de1; +const GL_TEXTURE0: GLenum = 0x84c0; const GL_RGBA: GLenum = 0x1908; const GL_LUMINANCE_ALPHA: GLenum = 0x190a; const GL_LINEAR: GLint = 0x2601; @@ -70,6 +71,7 @@ const GL_MAX_TEXTURE_SIZE: GLenum = 0x0d33; const GL_NO_ERROR: GLenum = 0; unsafe extern "C" { + fn glActiveTexture(texture: GLenum); fn glBindBuffer(target: GLenum, buffer: GLuint); fn glBindTexture(target: GLenum, texture: GLuint); fn glBufferData(target: GLenum, size: GLsizeiptr, data: *const c_void, usage: GLenum); @@ -847,6 +849,38 @@ impl Renderer { } } + fn rotate_clip(clip: Clip, logical_width: i32, logical_height: i32, quarter_turns: u32) -> Clip { + match quarter_turns & 3 { + 1 => Clip { + x: logical_height - clip.y - clip.h, + y: clip.x, + w: clip.h, + h: clip.w, + }, + 2 => Clip { + x: logical_width - clip.x - clip.w, + y: logical_height - clip.y - clip.h, + w: clip.w, + h: clip.h, + }, + 3 => Clip { + x: clip.y, + y: logical_width - clip.x - clip.w, + w: clip.h, + h: clip.w, + }, + _ => clip, + } + } + + fn oriented_dimensions(logical_width: i32, logical_height: i32, quarter_turns: u32) -> (i32, i32) { + if quarter_turns & 1 == 0 { + (logical_width, logical_height) + } else { + (logical_height, logical_width) + } + } + unsafe fn render( &mut self, ui: &mut Ui, @@ -857,6 +891,7 @@ impl Renderer { window_width: i32, window_height: i32, clear_color: bool, + quarter_turns: u32, ) -> bool { clear_errors(); if window_width <= 0 || window_height <= 0 { @@ -884,7 +919,7 @@ impl Renderer { self.build(words, logical_width, logical_height); self.pipeline - .begin_frame(logical_width as f32, logical_height as f32); + .begin_frame(logical_width as f32, logical_height as f32, quarter_turns); glViewport( target_x, window_height - target_y - target_height, @@ -914,10 +949,21 @@ impl Renderer { glBindTexture(GL_TEXTURE_2D, command.texture); bound = command.texture; } - let physical = Self::physical_clip( + let rotated_clip = Self::rotate_clip( command.clip, logical_width as i32, logical_height as i32, + quarter_turns, + ); + let (oriented_width, oriented_height) = Self::oriented_dimensions( + logical_width as i32, + logical_height as i32, + quarter_turns, + ); + let physical = Self::physical_clip( + rotated_clip, + oriented_width, + oriented_height, target_x, target_y, target_width, @@ -930,10 +976,13 @@ impl Renderer { glScissor(physical.x, physical.y, physical.w, physical.h); glDrawArrays(GL_TRIANGLES, command.first, command.count); } - glDisable(GL_SCISSOR_TEST); self.pipeline.unbind_vertices(); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_BLEND); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindTexture(GL_TEXTURE_2D, 0); + glActiveTexture(GL_TEXTURE0); + self.pipeline.end_frame(); glGetError() == GL_NO_ERROR } } @@ -989,6 +1038,7 @@ pub unsafe fn render( window_width, window_height, true, + 0, ) }) } @@ -1012,6 +1062,35 @@ pub unsafe fn render_over( window_width, window_height, false, + 0, + ) + }) +} + +pub unsafe fn render_rotated( + ui: &mut Ui, + target_x: i32, + target_y: i32, + target_width: i32, + target_height: i32, + window_width: i32, + window_height: i32, + quarter_turns: u32, +) -> bool { + if quarter_turns > 3 { + return false; + } + RENDERER.as_mut().is_some_and(|renderer| { + renderer.render( + ui, + target_x, + target_y, + target_width, + target_height, + window_width, + window_height, + true, + quarter_turns, ) }) } @@ -1284,4 +1363,26 @@ mod tests { }, ); } + + #[test] + fn quarter_turns_rotate_logical_scissors_and_dimensions() { + let clip = Clip { x: 10, y: 20, w: 30, h: 40 }; + assert_eq!(Renderer::rotate_clip(clip, 100, 200, 0), clip); + assert_eq!( + Renderer::rotate_clip(clip, 100, 200, 1), + Clip { x: 140, y: 10, w: 40, h: 30 }, + ); + assert_eq!( + Renderer::rotate_clip(clip, 100, 200, 2), + Clip { x: 60, y: 140, w: 30, h: 40 }, + ); + assert_eq!( + Renderer::rotate_clip(clip, 100, 200, 3), + Clip { x: 20, y: 60, w: 40, h: 30 }, + ); + assert_eq!(Renderer::oriented_dimensions(100, 200, 0), (100, 200)); + assert_eq!(Renderer::oriented_dimensions(100, 200, 1), (200, 100)); + assert_eq!(Renderer::oriented_dimensions(100, 200, 2), (100, 200)); + assert_eq!(Renderer::oriented_dimensions(100, 200, 3), (200, 100)); + } } diff --git a/engine/symbian/src/lib.rs b/engine/symbian/src/lib.rs index c6ac4412..f661778b 100644 --- a/engine/symbian/src/lib.rs +++ b/engine/symbian/src/lib.rs @@ -592,6 +592,53 @@ pub extern "C" fn ui_gl_render_over( } } +/// Render the retained UI after rotating its logical top-left coordinate +/// space clockwise by `quarter_turns`. Harmattan keeps its native GLES/X11 +/// surface in landscape while PocketJS may expose a portrait live viewport. +#[no_mangle] +pub extern "C" fn ui_gl_render_rotated( + target_x: i32, + target_y: i32, + target_width: i32, + target_height: i32, + window_width: i32, + window_height: i32, + quarter_turns: u32, +) -> i32 { + #[cfg(all( + any(target_os = "none", feature = "bare-platform"), + not(feature = "software-only") + ))] + unsafe { + return gl::render_rotated( + ui(), + target_x, + target_y, + target_width, + target_height, + window_width, + window_height, + quarter_turns, + ) as i32; + } + #[cfg(any( + feature = "software-only", + not(any(target_os = "none", feature = "bare-platform")) + ))] + { + let _ = ( + target_x, + target_y, + target_width, + target_height, + window_width, + window_height, + quarter_turns, + ); + 0 + } +} + #[no_mangle] pub extern "C" fn ui_draw_hash() -> u64 { draw_hash(&ui().draw().words) diff --git a/engine/symbian/targets/armv7-nokia-n9-eabihf.json b/engine/symbian/targets/armv7-nokia-n9-eabihf.json new file mode 100644 index 00000000..bd3acc56 --- /dev/null +++ b/engine/symbian/targets/armv7-nokia-n9-eabihf.json @@ -0,0 +1,22 @@ +{ + "abi": "eabihf", + "arch": "arm", + "cpu": "cortex-a8", + "data-layout": "e-m:e-p:32:32-Fi8-i64:64-v128:64:128-a:0:32-n32-S64", + "emit-debug-gdb-scripts": false, + "env": "gnu", + "features": "+v7,+vfp3,+thumb2,+neon", + "frame-pointer": "always", + "has-thumb-interworking": true, + "linker": "rust-lld", + "linker-flavor": "gnu-lld", + "llvm-floatabi": "hard", + "llvm-target": "armv7-unknown-linux-gnueabihf", + "max-atomic-width": 64, + "os": "linux", + "panic-strategy": "abort", + "relocation-model": "static", + "target-endian": "little", + "target-pointer-width": 32, + "vendor": "nokia" +} diff --git a/hosts/nokia-n9/probe/main.cpp b/hosts/nokia-n9/probe/main.cpp new file mode 100644 index 00000000..8b16406a --- /dev/null +++ b/hosts/nokia-n9/probe/main.cpp @@ -0,0 +1,179 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +QGLFormat pocketN9ProbeFormat() +{ + QGLFormat format; + format.setRgba(true); + format.setDoubleBuffer(true); + format.setDepth(false); + format.setStencil(false); + format.setSampleBuffers(false); + format.setSwapInterval(1); + return format; +} + +class PocketN9ProbeSurface : public QGLWidget +{ +public: + PocketN9ProbeSurface() + : QGLWidget(pocketN9ProbeFormat()), frames_(0), active_(true) + { + setAttribute(Qt::WA_AcceptTouchEvents, true); + setAttribute(Qt::WA_OpaquePaintEvent, true); + setAutoBufferSwap(false); + frameTimer_.start(0, this); + } + + void setDisplayActive(bool active) { active_ = active; } + +protected: + void initializeGL() + { + const char *version = reinterpret_cast(glGetString(GL_VERSION)); + const char *vendor = reinterpret_cast(glGetString(GL_VENDOR)); + const char *renderer = reinterpret_cast(glGetString(GL_RENDERER)); + qWarning( + "PocketJS N9 probe: GLES=%s vendor=%s renderer=%s size=%dx%d swap=%d", + version == 0 ? "unknown" : version, + vendor == 0 ? "unknown" : vendor, + renderer == 0 ? "unknown" : renderer, + width(), + height(), + format().swapInterval() + ); + } + + void paintGL() + { + const float pulse = static_cast(frames_ % 120) / 119.0f; + glViewport(0, 0, width(), height()); + glClearColor(0.02f, 0.2f + pulse * 0.25f, 0.45f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ++frames_; + } + + void timerEvent(QTimerEvent *event) + { + if (event->timerId() == frameTimer_.timerId() && active_) { + updateGL(); + swapBuffers(); + return; + } + QGLWidget::timerEvent(event); + } + + bool event(QEvent *event) + { + if (event->type() == QEvent::TouchBegin || + event->type() == QEvent::TouchUpdate || + event->type() == QEvent::TouchEnd) { + QTouchEvent *touch = static_cast(event); + qWarning( + "PocketJS N9 probe: touch type=%d contacts=%d", + static_cast(event->type()), + touch->touchPoints().size() + ); + event->accept(); + return true; + } + return QGLWidget::event(event); + } + +private: + QBasicTimer frameTimer_; + unsigned int frames_; + bool active_; +}; + +class PocketN9ProbeWindow : public MWindow +{ + Q_OBJECT +public: + explicit PocketN9ProbeWindow(PocketN9ProbeSurface *surface) + : MWindow(), surface_(surface) + { + setViewport(surface_); + setOrientationLocked(false); + setOrientationAngleLocked(false); + connect( + this, + SIGNAL(orientationAngleChanged(M::OrientationAngle)), + this, + SLOT(orientationStarted(M::OrientationAngle)) + ); + connect( + this, + SIGNAL(orientationChangeFinished(M::Orientation)), + this, + SLOT(orientationFinished(M::Orientation)) + ); + connect(this, SIGNAL(displayEntered()), this, SLOT(displayEnteredSlot())); + connect(this, SIGNAL(displayExited()), this, SLOT(displayExitedSlot())); + connect(this, SIGNAL(switcherEntered()), this, SLOT(displayExitedSlot())); + connect(this, SIGNAL(switcherExited()), this, SLOT(displayEnteredSlot())); + } + +private slots: + void orientationStarted(M::OrientationAngle angle) + { + surface_->setDisplayActive(false); + qWarning( + "PocketJS N9 probe: rotation begin angle=%d scene=%dx%d surface=%dx%d", + static_cast(angle), + visibleSceneSize().width(), + visibleSceneSize().height(), + surface_->width(), + surface_->height() + ); + } + + void orientationFinished(M::Orientation orientation) + { + qWarning( + "PocketJS N9 probe: rotation end orientation=%d angle=%d scene=%dx%d surface=%dx%d", + static_cast(orientation), + static_cast(orientationAngle()), + visibleSceneSize().width(), + visibleSceneSize().height(), + surface_->width(), + surface_->height() + ); + surface_->setDisplayActive(true); + } + + void displayEnteredSlot() + { + qWarning("PocketJS N9 probe: display entered"); + surface_->setDisplayActive(true); + } + + void displayExitedSlot() + { + qWarning("PocketJS N9 probe: display exited"); + surface_->setDisplayActive(false); + } + +private: + PocketN9ProbeSurface *surface_; +}; + +M_EXPORT int main(int argc, char *argv[]) +{ + MApplication application(argc, argv); + PocketN9ProbeSurface *surface = new PocketN9ProbeSurface(); + PocketN9ProbeWindow window(surface); + window.showFullScreen(); + surface->setFocus(); + return application.exec(); +} + +#include "main.moc" diff --git a/hosts/nokia-n9/probe/pocketjs-n9-probe.pro b/hosts/nokia-n9/probe/pocketjs-n9-probe.pro new file mode 100644 index 00000000..a685a1e2 --- /dev/null +++ b/hosts/nokia-n9/probe/pocketjs-n9-probe.pro @@ -0,0 +1,17 @@ +TEMPLATE = app +TARGET = pocketjs-n9-probe +QT += core gui opengl +CONFIG += release meegotouch +CONFIG -= debug app_bundle + +SOURCES += main.cpp +QMAKE_CXXFLAGS += -O2 -march=armv7-a -mfpu=neon -mfloat-abi=hard +QMAKE_LFLAGS += -Wl,--no-undefined +LIBS += -lGLESv2 -lEGL + +target.path = /opt/pocketjs-n9-probe/bin +desktop.path = /usr/share/applications +desktop.files = pocketjs-n9-probe_harmattan.desktop +icon.path = /usr/share/icons/hicolor/80x80/apps +icon.files = pocketjs-n9-probe.png +INSTALLS += target desktop icon diff --git a/hosts/nokia-n9/probe/pocketjs-n9-probe_harmattan.desktop b/hosts/nokia-n9/probe/pocketjs-n9-probe_harmattan.desktop new file mode 100644 index 00000000..4e9e62c2 --- /dev/null +++ b/hosts/nokia-n9/probe/pocketjs-n9-probe_harmattan.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Encoding=UTF-8 +Version=1.0 +Type=Application +Terminal=false +Name=PocketJS N9 Probe +Icon=pocketjs-n9-probe +Exec=/usr/bin/invoker --single-instance --type=e /opt/pocketjs-n9-probe/bin/pocketjs-n9-probe diff --git a/hosts/nokia-n9/runtime/manifest.aegis b/hosts/nokia-n9/runtime/manifest.aegis new file mode 100644 index 00000000..30c8c652 --- /dev/null +++ b/hosts/nokia-n9/runtime/manifest.aegis @@ -0,0 +1,4 @@ + + + + diff --git a/hosts/nokia-n9/runtime/pocketjs-n9-runtime.pro b/hosts/nokia-n9/runtime/pocketjs-n9-runtime.pro new file mode 100644 index 00000000..ec1a79c0 --- /dev/null +++ b/hosts/nokia-n9/runtime/pocketjs-n9-runtime.pro @@ -0,0 +1,42 @@ +TEMPLATE = app +isEmpty(POCKETJS_N9_TARGET): error(POCKETJS_N9_TARGET is required) +TARGET = $$POCKETJS_N9_TARGET +QT += core gui opengl +CONFIG += release meegotouch +CONFIG -= debug app_bundle + +SOURCES += main.cpp +HEADERS += pocketjs_symbian_core.h pocketjs_symbian_extension.h pocketjs_symbian_keys.h pocketjs_n9_build.h +RESOURCES += pocketjs-runtime.qrc + +isEmpty(POCKETJS_QUICKJS_INCLUDE): error(POCKETJS_QUICKJS_INCLUDE is required) +isEmpty(POCKETJS_QUICKJS_LIBRARY): error(POCKETJS_QUICKJS_LIBRARY is required) +isEmpty(POCKETJS_CORE_LIBRARY): error(POCKETJS_CORE_LIBRARY is required) +isEmpty(POCKETJS_N9_PACKAGE): error(POCKETJS_N9_PACKAGE is required) + +INCLUDEPATH += $$POCKETJS_QUICKJS_INCLUDE +DEFINES += __STDC_LIMIT_MACROS +DEFINES += POCKETJS_HARMATTAN=1 +DEFINES += POCKETJS_MAXIMUM_VIEWPORT_EXTENT=854 +DEFINES += POCKETJS_FRAME_RATE=60 +DEFINES += POCKETJS_HOST_ABI=9 +DEFINES += POCKETJS_INITIAL_LOGICAL_WIDTH=854 +DEFINES += POCKETJS_INITIAL_LOGICAL_HEIGHT=480 +DEFINES += POCKETJS_CAPTURE_FRAME_PERIOD=24 + +QMAKE_CFLAGS += -O2 -march=armv7-a -mfpu=neon -mfloat-abi=hard +QMAKE_CXXFLAGS += -O2 -march=armv7-a -mfpu=neon -mfloat-abi=hard +QMAKE_LFLAGS += -Wl,--no-undefined + +LIBS += $$POCKETJS_QUICKJS_LIBRARY +PRE_TARGETDEPS += $$POCKETJS_QUICKJS_LIBRARY +QMAKE_LFLAGS += -Wl,-u,ui_init -Wl,--whole-archive $$POCKETJS_CORE_LIBRARY -Wl,--no-whole-archive +PRE_TARGETDEPS += $$POCKETJS_CORE_LIBRARY +LIBS += -lGLESv2 -lEGL + +target.path = /opt/$$POCKETJS_N9_PACKAGE/bin +desktop.path = /usr/share/applications +desktop.files = $${POCKETJS_N9_PACKAGE}_harmattan.desktop +icon.path = /usr/share/icons/hicolor/80x80/apps +icon.files = $${POCKETJS_N9_PACKAGE}.png +INSTALLS += target desktop icon diff --git a/hosts/nokia-n9/runtime/pocketjs-n9.desktop.in b/hosts/nokia-n9/runtime/pocketjs-n9.desktop.in new file mode 100644 index 00000000..feb0789e --- /dev/null +++ b/hosts/nokia-n9/runtime/pocketjs-n9.desktop.in @@ -0,0 +1,8 @@ +[Desktop Entry] +Encoding=UTF-8 +Version=1.0 +Type=Application +Terminal=false +Name=@TITLE@ +Icon=@PACKAGE@ +Exec=/usr/bin/invoker --single-instance --type=e /opt/@PACKAGE@/bin/@EXECUTABLE@ diff --git a/hosts/symbian/runtime/main.cpp b/hosts/symbian/runtime/main.cpp index 2391e171..6fb1b27e 100644 --- a/hosts/symbian/runtime/main.cpp +++ b/hosts/symbian/runtime/main.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -15,14 +16,26 @@ #include #include #include -#ifdef POCKETJS_PERF_TRACE +#if defined(POCKETJS_PERF_TRACE) || defined(POCKETJS_HARMATTAN) #include #endif +#ifdef POCKETJS_HARMATTAN +#include +#endif #include #include #include #include +#ifdef POCKETJS_HARMATTAN +#include +#include +#include +#include +#include +#include +#endif + #include #include @@ -33,6 +46,38 @@ extern "C" { #include "pocketjs_symbian_core.h" #include "pocketjs_symbian_extension.h" #include "pocketjs_symbian_keys.h" +#ifdef POCKETJS_HARMATTAN +#include "pocketjs_n9_build.h" +#endif + +#ifndef POCKETJS_TARGET_ID +#ifdef POCKETJS_HARMATTAN +#define POCKETJS_TARGET_ID "nokia-n9-dev" +#else +#define POCKETJS_TARGET_ID "symbian-e7-dev" +#endif +#endif + +#ifndef POCKETJS_MAXIMUM_VIEWPORT_EXTENT +#define POCKETJS_MAXIMUM_VIEWPORT_EXTENT 640 +#endif + +#ifndef POCKETJS_RUNTIME_NAME +#ifdef POCKETJS_HARMATTAN +#define POCKETJS_RUNTIME_NAME "PocketJS Nokia N9" +#else +#define POCKETJS_RUNTIME_NAME "PocketJS E7" +#endif +#endif + +#ifdef POCKETJS_HARMATTAN +#ifndef POCKETJS_BUILD_ID +#error POCKETJS_BUILD_ID is required for the Harmattan acceptance paths +#endif +#ifndef POCKETJS_CAPTURE_FRAME_PERIOD +#define POCKETJS_CAPTURE_FRAME_PERIOD 1 +#endif +#endif typedef char PocketJsQuickJsValueMustBeEightBytes[ sizeof(JSValue) == 8 ? 1 : -1 @@ -58,7 +103,34 @@ typedef char PocketJsQuickJsValueMustBeEightBytes[ namespace { -const int kMaximumViewportExtent = 640; +#ifdef POCKETJS_HARMATTAN +qint64 pocketJsMonotonicMicros() +{ + struct timespec value; + if (clock_gettime(CLOCK_MONOTONIC, &value) != 0) return 0; + return static_cast(value.tv_sec) * Q_INT64_C(1000000) + + static_cast(value.tv_nsec / 1000); +} + +bool pocketJsWriteAtomicFile(const QString &destination, const QByteArray &bytes) +{ + const QString temporary = destination + ".tmp"; + QFile file(temporary); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) return false; + const bool written = file.write(bytes) == bytes.size() && file.flush(); + if (written) fsync(file.handle()); + file.close(); + if (!written || ::rename( + temporary.toLocal8Bit().constData(), + destination.toLocal8Bit().constData()) != 0) { + QFile::remove(temporary); + return false; + } + return true; +} +#endif + +const int kMaximumViewportExtent = POCKETJS_MAXIMUM_VIEWPORT_EXTENT; const int kAnalogCenter = 0x8080; const int kMaximumTouches = 8; const int kCoreTicksPerFrame = 60 / POCKETJS_FRAME_RATE; @@ -97,6 +169,9 @@ QGLFormat pocketJsGlFormat() QGLFormat format; format.setRgba(true); format.setDoubleBuffer(true); +#ifdef POCKETJS_HARMATTAN + format.setSwapInterval(1); +#endif const PocketJsSymbianExtensionV1 *extension = pocketJsNativeExtension(); format.setDepth( @@ -152,7 +227,9 @@ enum HostOperation { HostDebugRectXY, HostDebugRectWH, HostDebugPause, - HostDebugStep + HostDebugStep, + HostHitTestBounds, + HostReportAppAction }; struct EmbeddedApp @@ -182,6 +259,13 @@ bool lookupActivePackEntry( size_t *length ); +void recordActiveAppAction( + PocketJsRuntime *runtime, + const char *name, + size_t nameLength, + int32_t value +); + JSValue hostAppTable( JSContext *context, JSValueConst thisValue, @@ -471,7 +555,7 @@ bool parsePocketPackage( *error = "embedded .pocket variant is truncated"; return false; } - if (target != "symbian-e7-dev") continue; + if (target != POCKETJS_TARGET_ID) continue; if (hostAbi != static_cast(POCKETJS_HOST_ABI)) { *error = "embedded .pocket host ABI does not match this runtime"; return false; @@ -515,7 +599,7 @@ bool parsePocketPackage( } if (!foundVariant) { - *error = "embedded .pocket has no symbian-e7-dev variant"; + *error = "embedded .pocket has no variant for this Qt host"; return false; } if (identityOffset < 0 || javaScriptOffset < 0 || @@ -849,6 +933,16 @@ JSValue hostOperation( ui_hit_test(static_cast(da), static_cast(db)) ); + case HostHitTestBounds: + if (!floatArgument(context, argc, argv, 0, &da) || + !floatArgument(context, argc, argv, 1, &db)) { + return JS_EXCEPTION; + } + return JS_NewInt32( + context, + ui_hit_test_bounds(static_cast(da), static_cast(db)) + ); + case HostSetCursor: if (!intArgument(context, argc, argv, 0, &a) || !floatArgument(context, argc, argv, 1, &da) || @@ -964,6 +1058,22 @@ JSValue hostOperation( case HostDebugStep: ui_debug_step(); return JS_UNDEFINED; + + case HostReportAppAction: { + if (!stringArgument(context, argc, argv, 0, &text, &textLength) || + !intArgument(context, argc, argv, 1, &a)) { + if (text != 0) JS_FreeCString(context, text); + return JS_EXCEPTION; + } + recordActiveAppAction( + static_cast(JS_GetContextOpaque(context)), + text, + textLength, + a + ); + JS_FreeCString(context, text); + return JS_UNDEFINED; + } } return JS_ThrowInternalError(context, "unknown PocketJS HostOp"); @@ -1018,6 +1128,7 @@ bool installHostOps( addHostOperation(context, ui, "setFocus", 1, HostSetFocus); addHostOperation(context, ui, "setActive", 2, HostSetActive); addHostOperation(context, ui, "hitTest", 2, HostHitTest); + addHostOperation(context, ui, "hitTestBounds", 2, HostHitTestBounds); addHostOperation(context, ui, "setCursor", 5, HostSetCursor); addHostOperation(context, ui, "setCursorPos", 2, HostSetCursorPos); addHostOperation(context, ui, "loadStyles", 1, HostLoadStyles); @@ -1031,6 +1142,13 @@ bool installHostOps( addHostOperation(context, ui, "debugRectWH", 0, HostDebugRectWH); addHostOperation(context, ui, "debugPause", 1, HostDebugPause); addHostOperation(context, ui, "debugStep", 0, HostDebugStep); + addHostOperation( + context, + ui, + "__reportAppAction", + 2, + HostReportAppAction + ); if (multiApp) { JS_SetPropertyStr( context, @@ -1071,7 +1189,7 @@ bool installHostOps( context, ui, "__host", - JS_NewString(context, "symbian-e7-dev") + JS_NewString(context, POCKETJS_TARGET_ID) ); JS_SetPropertyStr( context, @@ -1079,6 +1197,12 @@ bool installHostOps( "__hostAbi", JS_NewInt32(context, POCKETJS_HOST_ABI) ); + JS_SetPropertyStr( + context, + ui, + "__tickHz", + JS_NewInt32(context, POCKETJS_FRAME_RATE) + ); // Deliberately publish neither __textures nor __sprites. The target-bound // native host still feeds styles, fonts, and images through global __pak. @@ -1166,6 +1290,13 @@ class PocketJsRuntime : public QGLWidget size_t *length ) const; bool requestAppLaunch(const QString &output); + void recordAppAction(const QString &name, int value); +#ifdef POCKETJS_HARMATTAN + void beginHarmattanOrientation(M::OrientationAngle angle); + void finishHarmattanOrientation(M::OrientationAngle angle); + void setHarmattanDisplayActive(bool active); + QByteArray nokiaN9StatusJson() const; +#endif protected: bool event(QEvent *event); @@ -1199,6 +1330,12 @@ class PocketJsRuntime : public QGLWidget void requestSummon(); void runFrame(); void updateTouches(QTouchEvent *event); +#ifdef POCKETJS_HARMATTAN + void captureNokiaN9Frame(); + void resetNokiaN9PerformanceWindow(); + void writeNokiaN9Status(); + QString nokiaN9Path(const char *suffix) const; +#endif JSRuntime *runtime_; JSContext *context_; @@ -1217,6 +1354,8 @@ class PocketJsRuntime : public QGLWidget const PocketJsSymbianExtensionV1 *extension_; QLabel *errorLabel_; QVector touches_; + QVector touchHits_; + QMap touchHitById_; QSize viewportSize_; QSize pendingViewportSize_; QSize windowSize_; @@ -1228,6 +1367,10 @@ class PocketJsRuntime : public QGLWidget int pendingApp_; int resumeApp_; int frozenShotHandle_; + QString actionName_; + int actionValue_; + uint32_t actionSequence_; + uint32_t completedTouchSequences_; bool coreInitialized_; bool initialized_; bool guestLiveViewport_; @@ -1236,6 +1379,33 @@ class PocketJsRuntime : public QGLWidget bool selectLatched_; bool failed_; bool glInitialized_; +#ifdef POCKETJS_HARMATTAN + uint32_t quarterTurns_; + uint32_t orientationTransitions_; + bool displayActive_; + bool orientationAnimating_; + QString runtimeError_; + QString glVersion_; + QString glVendor_; + QString glRenderer_; + int glMaximumTextureSize_; + uint64_t heartbeat_; + uint64_t guestFrames_; + uint64_t presentedFrames_; + uint32_t contextGeneration_; + uint32_t preparedContextGeneration_; + qint64 lastPresentUs_; + qint64 lastStatusWriteUs_; + qint64 lastJavaScriptUs_; + qint64 lastPendingJobsUs_; + qint64 lastCoreTickUs_; + qint64 lastGlSubmitUs_; + qint64 lastSwapUs_; + qint64 lastTotalFrameUs_; + QVector frameIntervalsUs_; + int warmupFramesRemaining_; + bool skipNextFrameInterval_; +#endif }; bool lookupActivePackEntry( @@ -1281,6 +1451,9 @@ PocketJsRuntime::PocketJsRuntime() pendingApp_(-1), resumeApp_(-1), frozenShotHandle_(-1), + actionValue_(0), + actionSequence_(0), + completedTouchSequences_(0), coreInitialized_(false), initialized_(false), guestLiveViewport_(true), @@ -1289,13 +1462,39 @@ PocketJsRuntime::PocketJsRuntime() selectLatched_(true), failed_(false), glInitialized_(false) +#ifdef POCKETJS_HARMATTAN + , quarterTurns_(0), + orientationTransitions_(0), + displayActive_(true), + orientationAnimating_(false), + glMaximumTextureSize_(0), + heartbeat_(0), + guestFrames_(0), + presentedFrames_(0), + contextGeneration_(0), + preparedContextGeneration_(0), + lastPresentUs_(0), + lastStatusWriteUs_(0), + lastJavaScriptUs_(0), + lastPendingJobsUs_(0), + lastCoreTickUs_(0), + lastGlSubmitUs_(0), + lastSwapUs_(0), + lastTotalFrameUs_(0), + warmupFramesRemaining_(120), + skipNextFrameInterval_(true) +#endif { #ifdef POCKETJS_PERF_TRACE QFile::remove("E:/Installs/pocketjs-perf.tsv"); #endif setAttribute(Qt::WA_OpaquePaintEvent, true); setAttribute(Qt::WA_AcceptTouchEvents, true); +#ifndef POCKETJS_HARMATTAN setAttribute(Qt::WA_AutoOrientation, true); +#else + setAutoBufferSwap(false); +#endif // This surface is a controller, not a Qt text editor. Do not advertise // input-method capabilities; controller identity comes from nativeScanCode. setAttribute(Qt::WA_InputMethodEnabled, false); @@ -1317,7 +1516,12 @@ PocketJsRuntime::PocketJsRuntime() // QApplication::exec() starts only after main() calls showFullScreen(). // The first timer event therefore observes the native fullscreen extent // instead of QWidget's pre-show default geometry. - const int interval = qMax(1, 1000 / POCKETJS_FRAME_RATE); + const int interval = +#ifdef POCKETJS_HARMATTAN + 0; +#else + qMax(1, 1000 / POCKETJS_FRAME_RATE); +#endif timer_.start(interval, this); } @@ -1340,6 +1544,8 @@ void PocketJsRuntime::clearInput() nativeKeys_ = 0; pressedNativeKeys_ = 0; touches_.clear(); + touchHits_.clear(); + touchHitById_.clear(); } void PocketJsRuntime::destroyGuest() @@ -1830,6 +2036,96 @@ int PocketJsRuntime::frozenShotHandle() const return frozenShotHandle_; } +void PocketJsRuntime::recordAppAction(const QString &name, int value) +{ + actionName_ = name; + actionValue_ = value; + ++actionSequence_; +} + +#ifdef POCKETJS_HARMATTAN +void PocketJsRuntime::resetNokiaN9PerformanceWindow() +{ + frameIntervalsUs_.clear(); + warmupFramesRemaining_ = 120; + lastPresentUs_ = 0; + skipNextFrameInterval_ = true; +} + +void PocketJsRuntime::beginHarmattanOrientation(M::OrientationAngle angle) +{ + uint32_t turns = 0; + if (angle == M::Angle270) { + turns = 3; + } else if (angle != M::Angle0) { + fail("PocketJS Nokia N9 supports only normal landscape and portrait."); + return; + } + const QSize viewport = (turns & 1U) != 0 + ? QSize(480, 854) + : QSize(854, 480); + orientationAnimating_ = true; + if (turns != quarterTurns_) { + quarterTurns_ = turns; + ++orientationTransitions_; + } + clearInput(); + resetNokiaN9PerformanceWindow(); + queueViewport(viewport); +} + +void PocketJsRuntime::finishHarmattanOrientation(M::OrientationAngle angle) +{ + beginHarmattanOrientation(angle); + orientationAnimating_ = false; + update(); +} + +void PocketJsRuntime::setHarmattanDisplayActive(bool active) +{ + displayActive_ = active; + if (!active) { + clearInput(); + resetNokiaN9PerformanceWindow(); + preparedContextGeneration_ = 0; + } + if (active) { + resetNokiaN9PerformanceWindow(); + if (coreInitialized_ && isValid() && + preparedContextGeneration_ != contextGeneration_) { + makeCurrent(); + glInitialized_ = ui_gl_initialize() != 0; + preparedContextGeneration_ = contextGeneration_; + doneCurrent(); + if (!glInitialized_) fail("PocketJS could not restore the GLES2 renderer."); + } + update(); + } +} + + +QString PocketJsRuntime::nokiaN9Path(const char *suffix) const +{ + return QString("/tmp/pocketjs-n9-%1%2") + .arg(POCKETJS_BUILD_ID) + .arg(QString::fromLatin1(suffix)); +} +#endif + +void recordActiveAppAction( + PocketJsRuntime *runtime, + const char *name, + size_t nameLength, + int32_t value +) +{ + if (runtime == 0 || name == 0) return; + runtime->recordAppAction( + QString::fromUtf8(name, static_cast(nameLength)), + value + ); +} + bool PocketJsRuntime::requestAppLaunch(const QString &output) { for (int index = 0; index < apps_.size(); ++index) { @@ -1924,9 +2220,13 @@ void PocketJsRuntime::fail(const QString &message) { if (failed_) return; failed_ = true; +#ifdef POCKETJS_HARMATTAN + runtimeError_ = message; + writeNokiaN9Status(); +#endif timer_.stop(); errorLabel_->setText( - QString("PocketJS E7 runtime stopped\n\n%1").arg(message) + QString(POCKETJS_RUNTIME_NAME " runtime stopped\n\n%1").arg(message) ); errorLabel_->setGeometry(rect()); errorLabel_->show(); @@ -2080,6 +2380,10 @@ QRect PocketJsRuntime::presentationRect() const void PocketJsRuntime::runFrame() { +#ifdef POCKETJS_HARMATTAN + const qint64 frameStartUs = pocketJsMonotonicMicros(); + const qint64 javaScriptStartUs = frameStartUs; +#endif #ifdef POCKETJS_PERF_TRACE static QTime frameClock; int frameDeltaMs = 0; @@ -2118,6 +2422,7 @@ void PocketJsRuntime::runFrame() } JSValue touchArray = JS_NewArray(context_); + JSValue hitArray = JS_NewArray(context_); for (int index = 0; index < touches_.size(); ++index) { JS_SetPropertyUint32( context_, @@ -2125,29 +2430,47 @@ void PocketJsRuntime::runFrame() static_cast(index), JS_NewUint32(context_, touches_.at(index)) ); + JS_SetPropertyUint32( + context_, + hitArray, + static_cast(index), + JS_NewInt32(context_, touchHits_.value(index, 0)) + ); } - JSValue arguments[3]; + JSValue arguments[4]; arguments[0] = JS_NewInt32(context_, frameButtons); arguments[1] = JS_NewInt32(context_, kAnalogCenter); arguments[2] = touchArray; + arguments[3] = hitArray; JSValue result = JS_Call( context_, frame_, global_, - 3, + 4, arguments ); JS_FreeValue(context_, arguments[0]); JS_FreeValue(context_, arguments[1]); JS_FreeValue(context_, touchArray); + JS_FreeValue(context_, hitArray); +#ifdef POCKETJS_HARMATTAN + const qint64 javaScriptEndUs = pocketJsMonotonicMicros(); +#endif if (JS_IsException(result)) { fail(takeException(context_)); return; } JS_FreeValue(context_, result); +#ifdef POCKETJS_HARMATTAN + ++guestFrames_; + const qint64 pendingJobsStartUs = pocketJsMonotonicMicros(); +#endif if (!drainJobs()) return; +#ifdef POCKETJS_HARMATTAN + const qint64 pendingJobsEndUs = pocketJsMonotonicMicros(); +#endif if (extension_ != 0 && extension_->after_guest != 0 && extension_->after_guest(context_) == 0) { @@ -2157,17 +2480,58 @@ void PocketJsRuntime::runFrame() #ifdef POCKETJS_PERF_TRACE const int jsMs = perfTimer.elapsed(); #endif +#ifdef POCKETJS_HARMATTAN + const qint64 coreTickStartUs = pocketJsMonotonicMicros(); +#endif for (int tick = 0; tick < kCoreTicksPerFrame; ++tick) { ui_tick(); } +#ifdef POCKETJS_HARMATTAN + const qint64 coreTickEndUs = pocketJsMonotonicMicros(); +#endif #ifdef POCKETJS_PERF_TRACE const int tickEndMs = perfTimer.elapsed(); #endif - // QGLWidget::updateGL() is synchronous: paintGL() submits this frame and - // auto-swaps before a pending guest switch can release its DrawList and - // texture storage. + // updateGL() submits synchronously. Harmattan disables QGLWidget's + // implicit swap so the blocking, interval-1 present is measured and the + // next zero-delay event cannot run before it completes. updateGL(); +#ifdef POCKETJS_HARMATTAN + if (failed_) return; + const qint64 glSubmitEndUs = pocketJsMonotonicMicros(); + swapBuffers(); + const qint64 presentUs = pocketJsMonotonicMicros(); + lastJavaScriptUs_ = javaScriptEndUs - javaScriptStartUs; + lastPendingJobsUs_ = pendingJobsEndUs - pendingJobsStartUs; + lastCoreTickUs_ = coreTickEndUs - coreTickStartUs; + lastGlSubmitUs_ = glSubmitEndUs - coreTickEndUs; + lastSwapUs_ = presentUs - glSubmitEndUs; + lastTotalFrameUs_ = presentUs - frameStartUs; + + if (lastPresentUs_ > 0 && !skipNextFrameInterval_) { + const qint64 intervalUs = presentUs - lastPresentUs_; + if (intervalUs > 0) { + if (warmupFramesRemaining_ > 0) { + --warmupFramesRemaining_; + } else { + frameIntervalsUs_.append(intervalUs); + if (frameIntervalsUs_.size() > 600) { + frameIntervalsUs_.remove(0); + } + } + } + } + lastPresentUs_ = presentUs; + skipNextFrameInterval_ = false; + ++presentedFrames_; + ++heartbeat_; + if (lastStatusWriteUs_ == 0 || + presentUs - lastStatusWriteUs_ >= Q_INT64_C(1000000)) { + writeNokiaN9Status(); + lastStatusWriteUs_ = presentUs; + } +#endif #ifdef POCKETJS_PERF_TRACE const int presentEndMs = perfTimer.elapsed(); static int perfFrame = 0; @@ -2292,6 +2656,7 @@ void PocketJsRuntime::finishPendingSwitch() void PocketJsRuntime::updateTouches(QTouchEvent *touchEvent) { touches_.clear(); + touchHits_.clear(); if (!initialized_ || hasPendingViewport_) { @@ -2306,27 +2671,76 @@ void PocketJsRuntime::updateTouches(QTouchEvent *touchEvent) index < points.size() && touches_.size() < kMaximumTouches; ++index) { const QTouchEvent::TouchPoint &point = points.at(index); - if (point.state() == Qt::TouchPointReleased) continue; + if (point.state() == Qt::TouchPointReleased) { + if (touchHitById_.remove(point.id()) > 0) { + ++completedTouchSequences_; + } + continue; + } const QPointF position = point.pos(); if (position.x() < target.left() || position.x() >= target.left() + target.width() || position.y() < target.top() || position.y() >= target.top() + target.height()) { + touchHitById_.remove(point.id()); continue; } - int x = static_cast( + int x = 0; + int y = 0; +#ifdef POCKETJS_HARMATTAN + const int orientedWidth = (quarterTurns_ & 1U) != 0 + ? viewportSize_.height() + : viewportSize_.width(); + const int orientedHeight = (quarterTurns_ & 1U) != 0 + ? viewportSize_.width() + : viewportSize_.height(); + const int orientedX = static_cast( + (position.x() - target.left()) * orientedWidth / target.width() + ); + const int orientedY = static_cast( + (position.y() - target.top()) * orientedHeight / target.height() + ); + switch (quarterTurns_ & 3U) { + case 1: + x = orientedY; + y = viewportSize_.height() - 1 - orientedX; + break; + case 2: + x = viewportSize_.width() - 1 - orientedX; + y = viewportSize_.height() - 1 - orientedY; + break; + case 3: + x = viewportSize_.width() - 1 - orientedY; + y = orientedX; + break; + default: + x = orientedX; + y = orientedY; + break; + } +#else + x = static_cast( (position.x() - target.left()) * viewportSize_.width() / target.width() ); - int y = static_cast( + y = static_cast( (position.y() - target.top()) * viewportSize_.height() / target.height() ); +#endif x = qBound(0, x, viewportSize_.width() - 1); y = qBound(0, y, viewportSize_.height() - 1); + if (point.state() == Qt::TouchPointPressed) { + touchHitById_.insert( + point.id(), + ui_hit_test_bounds(static_cast(x), static_cast(y)) + ); + } else if (!touchHitById_.contains(point.id())) { + continue; + } // Compatibility extension wire. Legacy contacts keep bit 31 clear // with 9-bit x/y. E7 contacts set it and carry 10-bit x/y so the // native 640px viewport never truncates coordinates: @@ -2337,6 +2751,7 @@ void PocketJsRuntime::updateTouches(QTouchEvent *touchEvent) ((static_cast(y) & 0x3ff) << 10) | (static_cast(x) & 0x3ff); touches_.append(packed); + touchHits_.append(touchHitById_.value(point.id(), 0)); } } @@ -2354,6 +2769,10 @@ bool PocketJsRuntime::event(QEvent *event) void PocketJsRuntime::keyPressEvent(QKeyEvent *event) { +#ifdef POCKETJS_HARMATTAN + QGLWidget::keyPressEvent(event); + return; +#else const int key = pocketjsSymbianControlKey( event->key(), event->nativeScanCode() @@ -2375,10 +2794,15 @@ void PocketJsRuntime::keyPressEvent(QKeyEvent *event) return; } QGLWidget::keyPressEvent(event); +#endif } void PocketJsRuntime::keyReleaseEvent(QKeyEvent *event) { +#ifdef POCKETJS_HARMATTAN + QGLWidget::keyReleaseEvent(event); + return; +#else const int key = pocketjsSymbianControlKey( event->key(), event->nativeScanCode() @@ -2396,6 +2820,7 @@ void PocketJsRuntime::keyReleaseEvent(QKeyEvent *event) return; } QGLWidget::keyReleaseEvent(event); +#endif } void PocketJsRuntime::focusOutEvent(QFocusEvent *event) @@ -2406,11 +2831,20 @@ void PocketJsRuntime::focusOutEvent(QFocusEvent *event) void PocketJsRuntime::initializeGL() { +#ifdef POCKETJS_HARMATTAN + ++contextGeneration_; +#endif const char *version = reinterpret_cast(glGetString(GL_VERSION)); const char *vendor = reinterpret_cast(glGetString(GL_VENDOR)); const char *renderer = reinterpret_cast(glGetString(GL_RENDERER)); GLint maximumTextureSize = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maximumTextureSize); +#ifdef POCKETJS_HARMATTAN + glVersion_ = QString::fromLatin1(version == 0 ? "unknown" : version); + glVendor_ = QString::fromLatin1(vendor == 0 ? "unknown" : vendor); + glRenderer_ = QString::fromLatin1(renderer == 0 ? "unknown" : renderer); + glMaximumTextureSize_ = maximumTextureSize; +#endif qWarning( "PocketJS GLES2: version=%s vendor=%s renderer=%s maxTexture=%d", version == 0 ? "unknown" : version, @@ -2440,6 +2874,9 @@ void PocketJsRuntime::initializeGL() return; } glInitialized_ = ui_gl_initialize() != 0; +#ifdef POCKETJS_HARMATTAN + preparedContextGeneration_ = glInitialized_ ? contextGeneration_ : 0; +#endif if (!glInitialized_) { fail("PocketJS could not initialize the OpenGL ES 2 renderer."); } @@ -2470,16 +2907,29 @@ void PocketJsRuntime::paintGL() } nativeRendered = true; } - const int uiRendered = nativeRendered - ? ui_gl_render_over( + int uiRendered = 0; + if (nativeRendered) { + uiRendered = ui_gl_render_over( target.x(), target.y(), target.width(), target.height(), width(), height() - ) - : ui_gl_render( + ); + } else { +#ifdef POCKETJS_HARMATTAN + uiRendered = ui_gl_render_rotated( + target.x(), + target.y(), + target.width(), + target.height(), + width(), + height(), + quarterTurns_ + ); +#else + uiRendered = ui_gl_render( target.x(), target.y(), target.width(), @@ -2487,10 +2937,15 @@ void PocketJsRuntime::paintGL() width(), height() ); +#endif + } if (uiRendered == 0) { fail("PocketJS OpenGL ES 2 rendering failed."); return; } +#ifdef POCKETJS_HARMATTAN + captureNokiaN9Frame(); +#endif if (pendingApp_ >= 0 && pendingSummon_) { // QGLWidget auto-swaps only after paintGL returns, so this captures // the outgoing guest's just-rendered back buffer, never an undefined @@ -2499,9 +2954,172 @@ void PocketJsRuntime::paintGL() } } +#ifdef POCKETJS_HARMATTAN +QByteArray PocketJsRuntime::nokiaN9StatusJson() const +{ + QVector sorted = frameIntervalsUs_; + qSort(sorted); + qint64 sum = 0; + qint64 maximum = 0; + int missed = 0; + for (int index = 0; index < sorted.size(); ++index) { + const qint64 value = sorted.at(index); + sum += value; + maximum = qMax(maximum, value); + if (value >= Q_INT64_C(25000)) ++missed; + } + const qint64 p95 = sorted.isEmpty() + ? 0 + : sorted.at(qMin( + sorted.size() - 1, + (sorted.size() * 95 + 99) / 100 - 1 + )); + const double averageHz = sum > 0 + ? (1000000.0 * sorted.size()) / static_cast(sum) + : 0.0; + + QByteArray json("{"); + json.append("\"schema\":1,\"build_id\":"); + appendJsonString(&json, QString::fromLatin1(POCKETJS_BUILD_ID)); + json.append(",\"target\":\"nokia-n9-dev\",\"host_abi\":9"); + json.append(",\"pid\":"); + json.append(QByteArray::number(static_cast(getpid()))); + json.append(",\"state\":"); + appendJsonString(&json, failed_ ? QString("error") : QString("running")); + json.append(",\"error\":"); + appendJsonString(&json, runtimeError_); + json.append(",\"heartbeat\":"); + json.append(QByteArray::number(heartbeat_)); + json.append(",\"guest_frames\":"); + json.append(QByteArray::number(guestFrames_)); + json.append(",\"presented_frames\":"); + json.append(QByteArray::number(presentedFrames_)); + json.append(",\"logical_width\":"); + json.append(QByteArray::number(viewportSize_.width())); + json.append(",\"logical_height\":"); + json.append(QByteArray::number(viewportSize_.height())); + json.append(",\"physical_width\":"); + json.append(QByteArray::number(width())); + json.append(",\"physical_height\":"); + json.append(QByteArray::number(height())); + json.append(",\"orientation\":"); + appendJsonString( + &json, + (quarterTurns_ & 1U) != 0 ? QString("portrait") : QString("landscape") + ); + json.append(",\"quarter_turns\":"); + json.append(QByteArray::number(quarterTurns_)); + json.append(",\"orientation_transitions\":"); + json.append(QByteArray::number(orientationTransitions_)); + json.append(",\"context_generation\":"); + json.append(QByteArray::number(contextGeneration_)); + json.append(",\"display_active\":"); + json.append(displayActive_ ? "true" : "false"); + json.append(",\"tick_hz\":60,\"renderer\":\"gles2\",\"gl_version\":"); + appendJsonString(&json, glVersion_); + json.append(",\"gl_vendor\":"); + appendJsonString(&json, glVendor_); + json.append(",\"gl_renderer\":"); + appendJsonString(&json, glRenderer_); + json.append(",\"gl_max_texture_size\":"); + json.append(QByteArray::number(glMaximumTextureSize_)); + json.append(",\"completed_touch_sequences\":"); + json.append(QByteArray::number(completedTouchSequences_)); + json.append(",\"action_name\":"); + appendJsonString(&json, actionName_); + json.append(",\"action_value\":"); + json.append(QByteArray::number(actionValue_)); + json.append(",\"action_sequence\":"); + json.append(QByteArray::number(actionSequence_)); + json.append(",\"timings_us\":{\"javascript\":"); + json.append(QByteArray::number(lastJavaScriptUs_)); + json.append(",\"pending_jobs\":"); + json.append(QByteArray::number(lastPendingJobsUs_)); + json.append(",\"core_tick\":"); + json.append(QByteArray::number(lastCoreTickUs_)); + json.append(",\"gl_submit\":"); + json.append(QByteArray::number(lastGlSubmitUs_)); + json.append(",\"swap\":"); + json.append(QByteArray::number(lastSwapUs_)); + json.append(",\"total\":"); + json.append(QByteArray::number(lastTotalFrameUs_)); + json.append("}"); + json.append(",\"fps_window\":{\"samples\":"); + json.append(QByteArray::number(sorted.size())); + json.append(",\"warmup_remaining\":"); + json.append(QByteArray::number(warmupFramesRemaining_)); + json.append(",\"average_hz\":"); + json.append(QByteArray::number(averageHz, 'f', 3)); + json.append(",\"p95_ms\":"); + json.append(QByteArray::number(static_cast(p95) / 1000.0, 'f', 3)); + json.append(",\"max_ms\":"); + json.append(QByteArray::number(static_cast(maximum) / 1000.0, 'f', 3)); + json.append(",\"missed_vblanks\":"); + json.append(QByteArray::number(missed)); + json.append("}}"); + return json; +} + +void PocketJsRuntime::writeNokiaN9Status() +{ + pocketJsWriteAtomicFile(nokiaN9Path(".status.json"), nokiaN9StatusJson()); +} + +void PocketJsRuntime::captureNokiaN9Frame() +{ + const QString request = nokiaN9Path(".capture-request"); + if (!QFile::exists(request)) return; + if ((guestFrames_ % POCKETJS_CAPTURE_FRAME_PERIOD) != 0U) return; + const int captureWidth = width(); + const int captureHeight = height(); + if (captureWidth <= 0 || captureHeight <= 0) return; + + QByteArray pixels(captureWidth * captureHeight * 4, 0); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels( + 0, + 0, + captureWidth, + captureHeight, + GL_RGBA, + GL_UNSIGNED_BYTE, + pixels.data() + ); + if (glGetError() != GL_NO_ERROR) return; + + const QString raw = nokiaN9Path(".frame.rgba"); + if (!pocketJsWriteAtomicFile(raw, pixels)) return; + + QByteArray sidecar("{\"build_id\":\""); + sidecar.append(POCKETJS_BUILD_ID); + sidecar.append("\",\"width\":"); + sidecar.append(QByteArray::number(captureWidth)); + sidecar.append(",\"height\":"); + sidecar.append(QByteArray::number(captureHeight)); + sidecar.append(",\"quarter_turns\":"); + sidecar.append(QByteArray::number(quarterTurns_)); + sidecar.append(",\"guest_frame\":"); + sidecar.append(QByteArray::number(guestFrames_)); + sidecar.append(",\"gl_version\":"); + appendJsonString(&sidecar, glVersion_); + sidecar.append(",\"gl_vendor\":"); + appendJsonString(&sidecar, glVendor_); + sidecar.append(",\"gl_renderer\":"); + appendJsonString(&sidecar, glRenderer_); + sidecar.append("}"); + const QString metadata = nokiaN9Path(".frame.json"); + if (pocketJsWriteAtomicFile(metadata, sidecar)) { + QFile::remove(request); + skipNextFrameInterval_ = true; + } +} +#endif + void PocketJsRuntime::resizeEvent(QResizeEvent *event) { +#ifndef POCKETJS_HARMATTAN queueViewport(event->size()); +#endif errorLabel_->setGeometry(rect()); QGLWidget::resizeEvent(event); } @@ -2510,23 +3128,49 @@ void PocketJsRuntime::timerEvent(QTimerEvent *event) { if (event->timerId() == timer_.timerId()) { if (failed_) return; +#ifdef POCKETJS_HARMATTAN + if (!displayActive_ || orientationAnimating_) return; +#endif // QResizeEvent is authoritative; polling size() is a cheap fallback // for Belle variants that coalesce a native layout notification. +#ifndef POCKETJS_HARMATTAN queueViewport(size()); +#endif if (!initialized_) { - if (!isVisible() || !isFullScreen() || !validViewport(size())) { + if (!isVisible() || +#ifndef POCKETJS_HARMATTAN + !isFullScreen() || +#endif + !validViewport( +#ifdef POCKETJS_HARMATTAN + pendingViewportSize_ +#else + size() +#endif + )) { return; } if (!isValid()) { fail("PocketJS could not obtain a valid OpenGL ES 2 context."); return; } - if (!initialize(size())) return; + if (!initialize( +#ifdef POCKETJS_HARMATTAN + pendingViewportSize_ +#else + size() +#endif + )) return; } else if (!isValid()) { +#ifdef POCKETJS_HARMATTAN + update(); + return; +#else fail("PocketJS lost its OpenGL ES 2 context."); return; +#endif } if (!applyPendingViewport()) { @@ -2540,8 +3184,77 @@ void PocketJsRuntime::timerEvent(QTimerEvent *event) QGLWidget::timerEvent(event); } +#ifdef POCKETJS_HARMATTAN +class PocketN9Window : public MWindow +{ + Q_OBJECT +public: + explicit PocketN9Window(PocketJsRuntime *runtime) + : MWindow(), runtime_(runtime) + { + setViewport(runtime_); + setOrientationLocked(false); + setOrientationAngleLocked(false); + connect( + this, + SIGNAL(orientationAngleChanged(M::OrientationAngle)), + this, + SLOT(beginOrientation(M::OrientationAngle)) + ); + connect( + this, + SIGNAL(orientationChangeFinished(M::Orientation)), + this, + SLOT(finishOrientation(M::Orientation)) + ); + connect(this, SIGNAL(displayEntered()), this, SLOT(enterDisplay())); + connect(this, SIGNAL(displayExited()), this, SLOT(exitDisplay())); + connect(this, SIGNAL(switcherEntered()), this, SLOT(exitDisplay())); + connect(this, SIGNAL(switcherExited()), this, SLOT(enterDisplay())); + } + +private slots: + void beginOrientation(M::OrientationAngle angle) + { + runtime_->beginHarmattanOrientation(angle); + } + + void finishOrientation(M::Orientation) + { + runtime_->finishHarmattanOrientation(orientationAngle()); + } + + void enterDisplay() + { + runtime_->setHarmattanDisplayActive(true); + runtime_->finishHarmattanOrientation(orientationAngle()); + } + + void exitDisplay() + { + runtime_->setHarmattanDisplayActive(false); + } + +private: + PocketJsRuntime *runtime_; +}; +#endif + } // namespace +#ifdef POCKETJS_HARMATTAN +M_EXPORT int main(int argc, char *argv[]) +{ + MApplication application(argc, argv); + PocketJsRuntime *runtime = new PocketJsRuntime(); + PocketN9Window window(runtime); + window.showFullScreen(); + runtime->finishHarmattanOrientation(window.orientationAngle()); + runtime->setFocus(); + return application.exec(); +} +#include "main.moc" +#else int main(int argc, char *argv[]) { QApplication application(argc, argv); @@ -2550,3 +3263,4 @@ int main(int argc, char *argv[]) runtime.setFocus(); return application.exec(); } +#endif diff --git a/hosts/symbian/runtime/pocketjs_symbian_core.h b/hosts/symbian/runtime/pocketjs_symbian_core.h index a59a1216..3c804fbf 100644 --- a/hosts/symbian/runtime/pocketjs_symbian_core.h +++ b/hosts/symbian/runtime/pocketjs_symbian_core.h @@ -98,6 +98,16 @@ int32_t ui_gl_render_over( int32_t window_width, int32_t window_height ); +int32_t ui_gl_render_rotated( + int32_t target_x, + int32_t target_y, + int32_t target_width, + int32_t target_height, + int32_t window_width, + int32_t window_height, + uint32_t quarter_turns +); +int32_t ui_hit_test_bounds(float x, float y); const uint8_t *ui_render_incremental(void); uint32_t ui_framebuffer_width(void); uint32_t ui_framebuffer_height(void); diff --git a/package.json b/package.json index cef70d78..57ca86d5 100644 --- a/package.json +++ b/package.json @@ -30,18 +30,21 @@ "apps/iphone4s-demo", "apps/ipodtouch-demo", "apps/meizu-m8-demo", + "apps/nokia-n9-demo", "apps/nsengine", "hosts/apple", "hosts/iphone2g", "hosts/iphone4s", "hosts/ipodtouch", "hosts/meizu-m8", + "hosts/nokia-n9", "hosts/web", "docs/APPLE.md", "docs/IPHONE2G.md", "docs/IPHONE4S.md", "docs/IPODTOUCH.md", "docs/MEIZU_M8.md", + "docs/NOKIA_N9.md", "assets/brand", "assets/fonts", "assets/images/logo.png", @@ -203,6 +206,7 @@ "iphone4s": "bun tools/iphone4s.ts", "ipodtouch": "bun tools/ipodtouch.ts", "meizu-m8": "bun tools/meizu-m8.ts", + "nokia-n9": "bun tools/nokia-n9.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/tests/cli.test.ts b/tests/cli.test.ts index 24752cc7..b5777a0d 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -69,7 +69,7 @@ describe("published PocketJS CLI", () => { script: import.meta.url, args: Bun.argv.slice(2), }));\n`; - for (const name of ["pocket", "play", "symbian", "vita", "ios"]) { + for (const name of ["pocket", "play", "symbian", "nokia-n9", "vita", "ios"]) { writeFileSync(join(scripts, `${name}.ts`), recorder); } @@ -82,6 +82,7 @@ describe("published PocketJS CLI", () => { { cliArgs: ["symbian", "doctor", "--device"], script: "symbian.ts", args: ["doctor", "--device"] }, { cliArgs: ["symbian", "coda", "usb"], script: "symbian.ts", args: ["coda", "usb"] }, { cliArgs: ["symbian", "coda", "usb", "launch"], script: "symbian.ts", args: ["coda", "usb", "launch"] }, + { cliArgs: ["nokia-n9", "doctor", "--device"], script: "nokia-n9.ts", args: ["doctor", "--device"] }, { cliArgs: ["ios", "play", "nsengine"], script: "ios.ts", args: ["play", "nsengine"] }, { cliArgs: ["play", "ios", "nsengine"], script: "play.ts", args: ["ios", "nsengine"] }, ]; diff --git a/tests/nokia-n9-profile.test.ts b/tests/nokia-n9-profile.test.ts new file mode 100644 index 00000000..f0436935 --- /dev/null +++ b/tests/nokia-n9-profile.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { POCKET_TARGETS } from "../contracts/spec/platforms.ts"; +import { + NOKIA_N9_DEFAULT_VIEWPORT, + NOKIA_N9_DEV_CONTRACTS, + NOKIA_N9_DEV_HOST_ABI, + NOKIA_N9_DEV_TARGET_ID, + NOKIA_N9_MAX_VIEWPORT, + NOKIA_N9_MIN_VIEWPORT, + NOKIA_N9_PHYSICAL_VIEWPORT, + resolveNokiaN9BuildPlan, +} from "../tools/nokia-n9-profile.ts"; + +const root = resolve(import.meta.dir, ".."); +const demoManifest = () => JSON.parse( + readFileSync(resolve(root, "apps/nokia-n9-demo/pocket.json"), "utf8"), +); + +describe("private Nokia N9 build profile", () => { + test("stays private and describes the live native Harmattan viewport", () => { + expect(POCKET_TARGETS).not.toHaveProperty(NOKIA_N9_DEV_TARGET_ID); + expect(NOKIA_N9_DEV_CONTRACTS.targets[NOKIA_N9_DEV_TARGET_ID]).toEqual({ + hostAbi: NOKIA_N9_DEV_HOST_ABI, + platform: "harmattan", + form: "window", + display: { + physicalViewport: NOKIA_N9_PHYSICAL_VIEWPORT, + logicalViewports: [NOKIA_N9_DEFAULT_VIEWPORT], + dynamicViewport: { + min: NOKIA_N9_MIN_VIEWPORT, + max: NOKIA_N9_MAX_VIEWPORT, + }, + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: [ + "input.touch", + "display.viewport.live", + "text.glyphs.baked", + ], + }); + }); + + test("resolves the dedicated touch Hero at 854x480", () => { + const plan = resolveNokiaN9BuildPlan(demoManifest()); + expect(plan.target).toEqual({ + id: NOKIA_N9_DEV_TARGET_ID, + hostAbi: NOKIA_N9_DEV_HOST_ABI, + }); + expect(plan.viewport).toEqual({ + logical: [854, 480], + physical: [854, 480], + presentation: "native", + rasterDensity: 1, + }); + expect(plan.features).toEqual({ + "input.touch": true, + "display.viewport.live": true, + "text.glyphs.baked": true, + }); + }); + + test("rejects button-only and fixed-viewport applications", () => { + const buttons = demoManifest(); + buttons.engine.capabilities.requires = ["input.buttons", "text.glyphs.baked"]; + expect(() => resolveNokiaN9BuildPlan(buttons)).toThrow("input.buttons"); + + const fixed = demoManifest(); + fixed.app.viewport = { + fixed: { logical: [854, 480], presentation: "native" }, + }; + expect(() => resolveNokiaN9BuildPlan(fixed)).toThrow(); + }); + + test("keeps the shared Hero device-neutral", () => { + const shared = readFileSync(resolve(root, "apps/hero/app.tsx"), "utf8"); + const wrapper = readFileSync(resolve(root, "apps/nokia-n9-demo/app.tsx"), "utf8"); + expect(shared).not.toContain("Nokia N9"); + expect(wrapper).toContain('headline="JSX on Harmattan."'); + expect(wrapper).toContain("compactHeadline"); + expect(wrapper).toContain('reportAppAction("hero_tap", count)'); + expect(wrapper).toContain("presentationHz={60}"); + }); +}); diff --git a/tests/nokia-n9-runtime.test.ts b/tests/nokia-n9-runtime.test.ts new file mode 100644 index 00000000..4a0a9cf3 --- /dev/null +++ b/tests/nokia-n9-runtime.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + nokiaN9PackageIdentity, + mapNokiaN9Touch, + normalizeNokiaN9Frame, + parseNokiaN9Status, + validateNokiaN9CaptureReceipts, + validateNokiaN9Status, +} from "../tools/nokia-n9.ts"; +import { resolveNokiaN9BuildPlan } from "../tools/nokia-n9-profile.ts"; + +const root = resolve(import.meta.dir, ".."); +const manifest = () => JSON.parse(readFileSync( + resolve(root, "apps/nokia-n9-demo/pocket.json"), + "utf8", +)); + +function status(overrides: Record = {}) { + return { + schema: 1, + build_id: "0123456789abcdef01234567", + target: "nokia-n9-dev", + host_abi: 9, + pid: 42, + state: "running", + error: "", + heartbeat: 10, + guest_frames: 10, + presented_frames: 10, + logical_width: 854, + logical_height: 480, + physical_width: 854, + physical_height: 480, + orientation: "landscape", + quarter_turns: 0, + orientation_transitions: 2, + context_generation: 1, + display_active: true, + tick_hz: 60, + renderer: "gles2", + gl_version: "OpenGL ES 2.0", + gl_vendor: "Imagination Technologies", + gl_renderer: "PowerVR SGX 530", + gl_max_texture_size: 2048, + completed_touch_sequences: 2, + action_name: "hero_tap", + action_value: 2, + action_sequence: 2, + timings_us: { + javascript: 500, + pending_jobs: 50, + core_tick: 300, + gl_submit: 700, + swap: 15000, + total: 16550, + }, + fps_window: { + samples: 600, + warmup_remaining: 0, + average_hz: 60, + p95_ms: 17, + max_ms: 18, + missed_vblanks: 0, + }, + ...overrides, + }; +} + +describe("Nokia N9 runtime and acceptance contracts", () => { + test("derives stable Debian identity from the dedicated manifest", () => { + const source = manifest(); + const identity = nokiaN9PackageIdentity(resolveNokiaN9BuildPlan(source), source); + expect(identity).toEqual({ + packageName: "pocketjs-nokia-n9-hero", + executable: "nokia-n9-demo-main", + title: "PocketJS: Nokia N9 Hero", + version: "0.1.0", + output: "nokia-n9-demo-main", + }); + }); + + test("rejects stale, stalled, non-GLES and actionless status", () => { + const buildId = status().build_id; + expect(() => validateNokiaN9Status( + status({ heartbeat: 1, presented_frames: 1 }) as any, + status({ heartbeat: 2, presented_frames: 2 }) as any, + buildId, + true, + )).not.toThrow(); + expect(() => validateNokiaN9Status(status() as any, status() as any, buildId, false)) + .toThrow("did not advance"); + expect(() => validateNokiaN9Status( + status() as any, + status({ heartbeat: 11, presented_frames: 11, renderer: "software" }) as any, + buildId, + false, + )).toThrow("60 Hz GLES2"); + expect(() => validateNokiaN9Status( + status() as any, + status({ heartbeat: 11, presented_frames: 11, physical_width: 480 }) as any, + buildId, + false, + )).toThrow("viewport or visibility"); + expect(() => validateNokiaN9Status( + status() as any, + status({ heartbeat: 11, presented_frames: 11, action_sequence: 0 }) as any, + buildId, + true, + )).toThrow("Hero touch/action"); + }); + + test("parses only schema-1 ABI-9 N9 receipts", () => { + expect(parseNokiaN9Status(JSON.stringify(status())).host_abi).toBe(9); + expect(() => parseNokiaN9Status(JSON.stringify(status({ host_abi: 8 })))) + .toThrow("incompatible"); + }); + + test("normalizes bottom-up GLES captures through all quarter turns", () => { + // Physical top-down colors: A B / C D / E F. Raw GLES rows are reversed. + const pixel = (value: number) => [value, 0, 0, 255]; + const raw = Uint8Array.from([ + ...pixel(5), ...pixel(6), + ...pixel(3), ...pixel(4), + ...pixel(1), ...pixel(2), + ]); + const red = (turns: number) => { + const frame = normalizeNokiaN9Frame(raw, 2, 3, turns); + return { + size: [frame.width, frame.height], + values: Array.from(frame.rgba).filter((_, index) => index % 4 === 0), + }; + }; + expect(red(0)).toEqual({ size: [2, 3], values: [1, 2, 3, 4, 5, 6] }); + expect(red(1)).toEqual({ size: [3, 2], values: [2, 4, 6, 1, 3, 5] }); + expect(red(2)).toEqual({ size: [2, 3], values: [6, 5, 4, 3, 2, 1] }); + expect(red(3)).toEqual({ size: [3, 2], values: [5, 3, 1, 6, 4, 2] }); + }); + + test("maps all physical touch corners through four quarter turns", () => { + const corners = (turns: number) => { + const physicalWidth = turns & 1 ? 2 : 3; + const physicalHeight = turns & 1 ? 3 : 2; + return [ + mapNokiaN9Touch(0, 0, 3, 2, turns), + mapNokiaN9Touch(physicalWidth - 1, 0, 3, 2, turns), + mapNokiaN9Touch(0, physicalHeight - 1, 3, 2, turns), + mapNokiaN9Touch(physicalWidth - 1, physicalHeight - 1, 3, 2, turns), + ]; + }; + expect(corners(0)).toEqual([ + { x: 0, y: 0 }, { x: 2, y: 0 }, { x: 0, y: 1 }, { x: 2, y: 1 }, + ]); + expect(corners(1)).toEqual([ + { x: 0, y: 1 }, { x: 0, y: 0 }, { x: 2, y: 1 }, { x: 2, y: 0 }, + ]); + expect(corners(2)).toEqual([ + { x: 2, y: 1 }, { x: 0, y: 1 }, { x: 2, y: 0 }, { x: 0, y: 0 }, + ]); + expect(corners(3)).toEqual([ + { x: 2, y: 0 }, { x: 2, y: 1 }, { x: 0, y: 0 }, { x: 0, y: 1 }, + ]); + }); + + test("requires three aligned byte-identical captures in both orientations", () => { + const buildId = status().build_id; + const make = ( + orientation: "landscape" | "portrait", + sequence: number, + hash = "a".repeat(64), + ) => ({ + schema: 1 as const, + build_id: buildId, + orientation, + sequence, + width: 854, + height: 480, + quarter_turns: orientation === "landscape" ? 0 : 3, + guest_frame: sequence * 24, + gl_version: "OpenGL ES 2.0", + gl_vendor: "Imagination Technologies", + gl_renderer: "PowerVR SGX 530", + raw_sha256: hash, + device_fingerprint: "firmware-and-gl", + device: { + schema: 1 as const, + model: "RM696", + arch: "armv7l", + kernel: "2.6.32-dfl61", + harmattan: "40.2012.21-3", + qt: "4.7.4", + }, + }); + const complete = [ + ...[1, 2, 3].map((sequence) => make("landscape", sequence)), + ...[1, 2, 3].map((sequence) => make("portrait", sequence)), + ]; + expect(validateNokiaN9CaptureReceipts(complete, buildId)).toBe("firmware-and-gl"); + expect(() => validateNokiaN9CaptureReceipts(complete.slice(1), buildId)) + .toThrow("three byte-identical"); + expect(() => validateNokiaN9CaptureReceipts([ + ...complete.slice(0, -1), + make("portrait", 3, "b".repeat(64)), + ], buildId)).toThrow("three byte-identical"); + }); + + test("shares E7 HostOps while adding live rotation, hit facts, status and capture", () => { + const runtime = readFileSync(resolve(root, "hosts/symbian/runtime/main.cpp"), "utf8"); + const cli = readFileSync(resolve(root, "tools/nokia-n9.ts"), "utf8"); + expect(runtime).toContain("POCKETJS_TARGET_ID"); + expect(runtime).toContain('"hitTestBounds"'); + expect(runtime).toContain("JSValue arguments[4]"); + expect(runtime).toContain("ui_gl_render_rotated"); + expect(runtime).toContain("class PocketN9Window : public MWindow"); + expect(runtime).toContain("orientationAngleChanged(M::OrientationAngle)"); + expect(runtime).toContain("point.state() == Qt::TouchPointPressed"); + expect(runtime).toContain("touchHitById_.remove(point.id())"); + expect(runtime).toContain("switcherEntered()"); + expect(runtime).toContain("setAutoBufferSwap(false)"); + expect(runtime).toContain("swapBuffers();"); + expect(runtime).toContain("CLOCK_MONOTONIC"); + expect(runtime).toContain("timings_us"); + expect(runtime).toContain("POCKETJS_CAPTURE_FRAME_PERIOD"); + expect(runtime).toContain("::rename("); + expect(runtime).toContain(".status.json"); + expect(runtime).toContain(".capture-request"); + expect(cli).toContain('const PRIVATE_KEY = join(KEY_ROOT, "id_rsa")'); + expect(cli).toContain('sftpTransfer("put"'); + expect(cli).toContain('sftpTransfer("get"'); + expect(cli).toContain('"HostkeyAlgorithms=+ssh-rsa"'); + expect(cli).toContain("^model=RM-?696$"); + expect(cli).toContain("meego-nokia-version"); + expect(cli).not.toContain("IMEI"); + }); +}); diff --git a/tests/nokia-n9-toolchain.test.ts b/tests/nokia-n9-toolchain.test.ts new file mode 100644 index 00000000..53753aec --- /dev/null +++ b/tests/nokia-n9-toolchain.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + NOKIA_N9_TOOLCHAIN, + nokiaN9DockerBuildArguments, + nokiaN9DockerRunArguments, + nokiaN9ImplementationDigest, +} from "../tools/nokia-n9-toolchain.ts"; + +const root = resolve(import.meta.dir, ".."); + +describe("pinned Nokia N9 Harmattan toolchain", () => { + test("pins both the community archive and historical installer", () => { + expect(NOKIA_N9_TOOLCHAIN.sdk.url).toBe( + "https://n9.dy.fi/wp-content/uploads/2015/08/QtSdk-offline-linux-x86_64-v1.2.1.zip", + ); + expect(NOKIA_N9_TOOLCHAIN.sdk.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(NOKIA_N9_TOOLCHAIN.sdk.installerMd5).toBe( + "d200c6aa8684e9963d2ea4354266da3f", + ); + expect(NOKIA_N9_TOOLCHAIN.sdk.installerSha256).toMatch(/^[a-f0-9]{64}$/); + expect(NOKIA_N9_TOOLCHAIN.quickjs.rev).toMatch(/^[a-f0-9]{40}$/); + expect(NOKIA_N9_TOOLCHAIN.quickjs.sha256).toMatch(/^[a-f0-9]{64}$/); + }); + + test("builds amd64 from Debian snapshots and runs builds without network", () => { + const digest = nokiaN9ImplementationDigest(root); + expect(digest).toMatch(/^[a-f0-9]{64}$/); + const build = nokiaN9DockerBuildArguments(root).join(" "); + expect(build).toContain("--platform linux/amd64"); + expect(build).toContain(NOKIA_N9_TOOLCHAIN.container.baseImage); + expect(build).toContain(digest); + const run = nokiaN9DockerRunArguments(root, "pocketjs-nokia-n9-doctor"); + expect(run).toContain("--network=none"); + expect(run.join(" ")).toContain("dst=/work,readonly"); + expect(run.join(" ")).toContain("dst=/toolchain"); + const orchestrator = readFileSync(resolve(root, "tools/nokia-n9.ts"), "utf8"); + expect(orchestrator).toContain('"--env", "CARGO_NET_OFFLINE=true"'); + expect(orchestrator).toContain("dst=/rustup,readonly"); + expect(orchestrator).toContain("dst=/cargo,readonly"); + expect(orchestrator).toContain('"--workdir", "/work/engine/symbian"'); + }); + + test("installer setup validates hashes before entering the persistent volume", () => { + const setup = readFileSync( + resolve(root, "tools/nokia-n9/container/pocketjs-nokia-n9-setup"), + "utf8", + ); + expect(setup).toContain("sha256sum --check --status"); + expect(setup).toContain("md5sum --check --status"); + expect(setup).toContain("rm -rf /toolchain/QtSDK"); + expect(setup).toContain("test -x /toolchain/QtSDK/Madde/bin/mad"); + }); + + test("ARM target matches Harmattan's Cortex-A8 VFP-argument ABI", () => { + const target = JSON.parse(readFileSync( + resolve(root, "engine/symbian/targets/armv7-nokia-n9-eabihf.json"), + "utf8", + )); + expect(target.abi).toBe("eabihf"); + expect(target.cpu).toBe("cortex-a8"); + expect(target["llvm-floatabi"]).toBe("hard"); + expect(target.features).toContain("+vfp3"); + expect(target.features).toContain("+neon"); + }); + + test("app packaging rejects ABI, dependency, and GLIBC drift", () => { + const builder = readFileSync( + resolve(root, "tools/nokia-n9/container/pocketjs-nokia-n9-build-app"), + "utf8", + ); + expect(builder).toContain("-mfloat-abi=hard"); + expect(builder).toContain("Tag_ABI_VFP_args: VFP registers"); + expect(builder).toContain("Requesting program interpreter: /lib/ld-linux.so.3"); + expect(builder).toContain("unexpected dynamic dependency"); + expect(builder).toContain("required-glibc.txt"); + expect(builder).toContain("available-glibc.txt"); + expect(builder).toContain("dpkg-deb --build"); + expect(builder).toContain("s|@EXECUTABLE@|$executable|g"); + expect(builder).not.toContain("export HOME="); + }); +}); diff --git a/tests/npm-package.test.ts b/tests/npm-package.test.ts index b9a2a866..460999f6 100644 --- a/tests/npm-package.test.ts +++ b/tests/npm-package.test.ts @@ -61,18 +61,21 @@ describe("published npm artifacts", () => { "apps/iphone4s-demo", "apps/ipodtouch-demo", "apps/meizu-m8-demo", + "apps/nokia-n9-demo", "apps/nsengine", "hosts/apple", "hosts/iphone2g", "hosts/iphone4s", "hosts/ipodtouch", "hosts/meizu-m8", + "hosts/nokia-n9", "hosts/web", "docs/APPLE.md", "docs/IPHONE2G.md", "docs/IPHONE4S.md", "docs/IPODTOUCH.md", "docs/MEIZU_M8.md", + "docs/NOKIA_N9.md", "assets/brand", "assets/fonts", "assets/images/logo.png", diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index c5be4d56..4e12c244 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -348,6 +348,7 @@ describe("semantic resolution", () => { "iphone4s-demo": [false, false, false], // admitted only by the private iphone4s-dev profile "ipodtouch-demo": [false, false, false], // admitted only by the private ipodtouch-dev profile "meizu-m8-demo": [false, false, false], // admitted only by the private meizu-m8-dev profile + "nokia-n9-demo": [false, false, false], // admitted only by the private nokia-n9-dev live-viewport profile nsengine: [false, true, false], // targets the private ios-dev profile; vita shares its touch + integer-fit contract "ipod-nano": [false, false, false], // admitted by the package-shaped macos-embedded target launcher: [true, true, false], // the Cover Flow deck (docs/LAUNCHER.md) is an ordinary console app diff --git a/tests/symbian-runtime.test.ts b/tests/symbian-runtime.test.ts index 4768cc29..433e73bb 100644 --- a/tests/symbian-runtime.test.ts +++ b/tests/symbian-runtime.test.ts @@ -413,7 +413,8 @@ describe("experimental Nokia E7 runtime profile", () => { "utf8", ); - expect(runtime).toContain('JS_NewString(context, "symbian-e7-dev")'); + expect(runtime).toContain('#define POCKETJS_TARGET_ID "symbian-e7-dev"'); + expect(runtime).toContain("JS_NewString(context, POCKETJS_TARGET_ID)"); expect(runtime).toContain('"__hostAbi"'); expect(runtime).toContain("JS_NewInt32(context, POCKETJS_HOST_ABI)"); expect(runtime).toContain("JS_ExecutePendingJob"); @@ -444,8 +445,8 @@ describe("experimental Nokia E7 runtime profile", () => { expect(runtime).toContain("PocketJS lost its OpenGL ES 2 context."); expect(runtime).toContain("glInitialized_ && isValid()"); expect(runtime).toContain("void PocketJsRuntime::paintGL()"); - expect(runtime).toContain("? ui_gl_render_over("); - expect(runtime).toContain(": ui_gl_render("); + expect(runtime).toContain("uiRendered = ui_gl_render_over("); + expect(runtime).toContain("uiRendered = ui_gl_render("); expect(runtime).toContain("const QRect target = presentationRect();"); expect(runtime).toContain("glReadPixels("); expect(runtime).toContain( @@ -693,7 +694,7 @@ describe("experimental Nokia E7 runtime profile", () => { ); expect(runtime).toContain("kPocketPackageMagic = 0x544b4350U"); - expect(runtime).toContain('target != "symbian-e7-dev"'); + expect(runtime).toContain("target != POCKETJS_TARGET_ID"); expect(runtime).toContain( "hostAbi != static_cast(POCKETJS_HOST_ABI)", ); diff --git a/tests/test-suite.test.ts b/tests/test-suite.test.ts index 572b9c93..0382cc88 100644 --- a/tests/test-suite.test.ts +++ b/tests/test-suite.test.ts @@ -74,4 +74,15 @@ describe("declared test suite", () => { expect(ipodtouchTests).not.toHaveLength(0); expect(ipodtouchTests.filter((file) => !declared.has(file))).toEqual([]); }); + + test("runs every Nokia N9 test in the CI unit stage", () => { + const declared = unitTestFiles(); + const nokiaN9Tests = readdirSync(join(repository, "tests")) + .filter((file) => /^nokia-n9-.*\.test\.ts$/.test(file)) + .map((file) => `tests/${file}`) + .sort(); + + expect(nokiaN9Tests).not.toHaveLength(0); + expect(nokiaN9Tests.filter((file) => !declared.has(file))).toEqual([]); + }); }); diff --git a/tools/cli/README.md b/tools/cli/README.md index 327e42e6..6c03ee53 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -4,7 +4,8 @@ The [PocketJS](https://pocketjs.dev) toolchain CLI — `doctor`/`setup` for the bun + Rust + PSP toolchain (flutter-doctor style), manifest-first app scaffolding, build/run passthrough for PSP and PS Vita, an isolated Nokia E7 / Symbian development toolchain, and an Apple iOS flow that stages -guests into a NativeScript shell and launches them on the simulator. +guests into a NativeScript shell and launches them on the simulator. It also +ships the private Nokia N9 / Harmattan build and hardware-acceptance flow. ```sh npm install -g @pocketjs/cli @@ -27,6 +28,12 @@ pocket symbian build probe pocket symbian deploy dist/symbian/pocketjs-e7-probe.sis pocket symbian coda usb pocket symbian coda usb launch +pocket nokia-n9 setup --yes +pocket nokia-n9 doctor --device +pocket nokia-n9 build app --manifest apps/nokia-n9-demo/pocket.json +pocket nokia-n9 deploy dist/nokia-n9/pocketjs-nokia-n9-hero_0.1.0-1_armel.deb +pocket nokia-n9 launch +pocket nokia-n9 accept pocket ios doctor pocket ios setup pocket ios play nsengine # NativeScript shell on the arm64 iOS simulator diff --git a/tools/cli/bin.mjs b/tools/cli/bin.mjs index e3e6f4fc..c8c5de7e 100644 --- a/tools/cli/bin.mjs +++ b/tools/cli/bin.mjs @@ -370,6 +370,7 @@ const SCRIPTS = { psp: "tools/psp.ts", vita: "tools/vita.ts", symbian: "tools/symbian.ts", + "nokia-n9": "tools/nokia-n9.ts", ios: "tools/ios.ts", hw: "tools/hw.ts", psplink: "tools/psplink.ts", @@ -420,6 +421,7 @@ const HELP = `${C.bold("pocket")} — the PocketJS toolchain CLI pocket psp build the PSP EBOOT pocket vita build the PS Vita VPK pocket symbian Nokia E7 doctor/setup/build-probe/deploy + pocket nokia-n9 Nokia N9 doctor/setup/build/deploy/accept pocket ios Apple iOS doctor/setup/build/play on the simulator pocket hw build + run on a real PSP over PSPLINK pocket psplink interactive multi-app switcher on a real PSP @@ -446,6 +448,7 @@ switch (cmd) { case "psp": case "vita": case "symbian": + case "nokia-n9": case "ios": case "hw": case "psplink": diff --git a/tools/cli/nokia-n9-toolchain.json b/tools/cli/nokia-n9-toolchain.json new file mode 100644 index 00000000..90659c9b --- /dev/null +++ b/tools/cli/nokia-n9-toolchain.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "toolchainVersion": "qtsdk-1.2.1-harmattan-v1", + "container": { + "platform": "linux/amd64", + "baseImage": "debian:bullseye-slim@sha256:de70627667ac77b32ab6858f1acddfb04a4ff3acc1095ac17dbc19fe5725bcb6", + "debianSnapshot": "20260712T202631Z", + "debianSecuritySnapshot": "20260712T194830Z", + "image": "pocketjs-nokia-n9-toolchain:qtsdk-1.2.1-v1", + "volume": "pocketjs-nokia-n9-toolchain-v1" + }, + "sdk": { + "asset": "QtSdk-offline-linux-x86_64-v1.2.1.zip", + "url": "https://n9.dy.fi/wp-content/uploads/2015/08/QtSdk-offline-linux-x86_64-v1.2.1.zip", + "bytes": 795165690, + "sha256": "04f0dbbac9be5c30104159af287beb44baed3ade5d24fc31649418b7c5d1e3ca", + "installer": "QtSdk-offline-linux-x86_64-v1.2.1.run", + "installerMd5": "d200c6aa8684e9963d2ea4354266da3f", + "installerSha256": "c95f2da84f4925a6d7f8bc6b3257a5565b097e51bb851223614378fc61772a0c" + }, + "quickjs": { + "version": "2026-06-04", + "repository": "https://github.com/pocket-stack/quickjs-rs", + "rev": "ba5bdd0dc013518768e76cd9e05cd30ed53dd35b", + "asset": "quickjs-rs-ba5bdd0dc013518768e76cd9e05cd30ed53dd35b.tar.gz", + "url": "https://codeload.github.com/pocket-stack/quickjs-rs/tar.gz/ba5bdd0dc013518768e76cd9e05cd30ed53dd35b", + "sha256": "c721a3645e4c8df526482c544450fe2bb6351e820471727511ac054c35f44fc3" + }, + "downloadsCachePath": "nokia-n9/downloads", + "receipt": ".pocket-stack-nokia-n9.json", + "markers": [ + "Madde/bin/mad", + "Madde/bin/mad-admin", + "Madde/sysroots/harmattan_sysroot_10.2011.34-1_slim/usr/include/qt4/QtCore/qglobal.h", + "Madde/sysroots/harmattan_sysroot_10.2011.34-1_slim/usr/include/meegotouch/MWindow", + "Madde/sysroots/harmattan_sysroot_10.2011.34-1_slim/usr/lib/libGLESv2.so" + ], + "device": { + "usbVendorId": "0421", + "storageProductId": "0518", + "model": "Nokia N9", + "defaultHost": "192.168.2.15", + "user": "developer" + }, + "runtime": { + "rustToolchain": "nightly-2026-07-02", + "tickHz": 60, + "target": "nokia-n9-dev", + "hostAbi": 9 + } +} diff --git a/tools/nokia-n9-profile.ts b/tools/nokia-n9-profile.ts new file mode 100644 index 00000000..5ec5493f --- /dev/null +++ b/tools/nokia-n9-profile.ts @@ -0,0 +1,58 @@ +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 profile for the Nokia N9 running Harmattan. */ +export const NOKIA_N9_DEV_TARGET_ID = "nokia-n9-dev"; +export const NOKIA_N9_DEV_HOST_ABI = 9; +export const NOKIA_N9_PHYSICAL_VIEWPORT = [854, 480] as const; +export const NOKIA_N9_DEFAULT_VIEWPORT = [854, 480] as const; +export const NOKIA_N9_MIN_VIEWPORT = [480, 480] as const; +export const NOKIA_N9_MAX_VIEWPORT = [854, 854] as const; +export const NOKIA_N9_TICK_HZ = 60; + +export const NOKIA_N9_DEV_CONTRACTS = definePlatformContractRegistry( + POCKET_CAPABILITIES, + defineTargetRegistry({ + [NOKIA_N9_DEV_TARGET_ID]: { + hostAbi: NOKIA_N9_DEV_HOST_ABI, + platform: "harmattan", + form: "window", + display: { + physicalViewport: NOKIA_N9_PHYSICAL_VIEWPORT, + logicalViewports: [NOKIA_N9_DEFAULT_VIEWPORT], + dynamicViewport: { + min: NOKIA_N9_MIN_VIEWPORT, + max: NOKIA_N9_MAX_VIEWPORT, + }, + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: [ + "input.touch", + "display.viewport.live", + "text.glyphs.baked", + ], + }, + }), +); + +export function resolveNokiaN9BuildPlan(input: unknown): ResolvedBuildPlan { + const resolution = validateAndResolveBuildPlan( + input, + { target: NOKIA_N9_DEV_TARGET_ID }, + NOKIA_N9_DEV_CONTRACTS, + ); + if (!resolution.ok) { + throw new Error( + `pocket nokia-n9: manifest did not resolve: ${resolution.diagnostics + .map((diagnostic) => `${diagnostic.path || "/"}: ${diagnostic.message}`) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/nokia-n9-toolchain.ts b/tools/nokia-n9-toolchain.ts new file mode 100644 index 00000000..4b802c4b --- /dev/null +++ b/tools/nokia-n9-toolchain.ts @@ -0,0 +1,143 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; +import manifestJson from "./cli/nokia-n9-toolchain.json"; +import { pocketStackCacheRoot } from "./psp-toolchain.ts"; + +export interface NokiaN9ToolchainManifest { + readonly schemaVersion: 1; + readonly toolchainVersion: string; + readonly container: { + readonly platform: "linux/amd64"; + readonly baseImage: string; + readonly debianSnapshot: string; + readonly debianSecuritySnapshot: string; + readonly image: string; + readonly volume: string; + }; + readonly sdk: { + readonly asset: string; + readonly url: string; + readonly bytes: number; + readonly sha256: string; + readonly installer: string; + readonly installerMd5: string; + readonly installerSha256: string; + }; + readonly quickjs: { + readonly version: string; + readonly repository: string; + readonly rev: string; + readonly asset: string; + readonly url: string; + readonly sha256: string; + }; + readonly downloadsCachePath: string; + readonly receipt: string; + readonly markers: readonly string[]; + readonly device: { + readonly usbVendorId: string; + readonly storageProductId: string; + readonly model: string; + readonly defaultHost: string; + readonly user: string; + }; + readonly runtime: { + readonly rustToolchain: string; + readonly tickHz: 60; + readonly target: "nokia-n9-dev"; + readonly hostAbi: 9; + }; +} + +export const NOKIA_N9_TOOLCHAIN = manifestJson as unknown as NokiaN9ToolchainManifest; + +export function nokiaN9DownloadsRoot(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.POCKETJS_NOKIA_N9_DOWNLOADS?.trim(); + return explicit + ? resolve(explicit) + : join(pocketStackCacheRoot(env), NOKIA_N9_TOOLCHAIN.downloadsCachePath); +} + +export function nokiaN9DownloadPath( + asset: string, + env: NodeJS.ProcessEnv = process.env, +): string { + return join(nokiaN9DownloadsRoot(env), asset); +} + +const IMPLEMENTATION_FILES = [ + "tools/cli/nokia-n9-toolchain.json", + "tools/nokia-n9/Dockerfile", + "tools/nokia-n9/Dockerfile.dockerignore", + "tools/nokia-n9/container/controller.qs", + "tools/nokia-n9/container/pocketjs-nokia-n9-setup", + "tools/nokia-n9/container/pocketjs-nokia-n9-doctor", + "tools/nokia-n9/container/pocketjs-nokia-n9-build-probe", + "tools/nokia-n9/container/pocketjs-nokia-n9-build-app", + "tools/nokia-n9/container/patches/quickjs-harmattan.patch", +] as const; + +export function nokiaN9ImplementationDigest(repository: string): string { + const root = isAbsolute(repository) ? repository : resolve(repository); + const hash = createHash("sha256"); + for (const relative of IMPLEMENTATION_FILES) { + const path = join(root, relative); + if (!existsSync(path)) throw new Error(`missing N9 implementation input ${relative}`); + hash.update(relative); + hash.update("\0"); + hash.update(readFileSync(path)); + hash.update("\0"); + } + return hash.digest("hex"); +} + +export function nokiaN9DockerBuildArguments(repository: string): string[] { + const manifest = NOKIA_N9_TOOLCHAIN; + return [ + "build", + "--platform", + manifest.container.platform, + "--build-arg", + `POCKETJS_NOKIA_N9_BASE_IMAGE=${manifest.container.baseImage}`, + "--build-arg", + `POCKETJS_DEBIAN_SNAPSHOT=${manifest.container.debianSnapshot}`, + "--build-arg", + `POCKETJS_DEBIAN_SECURITY_SNAPSHOT=${manifest.container.debianSecuritySnapshot}`, + "--build-arg", + `POCKETJS_NOKIA_N9_IMPLEMENTATION_SHA256=${nokiaN9ImplementationDigest(repository)}`, + "--build-arg", + `POCKETJS_NOKIA_N9_TOOLCHAIN_VERSION=${manifest.toolchainVersion}`, + "-f", + join(repository, "tools/nokia-n9/Dockerfile"), + "-t", + manifest.container.image, + repository, + ]; +} + +export function nokiaN9DockerRunArguments( + repository: string, + command: string, + extra: readonly string[] = [], +): string[] { + const root = isAbsolute(repository) ? repository : resolve(repository); + return [ + "run", + "--rm", + "--platform", + NOKIA_N9_TOOLCHAIN.container.platform, + "--user", + `${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`, + "--network=none", + "--mount", + `type=volume,src=${NOKIA_N9_TOOLCHAIN.container.volume},dst=/toolchain`, + "--mount", + `type=bind,src=${root},dst=/work,readonly`, + "--mount", + `type=bind,src=${join(root, "dist/nokia-n9")},dst=/out`, + NOKIA_N9_TOOLCHAIN.container.image, + command, + ...extra, + ]; +} diff --git a/tools/nokia-n9.ts b/tools/nokia-n9.ts new file mode 100644 index 00000000..7619053a --- /dev/null +++ b/tools/nokia-n9.ts @@ -0,0 +1,962 @@ +import { createHash } from "node:crypto"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { createCanvas } from "@napi-rs/canvas"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { resolveNokiaN9BuildPlan } from "./nokia-n9-profile.ts"; +import { + NOKIA_N9_TOOLCHAIN, + nokiaN9DockerBuildArguments, + nokiaN9DockerRunArguments, + nokiaN9DownloadPath, + nokiaN9DownloadsRoot, + nokiaN9ImplementationDigest, +} from "./nokia-n9-toolchain.ts"; + +const REPOSITORY = resolve(import.meta.dir, ".."); +const OUTPUT_ROOT = join(REPOSITORY, "dist/nokia-n9"); +const DEFAULT_MANIFEST = join(REPOSITORY, "apps/nokia-n9-demo/pocket.json"); +const KEY_ROOT = join( + process.env.POCKET_STACK_CACHE_DIR ?? join(homedir(), ".cache/pocket-stack"), + "nokia-n9/ssh", +); +const PRIVATE_KEY = join(KEY_ROOT, "id_rsa"); +const KNOWN_HOSTS = join(KEY_ROOT, "known_hosts"); +const PAIRING_RECEIPT = join(KEY_ROOT, "device.json"); +const DEVICE_RUNTIME_RECEIPT = join(KEY_ROOT, "runtime.json"); + +interface CommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +async function run( + command: string, + args: readonly string[], + options: { + cwd?: string; + inherit?: boolean; + env?: Record; + input?: string; + } = {}, +): Promise { + const child = Bun.spawn({ + cmd: [command, ...args], + cwd: options.cwd ?? REPOSITORY, + env: options.env ?? process.env, + stdout: options.inherit ? "inherit" : "pipe", + stderr: options.inherit ? "inherit" : "pipe", + stdin: options.input === undefined ? "ignore" : new Blob([options.input]), + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + options.inherit ? Promise.resolve("") : new Response(child.stdout).text(), + options.inherit ? Promise.resolve("") : new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +async function mustRun( + command: string, + args: readonly string[], + options: { + cwd?: string; + inherit?: boolean; + env?: Record; + input?: string; + } = {}, +): Promise { + const result = await run(command, args, options); + if (result.exitCode !== 0) { + throw new Error( + `pocket nokia-n9: ${command} failed (${result.exitCode})${ + result.stderr.trim() ? `\n${result.stderr.trim()}` : "" + }`, + ); + } + return result.stdout.trim(); +} + +function sha256(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function hashInputs(paths: readonly string[]): string { + const hash = createHash("sha256"); + for (const path of paths) { + hash.update(relative(REPOSITORY, path)); + hash.update("\0"); + hash.update(readFileSync(path)); + hash.update("\0"); + } + return hash.digest("hex").slice(0, 24); +} + +function writeAtomic(path: string, bytes: string | Uint8Array): void { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.tmp-${process.pid}`; + writeFileSync(temporary, bytes); + renameSync(temporary, path); +} + +function flagValue(args: readonly string[], flag: string): string | undefined { + const direct = args.find((arg) => arg.startsWith(`${flag}=`)); + if (direct) return direct.slice(flag.length + 1); + const index = args.indexOf(flag); + return index >= 0 ? args[index + 1] : undefined; +} + +async function ensureDownload( + asset: string, + url: string, + expectedSha256: string, + expectedBytes?: number, +): Promise { + const destination = nokiaN9DownloadPath(asset); + mkdirSync(dirname(destination), { recursive: true }); + if (!existsSync(destination) || + (expectedBytes !== undefined && statSync(destination).size !== expectedBytes) || + sha256(destination) !== expectedSha256) { + rmSync(destination, { force: true }); + await mustRun("curl", [ + "--fail", + "--location", + "--output", + destination, + url, + ], { inherit: true }); + } + if (sha256(destination) !== expectedSha256) { + throw new Error(`pocket nokia-n9: ${asset} failed SHA-256 verification`); + } + if (expectedBytes !== undefined && statSync(destination).size !== expectedBytes) { + throw new Error(`pocket nokia-n9: ${asset} has the wrong byte length`); + } + return destination; +} + +export interface NokiaN9PackageIdentity { + readonly packageName: string; + readonly executable: string; + readonly title: string; + readonly version: string; + readonly output: string; +} + +export function nokiaN9PackageIdentity( + plan: ResolvedBuildPlan, + manifest: { readonly name: string; readonly version: string }, +): NokiaN9PackageIdentity { + const packageName = manifest.name + .toLowerCase() + .replace(/[^a-z0-9+.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!/^[a-z0-9][a-z0-9+.-]{1,62}$/.test(packageName)) { + throw new Error("pocket nokia-n9: manifest name is not a safe Debian package name"); + } + const executable = plan.app.output + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + if (!executable) throw new Error("pocket nokia-n9: app output has no safe executable name"); + if (!/^[0-9][a-zA-Z0-9.+:~_-]*$/.test(manifest.version)) { + throw new Error("pocket nokia-n9: manifest version is not a safe Debian version"); + } + if (!plan.app.title.trim() || /[\r\n\0]/.test(plan.app.title)) { + throw new Error("pocket nokia-n9: app title is not safe for desktop metadata"); + } + return { + packageName, + executable, + title: plan.app.title, + version: manifest.version, + output: plan.app.output, + }; +} + +async function setup(): Promise { + await ensureDownload( + NOKIA_N9_TOOLCHAIN.sdk.asset, + NOKIA_N9_TOOLCHAIN.sdk.url, + NOKIA_N9_TOOLCHAIN.sdk.sha256, + NOKIA_N9_TOOLCHAIN.sdk.bytes, + ); + await ensureDownload( + NOKIA_N9_TOOLCHAIN.quickjs.asset, + NOKIA_N9_TOOLCHAIN.quickjs.url, + NOKIA_N9_TOOLCHAIN.quickjs.sha256, + ); + await mustRun("docker", nokiaN9DockerBuildArguments(REPOSITORY), { inherit: true }); + await mustRun("docker", ["volume", "create", NOKIA_N9_TOOLCHAIN.container.volume]); + await mustRun("docker", [ + "run", + "--rm", + "--platform", + NOKIA_N9_TOOLCHAIN.container.platform, + "--network=none", + "--mount", + `type=volume,src=${NOKIA_N9_TOOLCHAIN.container.volume},dst=/toolchain`, + "--mount", + `type=bind,src=${nokiaN9DownloadsRoot()},dst=/downloads,readonly`, + NOKIA_N9_TOOLCHAIN.container.image, + "pocketjs-nokia-n9-setup", + ], { inherit: true }); + const installed = await mustRun("rustup", ["toolchain", "list"]); + if (!installed.split(/\r?\n/).some((line) => + line.startsWith(NOKIA_N9_TOOLCHAIN.runtime.rustToolchain))) { + await mustRun("rustup", [ + "toolchain", + "install", + NOKIA_N9_TOOLCHAIN.runtime.rustToolchain, + "--profile", + "minimal", + "--component", + "rust-src", + ], { inherit: true }); + } +} + +async function imageReady(): Promise { + const result = await run("docker", [ + "image", + "inspect", + "--format", + '{{index .Config.Labels "org.pocketjs.nokia-n9.toolchain"}} {{index .Config.Labels "org.pocketjs.nokia-n9.implementation"}}', + NOKIA_N9_TOOLCHAIN.container.image, + ]); + return result.exitCode === 0 && result.stdout.trim() === + `${NOKIA_N9_TOOLCHAIN.toolchainVersion} ${nokiaN9ImplementationDigest(REPOSITORY)}`; +} + +function validateDeviceHost(host: string): string { + if (!/^[a-zA-Z0-9.-]+$/.test(host) || host.startsWith("-") || host.includes("..")) { + throw new Error("pocket nokia-n9: --host must be an IPv4 address or DNS name"); + } + return host; +} + +function pairedHost(): string | undefined { + if (!existsSync(PAIRING_RECEIPT)) return undefined; + try { + const value = JSON.parse(readFileSync(PAIRING_RECEIPT, "utf8")) as { + schema?: number; + host?: string; + }; + return value.schema === 1 && value.host ? validateDeviceHost(value.host) : undefined; + } catch { + return undefined; + } +} + +function sshBase(host?: string): string[] { + if (!existsSync(PRIVATE_KEY) || !existsSync(KNOWN_HOSTS)) { + throw new Error("pocket nokia-n9: device key is absent; run `bun nokia-n9 pair --host
`"); + } + const address = validateDeviceHost( + host ?? process.env.POCKETJS_NOKIA_N9_HOST ?? pairedHost() ?? + NOKIA_N9_TOOLCHAIN.device.defaultHost, + ); + return [ + "-i", PRIVATE_KEY, + "-o", `UserKnownHostsFile=${KNOWN_HOSTS}`, + "-o", "StrictHostKeyChecking=yes", + "-o", "IdentitiesOnly=yes", + "-o", "ConnectTimeout=5", + "-o", "ServerAliveInterval=5", + "-o", "HostkeyAlgorithms=+ssh-rsa", + "-o", "PubkeyAcceptedAlgorithms=+ssh-rsa", + `${NOKIA_N9_TOOLCHAIN.device.user}@${address}`, + ]; +} + +function sftpQuote(path: string): string { + if (path.includes("\n") || path.includes("\r")) { + throw new Error("pocket nokia-n9: SFTP path contains a newline"); + } + return `"${path.replace(/["\\]/g, "\\$&")}"`; +} + +async function sftpTransfer(command: "put" | "get", source: string, destination: string): Promise { + await mustRun("sftp", ["-b", "-", ...sshBase()], { + input: `${command} ${sftpQuote(source)} ${sftpQuote(destination)}\n`, + }); +} + +async function doctor(device: boolean): Promise { + let ok = true; + for (const { label, path, expected, bytes } of [ + { + label: "Qt SDK archive", + path: nokiaN9DownloadPath(NOKIA_N9_TOOLCHAIN.sdk.asset), + expected: NOKIA_N9_TOOLCHAIN.sdk.sha256, + bytes: NOKIA_N9_TOOLCHAIN.sdk.bytes, + }, + { + label: "QuickJS source", + path: nokiaN9DownloadPath(NOKIA_N9_TOOLCHAIN.quickjs.asset), + expected: NOKIA_N9_TOOLCHAIN.quickjs.sha256, + bytes: undefined, + }, + ] as const) { + const valid = existsSync(path) && sha256(path) === expected && + (bytes === undefined || statSync(path).size === bytes); + console.log(`${valid ? "✓" : "✗"} ${label}`); + ok = valid && ok; + } + const image = await imageReady(); + console.log(`${image ? "✓" : "✗"} pinned linux/amd64 toolchain image`); + ok = image && ok; + if (image) { + mkdirSync(OUTPUT_ROOT, { recursive: true }); + const checked = await run("docker", nokiaN9DockerRunArguments( + REPOSITORY, + "pocketjs-nokia-n9-doctor", + )); + if (checked.stdout) process.stdout.write(checked.stdout); + if (checked.stderr) process.stderr.write(checked.stderr); + ok = checked.exitCode === 0 && ok; + } + const rust = await run("rustup", [ + "run", + NOKIA_N9_TOOLCHAIN.runtime.rustToolchain, + "rustc", + "--version", + ]); + console.log(`${rust.exitCode === 0 ? "✓" : "✗"} ${NOKIA_N9_TOOLCHAIN.runtime.rustToolchain}`); + ok = rust.exitCode === 0 && ok; + + if (device) { + rmSync(DEVICE_RUNTIME_RECEIPT, { force: true }); + const storage = await run("lsusb", [ + "-d", + `${NOKIA_N9_TOOLCHAIN.device.usbVendorId}:${NOKIA_N9_TOOLCHAIN.device.storageProductId}`, + ]); + if (storage.exitCode === 0 && storage.stdout.includes("N9")) { + console.error("✗ Nokia N9 is in Storage mode; select SDK mode and start SDK Connectivity"); + return false; + } + const remote = await run("ssh", [ + ...sshBase(), + "printf 'model='; hostname; printf 'arch='; uname -m; printf 'kernel='; uname -r; printf 'harmattan='; dpkg-query -W -f='${Version}\\n' meego-nokia-version 2>/dev/null; printf 'qt='; dpkg-query -W -f='${Version}\\n' libqt4-core 2>/dev/null", + ]); + const accepted = remote.exitCode === 0 && + /^model=RM-?696$/im.test(remote.stdout) && + /^arch=armv7/im.test(remote.stdout) && + /^kernel=.*dfl61/im.test(remote.stdout) && + /^harmattan=\S+/im.test(remote.stdout) && + /^qt=\S+/im.test(remote.stdout); + console.log(`${accepted ? "✓" : "✗"} Nokia N9 Harmattan SSH runtime`); + if (accepted) { + const fields = Object.fromEntries(remote.stdout.trim().split(/\r?\n/).map((line) => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1)]; + })); + writeAtomic(DEVICE_RUNTIME_RECEIPT, `${JSON.stringify({ + schema: 1, + model: fields.model, + arch: fields.arch, + kernel: fields.kernel, + harmattan: fields.harmattan, + qt: fields.qt, + }, null, 2)}\n`); + console.log(remote.stdout.trim()); + } + ok = accepted && ok; + } + return ok; +} + +async function pair(host: string): Promise { + const address = validateDeviceHost(host); + mkdirSync(KEY_ROOT, { recursive: true }); + if (!existsSync(PRIVATE_KEY)) { + await mustRun("ssh-keygen", ["-t", "rsa", "-b", "3072", "-N", "", "-f", PRIVATE_KEY]); + chmodSync(PRIVATE_KEY, 0o600); + } + await mustRun("ssh-copy-id", [ + "-i", + `${PRIVATE_KEY}.pub`, + "-o", + `UserKnownHostsFile=${KNOWN_HOSTS}`, + "-o", + "StrictHostKeyChecking=ask", + "-o", + "HostkeyAlgorithms=+ssh-rsa", + "-o", + "PubkeyAcceptedAlgorithms=+ssh-rsa", + `${NOKIA_N9_TOOLCHAIN.device.user}@${address}`, + ], { inherit: true }); + writeAtomic(PAIRING_RECEIPT, `${JSON.stringify({ schema: 1, host: address }, null, 2)}\n`); +} + +async function buildRustCore(payload: string): Promise { + const targetRoot = join(OUTPUT_ROOT, ".cargo-nokia-n9"); + const rustupRoot = resolve(process.env.RUSTUP_HOME ?? join(homedir(), ".rustup")); + const cargoRoot = resolve(process.env.CARGO_HOME ?? join(homedir(), ".cargo")); + if (!existsSync(rustupRoot) || !existsSync(cargoRoot)) { + throw new Error("pocket nokia-n9: pinned Rust or Cargo home is missing; run setup --yes"); + } + const args = nokiaN9DockerRunArguments(REPOSITORY, "/cargo/bin/rustup", [ + "run", NOKIA_N9_TOOLCHAIN.runtime.rustToolchain, "cargo", + "build", + "--release", + "--locked", + "--offline", + "--target", + "targets/armv7-nokia-n9-eabihf.json", + "-Z", + "json-target-spec", + "-Z", + "build-std=core,alloc,compiler_builtins", + "-Z", + "build-std-features=compiler-builtins-mem", + "--features", + "bare-platform", + ]); + args.splice(args.indexOf(NOKIA_N9_TOOLCHAIN.container.image), 0, + "--env", "RUSTUP_HOME=/rustup", + "--env", "CARGO_HOME=/cargo", + "--env", "CARGO_NET_OFFLINE=true", + "--env", "CARGO_TARGET_DIR=/out/.cargo-nokia-n9", + "--env", "CARGO_PROFILE_RELEASE_OPT_LEVEL=2", + "--mount", `type=bind,src=${rustupRoot},dst=/rustup,readonly`, + "--mount", `type=bind,src=${cargoRoot},dst=/cargo,readonly`, + "--workdir", "/work/engine/symbian"); + await mustRun("docker", args, { inherit: true }); + const library = join( + targetRoot, + "armv7-nokia-n9-eabihf/release/libpocketjs_symbian_core.a", + ); + if (!existsSync(library)) throw new Error("pocket nokia-n9: Rust core archive is missing"); + const staged = join(payload, "libpocketjs_symbian_core.a"); + copyFileSync(library, staged); + return staged; +} + +async function buildApp(manifestPath: string): Promise { + if (!await doctor(false)) throw new Error("pocket nokia-n9: toolchain doctor failed"); + const absoluteManifest = resolve(manifestPath); + const manifest = JSON.parse(readFileSync(absoluteManifest, "utf8")); + const plan = resolveNokiaN9BuildPlan(manifest); + const identity = nokiaN9PackageIdentity(plan, manifest); + const payload = join(OUTPUT_ROOT, "build", plan.app.output); + rmSync(payload, { recursive: true, force: true }); + mkdirSync(payload, { recursive: true }); + writeFileSync(join(payload, "plan.json"), `${JSON.stringify(plan, null, 2)}\n`); + writeFileSync(join(payload, "package.json"), `${JSON.stringify(identity, null, 2)}\n`); + + await mustRun("bun", [ + "tools/build.ts", + `--plan=${join(payload, "plan.json")}`, + `--project-root=${REPOSITORY}`, + `--outdir=${payload}`, + ], { inherit: true }); + copyFileSync(join(payload, `${plan.app.output}.js`), join(payload, "app.js")); + copyFileSync(join(payload, `${plan.app.output}.pak`), join(payload, "app.pak")); + const core = await buildRustCore(payload); + + const nativeInputs = [ + join(REPOSITORY, "hosts/symbian/runtime/main.cpp"), + join(REPOSITORY, "hosts/symbian/runtime/pocketjs_symbian_core.h"), + join(REPOSITORY, "hosts/symbian/runtime/pocketjs_symbian_extension.h"), + join(REPOSITORY, "hosts/symbian/runtime/pocketjs_symbian_keys.h"), + join(REPOSITORY, "hosts/nokia-n9/runtime/pocketjs-n9-runtime.pro"), + join(REPOSITORY, "hosts/nokia-n9/runtime/pocketjs-n9.desktop.in"), + join(REPOSITORY, "hosts/nokia-n9/runtime/manifest.aegis"), + join(REPOSITORY, "hosts/symbian/runtime/pocketjs-runtime.qrc"), + join(REPOSITORY, "tools/cli/nokia-n9-toolchain.json"), + join(REPOSITORY, "tools/nokia-n9/container/pocketjs-nokia-n9-build-app"), + join(REPOSITORY, "tools/nokia-n9/container/patches/quickjs-harmattan.patch"), + join(REPOSITORY, "apps/meizu-m8-demo/icon80.png"), + absoluteManifest, + join(payload, "plan.json"), + join(payload, "app.js"), + join(payload, "app.pak"), + core, + ]; + const buildId = hashInputs(nativeInputs); + writeFileSync(join(payload, "build-id.txt"), `${buildId}\n`); + + const args = nokiaN9DockerRunArguments( + REPOSITORY, + "pocketjs-nokia-n9-build-app", + ["/out/build/" + plan.app.output], + ); + args.splice(args.indexOf(NOKIA_N9_TOOLCHAIN.container.image), 0, + "--mount", + `type=bind,src=${nokiaN9DownloadsRoot()},dst=/downloads,readonly`); + await mustRun("docker", args, { inherit: true }); + const output = join( + OUTPUT_ROOT, + `${identity.packageName}_${identity.version}-1_armel.deb`, + ); + if (!existsSync(output)) throw new Error(`pocket nokia-n9: expected package is missing: ${output}`); + return output; +} + +async function buildProbe(): Promise { + if (!await doctor(false)) throw new Error("pocket nokia-n9: toolchain doctor failed"); + mkdirSync(OUTPUT_ROOT, { recursive: true }); + const args = nokiaN9DockerRunArguments(REPOSITORY, "pocketjs-nokia-n9-build-probe"); + await mustRun("docker", args, { inherit: true }); + const output = join(OUTPUT_ROOT, "pocketjs-n9-probe_0.1.0-1_armel.deb"); + if (!existsSync(output)) throw new Error("pocket nokia-n9: probe package is missing"); + return output; +} + +function receiptForDeb(deb: string): { buildId: string; packageName: string; executable: string } { + const receipt = deb.replace(/\.deb$/, ".receipt.json"); + if (!existsSync(receipt)) throw new Error(`pocket nokia-n9: missing receipt ${receipt}`); + return JSON.parse(readFileSync(receipt, "utf8")); +} + +async function deploy(debPath: string): Promise { + const deb = resolve(debPath); + const receipt = receiptForDeb(deb); + const remoteDirectory = `/home/developer/pocketjs-deploy/${receipt.buildId}`; + const remoteDeb = `${remoteDirectory}/${deb.split("/").pop()}`; + await mustRun("ssh", [...sshBase(), `mkdir -p '${remoteDirectory}'`]); + await sftpTransfer("put", deb, remoteDeb); + const remoteHash = await mustRun("ssh", [...sshBase(), `sha256sum '${remoteDeb}' | cut -d' ' -f1`]); + if (remoteHash !== sha256(deb)) throw new Error("pocket nokia-n9: device package readback mismatch"); + console.log("Package copied and verified. Enter the device root password when prompted."); + await mustRun("ssh", ["-tt", ...sshBase(), `devel-su -c \"dpkg -i '${remoteDeb}'\"`], { inherit: true }); + const installed = await mustRun("ssh", [ + ...sshBase(), + `dpkg-query -W -f='\${Version}' '${receipt.packageName}'`, + ]); + console.log(`installed ${receipt.packageName} ${installed}`); +} + +async function latestReceipt(): Promise<{ buildId: string; packageName: string; executable: string }> { + const path = join(OUTPUT_ROOT, "latest.receipt.json"); + if (!existsSync(path)) throw new Error("pocket nokia-n9: no app build receipt; run build app"); + return JSON.parse(readFileSync(path, "utf8")); +} + +async function launch(): Promise { + const receipt = await latestReceipt(); + await mustRun("ssh", [ + ...sshBase(), + `DISPLAY=:0 /usr/bin/invoker --single-instance --type=e /opt/${receipt.packageName}/bin/${receipt.executable} >/tmp/pocketjs-n9-launch.log 2>&1 &`, + ]); + console.log(`launch requested for ${receipt.packageName}`); +} + +export interface NokiaN9DeviceStatus { + readonly schema: number; + readonly build_id: string; + readonly target: string; + readonly host_abi: number; + readonly pid: number; + readonly state: string; + readonly error: string; + readonly heartbeat: number; + readonly guest_frames: number; + readonly presented_frames: number; + readonly logical_width: number; + readonly logical_height: number; + readonly physical_width: number; + readonly physical_height: number; + readonly orientation: "landscape" | "portrait"; + readonly quarter_turns: number; + readonly orientation_transitions: number; + readonly context_generation: number; + readonly display_active: boolean; + readonly tick_hz: number; + readonly renderer: string; + readonly gl_version: string; + readonly gl_vendor: string; + readonly gl_renderer: string; + readonly gl_max_texture_size: number; + readonly completed_touch_sequences: number; + readonly action_name: string; + readonly action_value: number; + readonly action_sequence: number; + readonly timings_us: { + readonly javascript: number; + readonly pending_jobs: number; + readonly core_tick: number; + readonly gl_submit: number; + readonly swap: number; + readonly total: number; + }; + readonly fps_window: { + readonly samples: number; + readonly warmup_remaining: number; + readonly average_hz: number; + readonly p95_ms: number; + readonly max_ms: number; + readonly missed_vblanks: number; + }; +} + +export function parseNokiaN9Status(text: string): NokiaN9DeviceStatus { + const value = JSON.parse(text) as NokiaN9DeviceStatus; + if (value.schema !== 1 || value.target !== "nokia-n9-dev" || value.host_abi !== 9) { + throw new Error("pocket nokia-n9: malformed or incompatible device status"); + } + if (!value.fps_window || !value.timings_us || !Number.isInteger(value.pid)) { + throw new Error("pocket nokia-n9: incomplete device status"); + } + return value; +} + +async function readStatus(buildId: string): Promise { + const path = `/tmp/pocketjs-n9-${buildId}.status.json`; + return parseNokiaN9Status(await mustRun("ssh", [...sshBase(), `cat '${path}'`])); +} + +export function validateNokiaN9Status( + first: NokiaN9DeviceStatus, + current: NokiaN9DeviceStatus, + buildId: string, + requireAction: boolean, +): void { + if (current.build_id !== buildId || current.state !== "running" || current.error) { + throw new Error(`pocket nokia-n9: device state=${current.state} error=${current.error || "none"}`); + } + if (current.heartbeat <= first.heartbeat || current.presented_frames <= first.presented_frames) { + throw new Error("pocket nokia-n9: device frame heartbeat did not advance"); + } + if (current.tick_hz !== 60 || current.renderer !== "gles2") { + throw new Error("pocket nokia-n9: device is not running the 60 Hz GLES2 path"); + } + const landscape = current.orientation === "landscape" && + current.logical_width === 854 && current.logical_height === 480; + const portrait = current.orientation === "portrait" && + current.logical_width === 480 && current.logical_height === 854; + if ((!landscape && !portrait) || current.physical_width !== 854 || + current.physical_height !== 480 || !current.display_active) { + throw new Error("pocket nokia-n9: device viewport or visibility is invalid"); + } + if (requireAction && ( + current.completed_touch_sequences < 1 || + current.action_name !== "hero_tap" || + current.action_value < 1 || + current.action_sequence < 1 + )) throw new Error("pocket nokia-n9: no completed Hero touch/action receipt yet"); +} + +async function status(requireAction: boolean): Promise { + const receipt = await latestReceipt(); + const first = await readStatus(receipt.buildId); + await Bun.sleep(1200); + const current = await readStatus(receipt.buildId); + validateNokiaN9Status(first, current, receipt.buildId, requireAction); + console.log(JSON.stringify(current, null, 2)); + return current; +} + +export function normalizeNokiaN9Frame( + raw: Uint8Array, + width: number, + height: number, + quarterTurns: number, +): { width: number; height: number; rgba: Uint8ClampedArray } { + if (raw.byteLength !== width * height * 4) throw new Error("N9 RGBA frame has the wrong byte length"); + const turns = quarterTurns & 3; + const outputWidth = turns & 1 ? height : width; + const outputHeight = turns & 1 ? width : height; + const rgba = new Uint8ClampedArray(outputWidth * outputHeight * 4); + const sourceAt = (physicalX: number, physicalYTop: number): number => + ((height - 1 - physicalYTop) * width + physicalX) * 4; + for (let y = 0; y < outputHeight; y += 1) { + for (let x = 0; x < outputWidth; x += 1) { + let physicalX = x; + let physicalY = y; + if (turns === 1) { + physicalX = width - 1 - y; + physicalY = x; + } else if (turns === 2) { + physicalX = width - 1 - x; + physicalY = height - 1 - y; + } else if (turns === 3) { + physicalX = y; + physicalY = height - 1 - x; + } + const source = sourceAt(physicalX, physicalY); + const target = (y * outputWidth + x) * 4; + rgba[target] = raw[source]; + rgba[target + 1] = raw[source + 1]; + rgba[target + 2] = raw[source + 2]; + rgba[target + 3] = raw[source + 3]; + } + } + return { width: outputWidth, height: outputHeight, rgba }; +} + +export function mapNokiaN9Touch( + physicalX: number, + physicalY: number, + logicalWidth: number, + logicalHeight: number, + quarterTurns: number, +): { x: number; y: number } { + const turns = quarterTurns & 3; + if (turns === 1) { + return { x: physicalY, y: logicalHeight - 1 - physicalX }; + } + if (turns === 2) { + return { + x: logicalWidth - 1 - physicalX, + y: logicalHeight - 1 - physicalY, + }; + } + if (turns === 3) { + return { x: logicalWidth - 1 - physicalY, y: physicalX }; + } + return { x: physicalX, y: physicalY }; +} + +interface NokiaN9RuntimeReceipt { + readonly schema: 1; + readonly model: string; + readonly arch: string; + readonly kernel: string; + readonly harmattan: string; + readonly qt: string; +} + +export interface NokiaN9CaptureReceipt { + readonly schema: 1; + readonly build_id: string; + readonly orientation: "landscape" | "portrait"; + readonly sequence: number; + readonly width: number; + readonly height: number; + readonly quarter_turns: number; + readonly guest_frame: number; + readonly gl_version: string; + readonly gl_vendor: string; + readonly gl_renderer: string; + readonly raw_sha256: string; + readonly device_fingerprint: string; + readonly device: NokiaN9RuntimeReceipt; +} + +function nokiaN9RuntimeReceipt(): NokiaN9RuntimeReceipt { + if (!existsSync(DEVICE_RUNTIME_RECEIPT)) { + throw new Error("pocket nokia-n9: device receipt is missing; run doctor --device"); + } + const value = JSON.parse(readFileSync(DEVICE_RUNTIME_RECEIPT, "utf8")) as NokiaN9RuntimeReceipt; + if (value.schema !== 1 || !/^RM-?696$/i.test(value.model) || + !/^armv7/i.test(value.arch) || !/dfl61/i.test(value.kernel) || + !value.harmattan || !value.qt) { + throw new Error("pocket nokia-n9: device receipt is incompatible"); + } + return value; +} + +export function validateNokiaN9CaptureReceipts( + captures: readonly NokiaN9CaptureReceipt[], + buildId: string, +): string { + const groups = new Map(); + for (const capture of captures) { + if (capture.schema !== 1 || capture.build_id !== buildId || + !/^[a-f0-9]{64}$/.test(capture.raw_sha256)) continue; + const group = groups.get(capture.device_fingerprint) ?? []; + group.push(capture); + groups.set(capture.device_fingerprint, group); + } + for (const [fingerprint, group] of groups) { + let complete = true; + for (const orientation of ["landscape", "portrait"] as const) { + const latest = group + .filter((capture) => capture.orientation === orientation) + .sort((left, right) => left.sequence - right.sequence) + .slice(-3); + const expectedTurns = orientation === "landscape" ? 0 : 3; + if (latest.length !== 3 || new Set(latest.map((capture) => capture.raw_sha256)).size !== 1 || + latest.some((capture) => capture.width !== 854 || capture.height !== 480 || + capture.quarter_turns !== expectedTurns || capture.guest_frame % 24 !== 0)) { + complete = false; + break; + } + } + if (complete) return fingerprint; + } + throw new Error( + "pocket nokia-n9: capture three byte-identical aligned frames in each orientation", + ); +} + +async function capture(): Promise { + const receipt = await latestReceipt(); + const prefix = `/tmp/pocketjs-n9-${receipt.buildId}`; + await mustRun("ssh", [...sshBase(), `rm -f '${prefix}.frame.rgba' '${prefix}.frame.json'; touch '${prefix}.capture-request'`]); + let metadata = ""; + for (let attempt = 0; attempt < 50; attempt += 1) { + await Bun.sleep(100); + const result = await run("ssh", [...sshBase(), `cat '${prefix}.frame.json' 2>/dev/null`]); + if (result.exitCode === 0 && result.stdout.trim()) { + metadata = result.stdout.trim(); + break; + } + } + if (!metadata) throw new Error("pocket nokia-n9: capture timed out"); + const info = JSON.parse(metadata) as { + build_id: string; + width: number; + height: number; + quarter_turns: number; + guest_frame: number; + gl_version: string; + gl_vendor: string; + gl_renderer: string; + }; + if (info.build_id !== receipt.buildId || info.width !== 854 || info.height !== 480 || + ![0, 3].includes(info.quarter_turns) || info.guest_frame % 24 !== 0 || + !info.gl_version || !info.gl_vendor || !info.gl_renderer) { + throw new Error("pocket nokia-n9: stale or malformed capture sidecar"); + } + const device = nokiaN9RuntimeReceipt(); + const deviceFingerprint = createHash("sha256").update(JSON.stringify([ + device.model, + device.kernel, + device.harmattan, + device.qt, + info.gl_version, + info.gl_vendor, + info.gl_renderer, + ])).digest("hex").slice(0, 16); + const firmware = device.harmattan.replace(/[^a-zA-Z0-9._-]+/g, "-"); + const captureRoot = join( + OUTPUT_ROOT, + "captures", + receipt.buildId, + `${firmware}-${deviceFingerprint}`, + ); + mkdirSync(captureRoot, { recursive: true }); + const orientation = info.quarter_turns === 0 ? "landscape" : "portrait"; + const sequence = readdirSync(captureRoot) + .map((name) => new RegExp(`^${orientation}-(\\d{3})\\.json$`).exec(name)) + .filter((match): match is RegExpExecArray => match !== null) + .reduce((maximum, match) => Math.max(maximum, Number(match[1])), 0) + 1; + const base = `${orientation}-${String(sequence).padStart(3, "0")}`; + const rawPath = join(captureRoot, `${base}.rgba`); + const rawTemporary = `${rawPath}.tmp-${process.pid}`; + await sftpTransfer("get", `${prefix}.frame.rgba`, rawTemporary); + if (statSync(rawTemporary).size !== info.width * info.height * 4) { + rmSync(rawTemporary, { force: true }); + throw new Error("pocket nokia-n9: capture byte length does not match its sidecar"); + } + renameSync(rawTemporary, rawPath); + const raw = readFileSync(rawPath); + const localReceipt: NokiaN9CaptureReceipt = { + schema: 1, + ...info, + orientation, + sequence, + raw_sha256: createHash("sha256").update(raw).digest("hex"), + device_fingerprint: deviceFingerprint, + device, + }; + writeAtomic(join(captureRoot, `${base}.json`), `${JSON.stringify(localReceipt, null, 2)}\n`); + const normalized = normalizeNokiaN9Frame(raw, info.width, info.height, info.quarter_turns); + const canvas = createCanvas(normalized.width, normalized.height); + const image = canvas.getContext("2d").createImageData(normalized.width, normalized.height); + image.data.set(normalized.rgba); + canvas.getContext("2d").putImageData(image, 0, 0); + const output = join(captureRoot, `${base}.png`); + writeAtomic(output, canvas.toBuffer("image/png")); + console.log(output); + return output; +} + +async function accept(): Promise { + const receipt = await latestReceipt(); + const current = await status(true); + if (current.orientation_transitions < 2) throw new Error("pocket nokia-n9: rotate landscape → portrait → landscape before acceptance"); + if (current.action_value < 2) throw new Error("pocket nokia-n9: tap Hero in both orientations before acceptance"); + const fps = current.fps_window; + if (fps.samples < 600 || fps.average_hz < 59 || fps.average_hz > 61 || + fps.p95_ms > 17.5 || fps.max_ms >= 25 || fps.missed_vblanks !== 0) { + throw new Error(`pocket nokia-n9: 60 Hz acceptance failed: ${JSON.stringify(fps)}`); + } + const captureRoot = join(OUTPUT_ROOT, "captures", receipt.buildId); + const captures: NokiaN9CaptureReceipt[] = []; + if (existsSync(captureRoot)) { + for (const directory of readdirSync(captureRoot, { withFileTypes: true })) { + if (!directory.isDirectory()) continue; + const root = join(captureRoot, directory.name); + for (const name of readdirSync(root).filter((entry) => entry.endsWith(".json"))) { + captures.push(JSON.parse(readFileSync(join(root, name), "utf8"))); + } + } + } + const fingerprint = validateNokiaN9CaptureReceipts(captures, receipt.buildId); + console.log(`PocketJS Nokia N9 automated hardware gates passed (${fingerprint})`); + console.log("Complete the documented 20-rotation, resume, edge-swipe, and visual-review checklist."); +} + +const HELP = `PocketJS Nokia N9 / Harmattan toolchain + + bun nokia-n9 setup --yes + bun nokia-n9 doctor [--device] + bun nokia-n9 pair --host
+ bun nokia-n9 build probe + bun nokia-n9 build app [--manifest ] + bun nokia-n9 deploy + bun nokia-n9 launch + bun nokia-n9 status [--require-action] + bun nokia-n9 capture + bun nokia-n9 accept`; + +export async function nokiaN9Main(args: readonly string[] = Bun.argv.slice(2)): Promise { + const command = args[0] ?? "help"; + if (command === "setup") { + if (!args.includes("--yes")) throw new Error("setup downloads the archived Qt SDK; re-run with --yes"); + await setup(); + } else if (command === "doctor") { + if (!await doctor(args.includes("--device"))) process.exitCode = 1; + } else if (command === "pair") { + const host = flagValue(args, "--host"); + if (!host) throw new Error("usage: bun nokia-n9 pair --host
"); + await pair(host); + } else if (command === "build" && args[1] === "probe") { + console.log(await buildProbe()); + } else if (command === "build" && args[1] === "app") { + console.log(await buildApp(flagValue(args, "--manifest") ?? DEFAULT_MANIFEST)); + } else if (command === "deploy") { + if (!args[1]) throw new Error("usage: bun nokia-n9 deploy "); + await deploy(args[1]); + } else if (command === "launch") { + await launch(); + } else if (command === "status") { + await status(args.includes("--require-action")); + } else if (command === "capture") { + await capture(); + } else if (command === "accept") { + await accept(); + } else if (["help", "--help", "-h"].includes(command)) { + console.log(HELP); + } else { + console.error(HELP); + throw new Error(`pocket nokia-n9: unknown command ${command}`); + } +} + +if (import.meta.main) { + try { + await nokiaN9Main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/tools/nokia-n9/Dockerfile b/tools/nokia-n9/Dockerfile new file mode 100644 index 00000000..e9e573ae --- /dev/null +++ b/tools/nokia-n9/Dockerfile @@ -0,0 +1,67 @@ +ARG POCKETJS_NOKIA_N9_BASE_IMAGE=debian:bullseye-slim@sha256:de70627667ac77b32ab6858f1acddfb04a4ff3acc1095ac17dbc19fe5725bcb6 +FROM ${POCKETJS_NOKIA_N9_BASE_IMAGE} + +ARG POCKETJS_DEBIAN_SNAPSHOT +ARG POCKETJS_DEBIAN_SECURITY_SNAPSHOT +ENV DEBIAN_FRONTEND=noninteractive + +RUN rm -f /etc/apt/sources.list.d/* \ + && { \ + echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${POCKETJS_DEBIAN_SNAPSHOT}/ bullseye main"; \ + echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${POCKETJS_DEBIAN_SECURITY_SNAPSHOT}/ bullseye-security main"; \ + } > /etc/apt/sources.list \ + && dpkg --add-architecture i386 \ + && apt-get -o Acquire::Retries=5 update \ + && apt-get -o Acquire::Retries=5 install -y --no-install-recommends \ + binutils \ + ca-certificates \ + clang \ + file \ + g++ \ + gcc \ + jq \ + libc6:i386 \ + libfontconfig1 \ + libfreetype6 \ + libglib2.0-0 \ + libice6 \ + libdigest-sha-perl \ + libfile-copy-recursive-perl \ + libtext-template-perl \ + libtimedate-perl \ + libxml-simple-perl \ + libsm6 \ + libstdc++6:i386 \ + libx11-6 \ + libxext6 \ + libxrender1 \ + make \ + openssh-client \ + p7zip-full \ + patch \ + perl \ + unzip \ + xauth \ + xvfb \ + && rm -rf /var/lib/apt/lists/* + +ARG POCKETJS_NOKIA_N9_IMPLEMENTATION_SHA256 +ARG POCKETJS_NOKIA_N9_TOOLCHAIN_VERSION +ENV POCKETJS_NOKIA_N9_IMAGE_IMPLEMENTATION_SHA256="${POCKETJS_NOKIA_N9_IMPLEMENTATION_SHA256}" + +LABEL org.pocketjs.nokia-n9.toolchain="${POCKETJS_NOKIA_N9_TOOLCHAIN_VERSION}" \ + org.pocketjs.nokia-n9.implementation="${POCKETJS_NOKIA_N9_IMPLEMENTATION_SHA256}" + +COPY tools/cli/nokia-n9-toolchain.json /opt/pocketjs/nokia-n9-toolchain.json +COPY tools/nokia-n9/container /opt/pocketjs/container +RUN chmod 0755 \ + /opt/pocketjs/container/pocketjs-nokia-n9-setup \ + /opt/pocketjs/container/pocketjs-nokia-n9-doctor \ + /opt/pocketjs/container/pocketjs-nokia-n9-build-probe \ + /opt/pocketjs/container/pocketjs-nokia-n9-build-app \ + && ln -s /opt/pocketjs/container/pocketjs-nokia-n9-setup /usr/local/bin/ \ + && ln -s /opt/pocketjs/container/pocketjs-nokia-n9-doctor /usr/local/bin/ \ + && ln -s /opt/pocketjs/container/pocketjs-nokia-n9-build-probe /usr/local/bin/ \ + && ln -s /opt/pocketjs/container/pocketjs-nokia-n9-build-app /usr/local/bin/ + +WORKDIR /work diff --git a/tools/nokia-n9/Dockerfile.dockerignore b/tools/nokia-n9/Dockerfile.dockerignore new file mode 100644 index 00000000..add1619c --- /dev/null +++ b/tools/nokia-n9/Dockerfile.dockerignore @@ -0,0 +1,6 @@ +.git +.pocket-build +dist +node_modules +target +**/target diff --git a/tools/nokia-n9/container/controller.qs b/tools/nokia-n9/container/controller.qs new file mode 100644 index 00000000..011595ce --- /dev/null +++ b/tools/nokia-n9/container/controller.qs @@ -0,0 +1,57 @@ +function Controller() { + installer.setMessageBoxAutomaticAnswer("installationErrorWithRetry", QMessageBox.Ignore); +} + +Controller.prototype.IntroductionPageCallback = function() { + gui.clickButton(buttons.NextButton); +}; + +Controller.prototype.TargetDirectoryPageCallback = function() { + gui.currentPageWidget().TargetDirectoryLineEdit.setText("/toolchain/QtSDK"); + gui.clickButton(buttons.NextButton); +}; + +Controller.prototype.PackageManagerSelectedCallback = function() { + var page = gui.pageWidgetByObjectName("DynamicInstallationKindWidget"); + if (page == null) page = gui.pageWidgetByObjectName("InstallationKindWidget"); + if (page != null && page.CustomRadioButton != null) { + page.CustomRadioButton.setChecked(true); + gui.clickButton(buttons.NextButton); + } +}; + +Controller.prototype.ComponentSelectionPageCallback = function() { + var page = gui.currentPageWidget(); + page.deselectAll(); + var components = [ + "com.nokia.ndk.tools.madde.application", + "com.nokia.ndk.tools.madde.qttools.474", + "com.nokia.ndk.tools.madde.toolchains.2009q367", + "com.nokia.ndk.tools.harmattan.sysroot", + "com.nokia.ndk.tools.harmattan" + ]; + for (var index = 0; index < components.length; ++index) { + installer.selectComponent(components[index]); + } + gui.clickButton(buttons.NextButton); +}; + +Controller.prototype.LicenseAgreementPageCallback = function() { + var page = gui.currentPageWidget(); + if (page.AcceptLicenseRadioButton) page.AcceptLicenseRadioButton.setChecked(true); + gui.clickButton(buttons.NextButton); +}; + +Controller.prototype.StartMenuDirectoryPageCallback = function() { + gui.clickButton(buttons.NextButton); +}; + +Controller.prototype.ReadyForInstallationPageCallback = function() { + gui.clickButton(buttons.CommitButton); +}; + +Controller.prototype.FinishedPageCallback = function() { + var page = gui.currentPageWidget(); + if (page.RunItCheckBox) page.RunItCheckBox.setChecked(false); + gui.clickButton(buttons.FinishButton); +}; diff --git a/tools/nokia-n9/container/patches/quickjs-harmattan.patch b/tools/nokia-n9/container/patches/quickjs-harmattan.patch new file mode 100644 index 00000000..ec6581bb --- /dev/null +++ b/tools/nokia-n9/container/patches/quickjs-harmattan.patch @@ -0,0 +1,21 @@ +diff --git a/libquickjs-sys/embed/quickjs/quickjs.c b/libquickjs-sys/embed/quickjs/quickjs.c +--- a/libquickjs-sys/embed/quickjs/quickjs.c ++++ b/libquickjs-sys/embed/quickjs/quickjs.c +@@ -100,3 +100,3 @@ +-#if defined(__PSP__) || defined(__vita__) ++#if defined(__PSP__) || defined(__vita__) || defined(__HARMATTAN__) + #undef CONFIG_ATOMICS + #endif +diff --git a/libquickjs-sys/embed/quickjs/quickjs.h b/libquickjs-sys/embed/quickjs/quickjs.h +--- a/libquickjs-sys/embed/quickjs/quickjs.h ++++ b/libquickjs-sys/embed/quickjs/quickjs.h +@@ -1065,7 +1065,8 @@ static inline JSValue JS_NewCFunctionMagic(JSContext *ctx, JSCFunctionMagic *fun + int length, JSCFunctionEnum cproto, int magic) + { + /* Used to squelch a -Wcast-function-type warning. */ +- JSCFunctionType ft = { .generic_magic = func }; ++ JSCFunctionType ft; ++ ft.generic_magic = func; + return JS_NewCFunction2(ctx, ft.generic, name, length, cproto, magic); + } + int JS_SetConstructor(JSContext *ctx, JSValueConst func_obj, diff --git a/tools/nokia-n9/container/pocketjs-nokia-n9-build-app b/tools/nokia-n9/container/pocketjs-nokia-n9-build-app new file mode 100644 index 00000000..04339c57 --- /dev/null +++ b/tools/nokia-n9/container/pocketjs-nokia-n9-build-app @@ -0,0 +1,168 @@ +#!/bin/bash +set -euo pipefail + +payload=${1:?usage: pocketjs-nokia-n9-build-app /out/build/app-output} +test -d "$payload" +for file in app.js app.pak plan.json package.json build-id.txt libpocketjs_symbian_core.a; do + test -s "$payload/$file" +done + +manifest=/opt/pocketjs/nokia-n9-toolchain.json +sdk=/toolchain/QtSDK +mad="$sdk/Madde/bin/mad" +test -x "$mad" +target=$(find "$sdk/Madde/targets" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | grep '^harmattan_' | sort | tail -n1) +test -n "$target" +sysroot="$sdk/Madde/sysroots/harmattan_sysroot_10.2011.34-1_slim" +test -d "$sysroot" + +package=$(jq -r .packageName "$payload/package.json") +executable=$(jq -r .executable "$payload/package.json") +title=$(jq -r .title "$payload/package.json") +version=$(jq -r .version "$payload/package.json") +output=$(jq -r .output "$payload/package.json") +build_id=$(tr -d '\r\n' < "$payload/build-id.txt") +test "$build_id" != "" + +build=$(mktemp -d /tmp/pocketjs-n9-app.XXXXXX) +quickjs_root="$build/quickjs-rs" +quickjs_objects="$build/quickjs-objects" +cleanup() { rm -rf "$build"; } +trap cleanup EXIT +mkdir -p "$quickjs_root" "$quickjs_objects" + +quickjs_asset="/downloads/$(jq -r .quickjs.asset "$manifest")" +echo "$(jq -r .quickjs.sha256 "$manifest") $quickjs_asset" | sha256sum --check --status +tar -xzf "$quickjs_asset" --strip-components=1 -C "$quickjs_root" +patch --batch --forward -d "$quickjs_root" -p1 \ + < /opt/pocketjs/container/patches/quickjs-harmattan.patch +quickjs_source="$quickjs_root/libquickjs-sys/embed/quickjs" + +common_flags=( + --target=armv7-unknown-linux-gnueabihf + --sysroot="$sysroot" + -march=armv7-a + -mfpu=neon + -mfloat-abi=hard + -O2 + -funsigned-char + -fwrapv + -fno-strict-aliasing + -D_GNU_SOURCE + -D__HARMATTAN__ + -I"$quickjs_source" + "-DCONFIG_VERSION=\"$(jq -r .quickjs.version "$manifest")\"" +) +for source in cutils.c dtoa.c libregexp.c libunicode.c quickjs.c; do + clang "${common_flags[@]}" -std=gnu11 -c "$quickjs_source/$source" \ + -o "$quickjs_objects/${source%.c}.o" +done +clang "${common_flags[@]}" -std=gnu11 \ + -c "$quickjs_root/libquickjs-sys/embed/static-functions.c" \ + -o "$quickjs_objects/static-functions.o" +ar rcs "$build/libquickjs.a" "$quickjs_objects"/*.o + +cp /work/hosts/symbian/runtime/main.cpp "$build/main.cpp" +cp /work/hosts/symbian/runtime/pocketjs_symbian_core.h "$build/" +cp /work/hosts/symbian/runtime/pocketjs_symbian_extension.h "$build/" +cp /work/hosts/symbian/runtime/pocketjs_symbian_keys.h "$build/" +cp /work/hosts/symbian/runtime/pocketjs-runtime.qrc "$build/" +cp /work/hosts/nokia-n9/runtime/pocketjs-n9-runtime.pro "$build/" +printf '#ifndef POCKETJS_N9_BUILD_H\n#define POCKETJS_N9_BUILD_H\n#define POCKETJS_BUILD_ID "%s"\n#endif\n' \ + "$build_id" > "$build/pocketjs_n9_build.h" +cp "$payload/app.js" "$payload/app.pak" "$build/" +: > "$build/catalog.tsv" +: > "$build/catalog.bin" +cp "$payload/libpocketjs_symbian_core.a" "$build/" +cp /work/apps/meizu-m8-demo/icon80.png "$build/$package.png" +desktop_title=$(printf '%s' "$title" | sed 's/[&|\\]/\\&/g') +sed -e "s|@TITLE@|$desktop_title|g" \ + -e "s|@PACKAGE@|$package|g" \ + -e "s|@EXECUTABLE@|$executable|g" \ + /work/hosts/nokia-n9/runtime/pocketjs-n9.desktop.in \ + > "$build/${package}_harmattan.desktop" + +cd "$build" +pocketjs_n9_build_home=/tmp/pocketjs-n9-home +mkdir -p "$pocketjs_n9_build_home" +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" qmake \ + "POCKETJS_N9_TARGET=$executable" \ + "POCKETJS_N9_PACKAGE=$package" \ + "POCKETJS_QUICKJS_INCLUDE=$quickjs_source" \ + "POCKETJS_QUICKJS_LIBRARY=$build/libquickjs.a" \ + "POCKETJS_CORE_LIBRARY=$build/libpocketjs_symbian_core.a" \ + pocketjs-n9-runtime.pro +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" make -j2 + +binary="$build/$executable" +test -x "$binary" +file "$binary" | grep -q 'ELF 32-bit LSB.*ARM' +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" readelf -A "$binary" > "$build/attributes.txt" +if ! grep -q 'Tag_ABI_VFP_args: VFP registers' "$build/attributes.txt"; then + echo "N9 binary does not match Harmattan's VFP-argument ABI" >&2 + exit 1 +fi +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" readelf -l "$binary" > "$build/program-headers.txt" +grep -q 'Requesting program interpreter: /lib/ld-linux.so.3' "$build/program-headers.txt" +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" readelf -d "$binary" > "$build/dynamic.txt" +sed -n 's/.*Shared library: \[\([^]]*\)\].*/\1/p' "$build/dynamic.txt" \ + > "$build/needed.txt" +if grep -Ev \ + '^lib(QtCore|QtGui|QtOpenGL|QtDBus|meegotouchcore|meegotouchsettings|meegotouchextensions|GLESv2|EGL|stdc\+\+|m|gcc_s|c|pthread|dl|rt)\.so(\.[0-9]+)*$' \ + "$build/needed.txt"; then + echo "N9 binary has an unexpected dynamic dependency" >&2 + exit 1 +fi +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" readelf --version-info "$binary" \ + | sed -n 's/.*Name: \(GLIBC_[0-9.]*\).*/\1/p' | sort -u \ + > "$build/required-glibc.txt" +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" readelf --version-info "$sysroot/lib/libc.so.6" \ + | sed -n 's/.*Name: \(GLIBC_[0-9.]*\).*/\1/p' | sort -u \ + > "$build/available-glibc.txt" +if test -s "$build/required-glibc.txt" \ + && test -n "$(comm -23 "$build/required-glibc.txt" "$build/available-glibc.txt")"; then + echo "N9 binary requires GLIBC versions absent from the Harmattan sysroot" >&2 + comm -23 "$build/required-glibc.txt" "$build/available-glibc.txt" >&2 + exit 1 +fi + +stage="$build/debian/tmp" +mkdir -p "$stage/DEBIAN" +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" make INSTALL_ROOT="$stage" install +cat > "$stage/DEBIAN/control" < +Depends: applauncherd, libqt4-core, libqt4-gui, libqt4-opengl, libmeegotouchcore0, libmeegotouchsettings0, libmeegotouchextensions0 +Description: $title + PocketJS Hero for the Nokia N9 running MeeGo 1.2 Harmattan. +EOF +cp "$stage/DEBIAN/control" "$build/debian/control" +cp /work/hosts/nokia-n9/runtime/manifest.aegis "$build/debian/manifest.aegis" +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" dpkg-deb --build "$stage" "$build" +deb="$build/${package}_${version}-1_armel.deb" +test -s "$deb" +destination="/out/${package}_${version}-1_armel.deb" +cp "$deb" "$destination" +deb_sha=$(sha256sum "$destination" | cut -d' ' -f1) +core_sha=$(sha256sum "$payload/libpocketjs_symbian_core.a" | cut -d' ' -f1) +js_sha=$(sha256sum "$payload/app.js" | cut -d' ' -f1) +pak_sha=$(sha256sum "$payload/app.pak" | cut -d' ' -f1) +jq -n \ + --arg buildId "$build_id" \ + --arg packageName "$package" \ + --arg executable "$executable" \ + --arg output "$output" \ + --arg version "$version" \ + --arg debSha256 "$deb_sha" \ + --arg coreSha256 "$core_sha" \ + --arg jsSha256 "$js_sha" \ + --arg pakSha256 "$pak_sha" \ + --arg quickjsRevision "$(jq -r .quickjs.rev "$manifest")" \ + '{schema:1,buildId:$buildId,target:"nokia-n9-dev",hostAbi:9,tickHz:60,packageName:$packageName,executable:$executable,output:$output,version:$version,debSha256:$debSha256,coreSha256:$coreSha256,jsSha256:$jsSha256,pakSha256:$pakSha256,quickjsRevision:$quickjsRevision}' \ + > "/out/${package}_${version}-1_armel.receipt.json" +cp "/out/${package}_${version}-1_armel.receipt.json" /out/latest.receipt.json +printf '%s\n' "$destination" diff --git a/tools/nokia-n9/container/pocketjs-nokia-n9-build-probe b/tools/nokia-n9/container/pocketjs-nokia-n9-build-probe new file mode 100644 index 00000000..2d2726ce --- /dev/null +++ b/tools/nokia-n9/container/pocketjs-nokia-n9-build-probe @@ -0,0 +1,46 @@ +#!/bin/bash +set -euo pipefail + +sdk=/toolchain/QtSDK +mad="$sdk/Madde/bin/mad" +test -x "$mad" +target=$(find "$sdk/Madde/targets" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | grep '^harmattan_' | sort | tail -n1) +test -n "$target" +build=$(mktemp -d /tmp/pocketjs-n9-probe.XXXXXX) +trap 'rm -rf "$build"' EXIT +mkdir -p "$build/debian" +cp /work/hosts/nokia-n9/probe/main.cpp "$build/" +cp /work/hosts/nokia-n9/probe/pocketjs-n9-probe.pro "$build/" +cp /work/hosts/nokia-n9/probe/pocketjs-n9-probe_harmattan.desktop "$build/" +cp /work/apps/meizu-m8-demo/icon80.png "$build/pocketjs-n9-probe.png" +cd "$build" +pocketjs_n9_build_home=/tmp/pocketjs-n9-home +mkdir -p "$pocketjs_n9_build_home" +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" qmake pocketjs-n9-probe.pro +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" make -j2 +binary="$build/pocketjs-n9-probe" +file "$binary" | grep -q 'ELF 32-bit LSB.*ARM' +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" readelf -A "$binary" \ + | grep -q 'Tag_ABI_VFP_args: VFP registers' +stage="$build/debian/tmp" +mkdir -p "$stage/DEBIAN" +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" make INSTALL_ROOT="$stage" install +cat > "$stage/DEBIAN/control" <<'EOF' +Package: pocketjs-n9-probe +Version: 0.1.0-1 +Section: user/development +Priority: optional +Architecture: armel +Maintainer: PocketJS +Depends: applauncherd, libqt4-core, libqt4-gui, libqt4-opengl, libmeegotouchcore0, libmeegotouchsettings0, libmeegotouchextensions0 +Description: PocketJS Nokia N9 GLES2 and orientation probe + Visible native probe for the private Nokia N9 port. +EOF +cp "$stage/DEBIAN/control" "$build/debian/control" +cp /work/hosts/nokia-n9/runtime/manifest.aegis "$build/debian/manifest.aegis" +env HOME="$pocketjs_n9_build_home" "$mad" -t "$target" dpkg-deb --build "$stage" "$build" +deb="$build/pocketjs-n9-probe_0.1.0-1_armel.deb" +test -s "$deb" +cp "$deb" /out/pocketjs-n9-probe_0.1.0-1_armel.deb +sha256sum /out/pocketjs-n9-probe_0.1.0-1_armel.deb \ + > /out/pocketjs-n9-probe_0.1.0-1_armel.sha256 diff --git a/tools/nokia-n9/container/pocketjs-nokia-n9-doctor b/tools/nokia-n9/container/pocketjs-nokia-n9-doctor new file mode 100644 index 00000000..2b26bd16 --- /dev/null +++ b/tools/nokia-n9/container/pocketjs-nokia-n9-doctor @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +manifest=/opt/pocketjs/nokia-n9-toolchain.json +root=/toolchain/QtSDK +receipt="$root/$(jq -r .receipt "$manifest")" +test -f "$receipt" +test "$(jq -r .toolchainVersion "$receipt")" = "$(jq -r .toolchainVersion "$manifest")" +test "$(jq -r .implementationSha256 "$receipt")" = "${POCKETJS_NOKIA_N9_IMAGE_IMPLEMENTATION_SHA256:?}" +jq -r '.markers[]' "$manifest" | while IFS= read -r marker; do + test -e "$root/$marker" +done +"$root/Madde/bin/mad" list >/dev/null +printf '%s\n' "PocketJS Nokia N9 toolchain: ok" diff --git a/tools/nokia-n9/container/pocketjs-nokia-n9-setup b/tools/nokia-n9/container/pocketjs-nokia-n9-setup new file mode 100644 index 00000000..947f9546 --- /dev/null +++ b/tools/nokia-n9/container/pocketjs-nokia-n9-setup @@ -0,0 +1,51 @@ +#!/bin/sh +set -eu + +manifest=/opt/pocketjs/nokia-n9-toolchain.json +archive=/downloads/$(jq -r .sdk.asset "$manifest") +archive_sha=$(jq -r .sdk.sha256 "$manifest") +installer_name=$(jq -r .sdk.installer "$manifest") +installer_sha=$(jq -r .sdk.installerSha256 "$manifest") +installer_md5=$(jq -r .sdk.installerMd5 "$manifest") + +test -f "$archive" +echo "$archive_sha $archive" | sha256sum --check --status + +stage=$(mktemp -d /tmp/pocketjs-n9-sdk.XXXXXX) +trap 'rm -rf "$stage"' EXIT +unzip -q "$archive" -d "$stage" || test -f "$stage/$installer_name" +installer="$stage/$installer_name" +echo "$installer_sha $installer" | sha256sum --check --status +echo "$installer_md5 $installer" | md5sum --check --status +chmod 0755 "$installer" + +rm -rf /toolchain/QtSDK +cp /opt/pocketjs/container/controller.qs "$stage/controller.qs" +xvfb-run -a "$installer" --show-virtual-components --script "$stage/controller.qs" & +installer_pid=$! +ready=false +for _ in $(seq 1 600); do + if test -x /toolchain/QtSDK/Madde/bin/mad \ + && test -d /toolchain/QtSDK/Madde/targets/harmattan_10.2011.34-1_rt1.2 \ + && test -f /toolchain/QtSDK/Madde/sysroots/harmattan_sysroot_10.2011.34-1_slim/usr/include/meegotouch/MWindow; then + sleep 5 + ready=true + kill "$installer_pid" 2>/dev/null || true + break + fi + kill -0 "$installer_pid" 2>/dev/null || break + sleep 1 +done +wait "$installer_pid" 2>/dev/null || true +test "$ready" = true + +test -x /toolchain/QtSDK/Madde/bin/mad + +implementation=${POCKETJS_NOKIA_N9_IMAGE_IMPLEMENTATION_SHA256:?} +jq -n \ + --arg version "$(jq -r .toolchainVersion "$manifest")" \ + --arg implementation "$implementation" \ + --arg archive "$archive_sha" \ + --arg installer "$installer_sha" \ + '{schemaVersion:1,toolchainVersion:$version,implementationSha256:$implementation,sdkArchiveSha256:$archive,sdkInstallerSha256:$installer}' \ + > /toolchain/QtSDK/.pocket-stack-nokia-n9.json diff --git a/tools/test.ts b/tools/test.ts index bbdb6ee5..494ace7d 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -50,6 +50,9 @@ const SUITE: readonly Stage[] = [ "tests/iphone4s-profile.test.ts", "tests/ipodtouch-profile.test.ts", "tests/meizu-m8-profile.test.ts", + "tests/nokia-n9-profile.test.ts", + "tests/nokia-n9-toolchain.test.ts", + "tests/nokia-n9-runtime.test.ts", "tests/ios-profile.test.ts", "tests/iphone2g-device-contract.test.ts", "tests/iphone2g-toolchain.test.ts",