From d7a3b0ef69c757bb80a72f3fc011b72d7c932920 Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Fri, 11 Sep 2026 01:30:50 -0700
Subject: [PATCH 1/7] feat(ime): add companion input and Moto G Play Clear
support
---
.gitignore | 3 +
apps/clear/app.tsx | 20 +-
apps/clear/candidate-panel.ts | 129 +++
apps/clear/editor.ts | 95 ++-
apps/clear/icon-backspace.svg | 4 +
apps/clear/icon-cancel.svg | 3 +
apps/clear/icon-expand.svg | 3 +
apps/clear/icon-globe.svg | 7 +
apps/clear/icon-next.svg | 3 +
apps/clear/icon-previous.svg | 3 +
apps/clear/icon-trackpad.svg | 12 +
apps/clear/images.json | 17 +
apps/clear/kb-layout.ts | 6 +-
apps/clear/keyboard-metrics.ts | 12 +-
apps/clear/keyboard-touch.ts | 80 ++
apps/clear/keyboard.tsx | 242 +++++-
apps/clear/metrics.ts | 6 +-
apps/clear/pocket.android.json | 31 +
apps/clear/pocket.json | 13 +-
apps/clear/remote-text.tsx | 53 ++
apps/clear/rows.tsx | 10 +-
contracts/spec/ime.ts | 27 +
contracts/spec/offload.ts | 3 +
contracts/spec/text.ts | 12 +
docs/CLEAR_IME.md | 146 ++++
docs/IPODTOUCH4.md | 5 +
docs/TEXT_RESOURCES.md | 78 ++
engine/core/src/draw.rs | 50 +-
engine/core/src/tests.rs | 75 ++
engine/quickjs-c/offload_qjs.h | 76 ++
engine/quickjs-c/pocket_runtime.c | 12 +
engine/quickjs-c/pocket_runtime.h | 4 +
framework/compiler/subpaths.ts | 4 +
framework/src/ime.ts | 106 +++
framework/src/text-view.ts | 47 ++
framework/src/text.ts | 150 ++++
hosts/3ds/src/offload_coverage.h | 68 +-
hosts/3ds/src/offload_queue.h | 34 +-
hosts/3ds/src/qjs.c | 2 +-
hosts/android/app/AndroidManifest.xml | 33 +
hosts/android/app/jni/runtime.c | 361 +++++++++
hosts/android/app/res/values/strings.xml | 5 +
.../pocketstack/android/PocketActivity.java | 293 +++++++
.../app/jni/runtime.c | 309 +-------
.../blackberry/PocketActivity.java | 269 +------
hosts/shared/offload_coverage.h | 72 ++
hosts/shared/offload_posix.c | 141 ++++
hosts/shared/offload_posix.h | 11 +
hosts/shared/offload_queue.h | 33 +
package.json | 18 +-
tests/clear-candidate-panel.test.ts | 47 ++
tests/clear-ime-loading.test.ts | 141 ++++
tests/clear-keyboard-touch.test.ts | 68 ++
tests/clear.test.ts | 62 ++
tests/companion-session.test.ts | 3 +-
tests/fixtures/offload-queue.c | 6 +
tests/ime-text-tile.test.ts | 73 ++
tests/ime.test.ts | 100 +++
tests/ipodtouch4-profile.test.ts | 2 +-
tests/moto-g-play-profile.test.ts | 12 +
tests/npm-package.test.ts | 2 +
tests/offload-posix.test.ts | 64 ++
tests/text.test.ts | 91 +++
tools/android.ts | 742 ++++++++++++++++++
tools/blackberry-android.ts | 721 +----------------
tools/cli/moto-g-play-toolchain.json | 29 +
tools/ime/build-ipod-tap.ts | 20 +
tools/ime/device.ts | 54 ++
tools/ime/ipod-tap.c | 42 +
tools/ime/pocket_pinyin.schema.yaml | 31 +
tools/ime/rime.c | 111 +++
tools/ime/rime.ts | 50 ++
tools/ime/serve.ts | 26 +
tools/ime/setup.ts | 30 +
tools/ime/text-tile.ts | 25 +
tools/ime/verify.ts | 65 ++
tools/ime/worker.ts | 24 +
tools/ipodtouch4-profile.ts | 2 +-
tools/ipodtouch4.ts | 9 +-
tools/moto-g-play-profile.ts | 15 +
tools/moto-g-play.ts | 47 ++
tools/test.ts | 9 +-
tools/text-provider.ts | 36 +
83 files changed, 4489 insertions(+), 1466 deletions(-)
create mode 100644 apps/clear/candidate-panel.ts
create mode 100644 apps/clear/icon-backspace.svg
create mode 100644 apps/clear/icon-cancel.svg
create mode 100644 apps/clear/icon-expand.svg
create mode 100644 apps/clear/icon-globe.svg
create mode 100644 apps/clear/icon-next.svg
create mode 100644 apps/clear/icon-previous.svg
create mode 100644 apps/clear/icon-trackpad.svg
create mode 100644 apps/clear/images.json
create mode 100644 apps/clear/keyboard-touch.ts
create mode 100644 apps/clear/pocket.android.json
create mode 100644 apps/clear/remote-text.tsx
create mode 100644 contracts/spec/ime.ts
create mode 100644 contracts/spec/text.ts
create mode 100644 docs/CLEAR_IME.md
create mode 100644 docs/TEXT_RESOURCES.md
create mode 100644 engine/quickjs-c/offload_qjs.h
create mode 100644 framework/src/ime.ts
create mode 100644 framework/src/text-view.ts
create mode 100644 framework/src/text.ts
create mode 100644 hosts/android/app/AndroidManifest.xml
create mode 100644 hosts/android/app/jni/runtime.c
create mode 100644 hosts/android/app/res/values/strings.xml
create mode 100644 hosts/android/app/src/dev/pocketstack/android/PocketActivity.java
create mode 100644 hosts/shared/offload_coverage.h
create mode 100644 hosts/shared/offload_posix.c
create mode 100644 hosts/shared/offload_posix.h
create mode 100644 hosts/shared/offload_queue.h
create mode 100644 tests/clear-candidate-panel.test.ts
create mode 100644 tests/clear-ime-loading.test.ts
create mode 100644 tests/clear-keyboard-touch.test.ts
create mode 100644 tests/ime-text-tile.test.ts
create mode 100644 tests/ime.test.ts
create mode 100644 tests/moto-g-play-profile.test.ts
create mode 100644 tests/offload-posix.test.ts
create mode 100644 tests/text.test.ts
create mode 100644 tools/android.ts
create mode 100644 tools/cli/moto-g-play-toolchain.json
create mode 100644 tools/ime/build-ipod-tap.ts
create mode 100644 tools/ime/device.ts
create mode 100644 tools/ime/ipod-tap.c
create mode 100644 tools/ime/pocket_pinyin.schema.yaml
create mode 100644 tools/ime/rime.c
create mode 100644 tools/ime/rime.ts
create mode 100644 tools/ime/serve.ts
create mode 100644 tools/ime/setup.ts
create mode 100644 tools/ime/text-tile.ts
create mode 100644 tools/ime/verify.ts
create mode 100644 tools/ime/worker.ts
create mode 100644 tools/moto-g-play-profile.ts
create mode 100644 tools/moto-g-play.ts
create mode 100644 tools/text-provider.ts
diff --git a/.gitignore b/.gitignore
index 8e792262d..ca237d6dc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,9 @@ framework/src/styles.generated.ts
# per-target pack scratch (tools/pocket-pack.ts — each target gets its own
# outdir so dist flavors never collide)
.pocket-build/
+# Retired Clear validation archives; per-run captures belong in .pocket-build/.
+/docs/evidence/clear-ime/
+/docs/evidence/clear-keyboard/
# regenerated by `bun tools/launcher.ts covers` (deterministic sim renders;
# registry.generated.ts is COMMITTED — scan output, small and reviewable, so
# single-app launcher builds and the psplink picker work on a fresh checkout;
diff --git a/apps/clear/app.tsx b/apps/clear/app.tsx
index 4935861e0..16ed96b48 100644
--- a/apps/clear/app.tsx
+++ b/apps/clear/app.tsx
@@ -549,19 +549,20 @@ export default () => {
region: {
rect: () =>
screenName === "todos" && editor.editing()
- ? { x: 0, y: 0, w: SCREEN_W, h: SCREEN_H - KB_H }
+ ? { x: 0, y: 0, w: SCREEN_W, h: SCREEN_H - kb.height() }
: null,
},
onTap: () => editor.close(true),
});
// The keyboard claims its panel outright (registered last = top priority).
- // Keys commit on the down edge; the key-cap popup lives until the lift.
+ // Contacts own holds and drags; a short space commits on release.
createGesture({
region: { rect: () => kb.rect() },
- onDown: (c) => kb.pressAt(c.x, c.y, SCREEN_H),
- onUp: () => kb.release(),
- onCancel: () => kb.release(),
+ onDown: (c) => kb.pressAt(c.x, c.y, SCREEN_H, c.id),
+ onMove: (c) => kb.moveAt(c.x, c.y, c.id),
+ onUp: (c) => kb.release(c.id),
+ onCancel: (c) => kb.release(c.id, true),
});
// ------------------------------------------------------------ frame pump
@@ -595,6 +596,15 @@ export default () => {
}
onFrame(() => {
+ editor.step();
+ const edited = editor.editing(), editedSlot = edited ? slotByTodo.get(edited.id) : undefined;
+ const textHeight = edited ? SCREEN_H - kb.height() : SCREEN_H;
+ const textOffset = scroller.offset() + Math.max(0, editedSlot ? editedSlot.y - scroller.offset() + ROW_H - textHeight : 0);
+ for (const slot of slots) {
+ slot.textVisible = screenName !== "lists" && (slot.todoId !== -1 || slot.busy) &&
+ (slot === editedSlot || slot.y + ROW_H > textOffset - ROW_H && slot.y < textOffset + textHeight + ROW_H);
+ slot.textPriority = slot === editedSlot ? 0 : 2;
+ }
if (screenName === "todos") {
scroller.step();
const off = scroller.offset();
diff --git a/apps/clear/candidate-panel.ts b/apps/clear/candidate-panel.ts
new file mode 100644
index 000000000..3741c8486
--- /dev/null
+++ b/apps/clear/candidate-panel.ts
@@ -0,0 +1,129 @@
+import type { ImeState, ImeCandidatePage } from "@pocketjs/framework/ime";
+import type { Scroller } from "@pocketjs/framework/kinetics";
+
+export const CANDIDATE_ROW_H = 44;
+export interface CandidateCell { index: number; text: string; x: number; y: number; w: number; pending: boolean; divider: boolean }
+/** Keep surviving rows in their mounted slots; recycle only departed cells. */
+export function candidateSlots(current: readonly (CandidateCell | null)[], visible: readonly CandidateCell[]): (CandidateCell | null)[] {
+ const key = (cell: CandidateCell) => `${cell.index}:${cell.y}`;
+ const remaining = new Map(visible.map(cell => [key(cell), cell]));
+ const next = current.map(cell => {
+ const id = cell && key(cell), keep = id ? remaining.get(id) : undefined;
+ if (id && keep) remaining.delete(id);
+ return keep ?? null;
+ });
+ const incoming = remaining.values();
+ for (let i = 0; i < next.length; i++) if (!next[i]) next[i] = incoming.next().value ?? null;
+ return next;
+}
+export function candidateGrid(words: readonly string[], width: number, placeholders = 0, firstIndex = 0): CandidateCell[] {
+ let x = 0, y = 0;
+ const result: CandidateCell[] = [];
+ for (let index = firstIndex; index < words.length + placeholders; index++) {
+ const text = words[index] ?? "", scalars = Array.from(text), w = Math.min(width, Math.max(64, scalars.length * 16 + 24));
+ if (x + w > width) { x = 0; y += CANDIDATE_ROW_H; }
+ const chars = Math.max(1, Math.floor((width - 24) / 16));
+ if (scalars.length > chars) {
+ for (let from = 0; from < scalars.length; from += chars) {
+ result.push({ index, text: scalars.slice(from, from + chars).join(""), x: 0, y, w: width, pending: false, divider: from + chars >= scalars.length });
+ y += CANDIDATE_ROW_H;
+ }
+ x = 0;
+ } else { result.push({ index, text, x, y, w, pending: index >= words.length, divider: true }); x += w; }
+ }
+ return result;
+}
+
+/** Scroll/tap ownership and bounded read windows, independent of key actions. */
+export function createCandidatePanel(options: {
+ width: number; height: number; scroller: Scroller;
+ firstIndex?: number | (() => number);
+ browse(offset: number, complete: (page: ImeCandidatePage | null) => void): number;
+ select(index: number): void;
+}) {
+ let state: ImeState | undefined, chinese = false, open = false, epoch = 0, loaded = 0, last = false, loading = false, retry = 0;
+ let words: string[] = [], cells: CandidateCell[] = [], contact: { id: number; x: number; y: number; lastY: number; at: number; velocity: number; moved: boolean; epoch: number } | undefined;
+ const empty: CandidateCell[] = [];
+ let visibleCells = empty, visibleSource = cells, visibleStart = -1, visibleEnd = -1;
+ const scroll = options.scroller;
+ function layout() {
+ const first = typeof options.firstIndex === "function" ? options.firstIndex() : options.firstIndex;
+ const next = candidateGrid(words, options.width, last ? 0 : 10, first);
+ cells = next.map((cell, i) => {
+ const old = cells[i];
+ return old && old.index === cell.index && old.text === cell.text && old.x === cell.x && old.y === cell.y &&
+ old.w === cell.w && old.pending === cell.pending && old.divider === cell.divider ? old : cell;
+ });
+ }
+ function bound(y: number, after: boolean) {
+ let lo = 0, hi = cells.length;
+ while (lo < hi) { const mid = (lo + hi) >>> 1; if (after ? cells[mid].y <= y : cells[mid].y < y) lo = mid + 1; else hi = mid; }
+ return lo;
+ }
+ function close() { open = false; contact = undefined; scroll.endDrag(0); scroll.scrollTo(0, { immediate: true }); }
+ return {
+ setState(next: ImeState, mode: boolean) {
+ if (state?.revision !== next.revision) {
+ epoch++; words = []; loaded = 0; last = false; loading = false; retry = 0; contact = undefined;
+ scroll.endDrag(0); scroll.scrollTo(0, { immediate: true });
+ }
+ state = next; chinese = mode;
+ if (!loaded && !next.pending && next.page === 0) words = next.candidates.slice();
+ if (!mode || !next.composing) close();
+ layout();
+ },
+ toggle() { if (!chinese || !state?.composing) return; open ? close() : open = true; },
+ close,
+ isOpen: () => open,
+ max: () => Math.max(0, (cells.at(-1)?.y ?? 0) + CANDIDATE_ROW_H - options.height),
+ visible() {
+ if (!open) return empty;
+ const start = bound(scroll.offset() - CANDIDATE_ROW_H * 2, true), end = bound(scroll.offset() + options.height + CANDIDATE_ROW_H, false);
+ if (visibleSource !== cells || visibleStart !== start || visibleEnd !== end) {
+ visibleSource = cells; visibleStart = start; visibleEnd = end; visibleCells = cells.slice(start, end);
+ }
+ return visibleCells;
+ },
+ offset: scroll.offset,
+ step() {
+ if (!open) return;
+ scroll.step();
+ if (retry > 0) { retry--; return; }
+ if (!state || state.pending || !state.connected || loading || last || loaded >= 512) return;
+ const lastLoadedY = cells.findLast(c => c.index < loaded)?.y ?? 0;
+ if (loaded && lastLoadedY > scroll.offset() + options.height + CANDIDATE_ROW_H * 2) return;
+ const version = epoch;
+ loading = true;
+ const id = options.browse(loaded, page => {
+ if (version !== epoch) return;
+ loading = false;
+ if (!page) { retry = 60; return; }
+ words = [...words.slice(0, page.offset), ...page.candidates]; loaded = words.length; last = page.last;
+ layout();
+ });
+ if (!id) loading = false;
+ },
+ press(id: number, x: number, y: number, now: number) {
+ if (contact || !open) return;
+ contact = { id, x, y, lastY: y, at: now, velocity: 0, moved: false, epoch };
+ scroll.beginDrag();
+ },
+ move(id: number, x: number, y: number, now: number) {
+ if (contact?.id !== id) return;
+ const c = contact, dy = c.lastY - y, dt = now - c.at;
+ if (Math.hypot(x - c.x, y - c.y) > 6) c.moved = true;
+ if (c.moved) { scroll.drag(dy); if (dt > 0) c.velocity = Math.max(-1800, Math.min(1800, dy / dt)); }
+ c.lastY = y; c.at = now;
+ },
+ release(id: number, cancelled: boolean) {
+ if (contact?.id !== id) return false;
+ const c = contact; contact = undefined;
+ scroll.endDrag(cancelled ? 0 : c.velocity);
+ if (!cancelled && !c.moved && c.epoch === epoch) {
+ const y = c.y + scroll.offset(), cell = cells.find(p => c.x >= p.x && c.x < p.x + p.w && y >= p.y && y < p.y + CANDIDATE_ROW_H);
+ if (cell && !cell.pending && !state?.pending) { options.select(cell.index); close(); }
+ }
+ return true;
+ },
+ };
+}
diff --git a/apps/clear/editor.ts b/apps/clear/editor.ts
index 3ba4d5281..cc1b33b90 100644
--- a/apps/clear/editor.ts
+++ b/apps/clear/editor.ts
@@ -3,11 +3,13 @@
// that keeps the edited row above the keyboard. The host (app.tsx) supplies
// model/layout access; this module owns the editing state machine.
+import { createIme, IME } from "@pocketjs/framework/ime";
+import { hasCompanion } from "./remote-text.tsx";
import { animate } from "@pocketjs/framework/animation";
import type { NodeMirror } from "@pocketjs/framework/components";
import { removeTodo, type Todo, type TodoList } from "./model.ts";
import { ROW_H, SCREEN_H } from "./metrics.ts";
-import { KB_H, makeKeyboard, type Keyboard } from "./keyboard.tsx";
+import { makeKeyboard, type Keyboard } from "./keyboard.tsx";
import type { RowSlot } from "./rows.tsx";
export interface EditorHost {
@@ -26,6 +28,7 @@ export interface Editor {
editing(): Todo | null;
open(todo: Todo, wasNew: boolean): void;
close(commit: boolean): void;
+ step(): void;
}
export function makeEditor(host: EditorHost): Editor {
@@ -33,6 +36,10 @@ export function makeEditor(host: EditorHost): Editor {
let editCaret = 0;
let editOriginal = "";
let editWasNew = false;
+ let chinese = hasCompanion();
+ let closeAfterComposition = false;
+ let chooseWhenReady = false;
+ let keyboardLift = NaN;
function paintEditRow(): void {
if (!editing) return;
@@ -51,16 +58,27 @@ export function makeEditor(host: EditorHost): Editor {
}
function open(todo: Todo, wasNew: boolean): void {
+ ime?.reset();
+ closeAfterComposition = chooseWhenReady = false;
editing = todo;
editWasNew = wasNew;
editOriginal = todo.text;
editCaret = todo.text.length;
kb.setOpen(true);
+ if (ime) kb.setIme(ime.state(), chinese);
paintEditRow();
shadeRows(true);
- const index = host.order().indexOf(todo);
+ keyboardLift = NaN;
+ updateLift();
+ }
+
+ function updateLift(): void {
+ if (!editing) return;
+ const index = host.order().indexOf(editing);
const rowBottom = index * ROW_H - host.scrollOffset() + ROW_H;
- const liftNeeded = Math.max(0, rowBottom - (SCREEN_H - KB_H));
+ const liftNeeded = Math.max(0, rowBottom - (SCREEN_H - kb.height()));
+ if (liftNeeded === keyboardLift) return;
+ keyboardLift = liftNeeded;
const canvas = host.canvas();
if (canvas) {
animate(canvas, "translateY", -host.scrollOffset() - liftNeeded, { dur: 200, easing: "out" });
@@ -70,6 +88,13 @@ export function makeEditor(host: EditorHost): Editor {
function close(commit: boolean): void {
const todo = editing;
if (!todo) return;
+ if (commit && ime?.composing()) {
+ closeAfterComposition = true;
+ chooseWhenReady = true;
+ return;
+ }
+ ime?.reset();
+ closeAfterComposition = chooseWhenReady = false;
shadeRows(false);
editing = null;
kb.setOpen(false);
@@ -89,21 +114,63 @@ export function makeEditor(host: EditorHost): Editor {
host.layout(true);
}
+ function insert(text: string): void {
+ if (!editing) return;
+ const room = 40 - Array.from(editing.text).length;
+ const next = Array.from(text).slice(0, Math.max(0, room)).join("");
+ editing.text = editing.text.slice(0, editCaret) + next + editing.text.slice(editCaret);
+ editCaret += next.length;
+ paintEditRow();
+ }
+ function backspace(): void {
+ if (!editing || editCaret === 0) return;
+ const prefix = Array.from(editing.text.slice(0, editCaret));
+ const count = prefix.pop()!.length;
+ editing.text = prefix.join("") + editing.text.slice(editCaret);
+ editCaret -= count;
+ paintEditRow();
+ }
+ const ime = hasCompanion() ? createIme({
+ changed: state => kb.setIme(state, chinese),
+ commit: insert,
+ }) : undefined;
const kb = makeKeyboard({
onInsert(ch) {
- if (!editing || editing.text.length >= 40) return;
- editing.text = editing.text.slice(0, editCaret) + ch + editing.text.slice(editCaret);
- editCaret += ch.length;
- paintEditRow();
+ if (!editing) return;
+ if (chinese && ime && (/^[a-z']$/.test(ch) || ime.composing())) {
+ if (ch === " ") chooseWhenReady = true;
+ else ime.key(ch.charCodeAt(0));
+ } else insert(ch);
+ },
+ onBackspace() { if (ime?.composing()) ime.key(IME.backspace); else backspace(); },
+ onEnter() { if (ime?.composing()) chooseWhenReady = true; else close(true); },
+ onMode() {
+ if (!ime) return;
+ if (ime.composing()) ime.key(IME.enter);
+ chinese = !chinese;
+ kb.setIme(ime.state(), chinese);
},
- onBackspace() {
- if (!editing || editCaret === 0) return;
- editing.text = editing.text.slice(0, editCaret - 1) + editing.text.slice(editCaret);
- editCaret -= 1;
+ onCandidate(index) { ime?.select(index); },
+ onCandidateAbsolute(index) { ime?.selectAbsolute(index); },
+ onBrowse(offset, complete) { return ime?.browse(offset, complete) ?? 0; },
+ onCancelComposition() { ime?.reset(); closeAfterComposition = chooseWhenReady = false; },
+ onCaret(direction) {
+ if (ime?.composing()) { ime.key(direction < 0 ? IME.left : IME.right); return; }
+ if (!editing) return;
+ if (direction < 0) editCaret -= Array.from(editing.text.slice(0, editCaret)).pop()?.length ?? 0;
+ else editCaret += Array.from(editing.text.slice(editCaret))[0]?.length ?? 0;
paintEditRow();
},
- onEnter: () => close(true),
});
-
- return { kb, editing: () => editing, open, close };
+ function step() {
+ ime?.step();
+ updateLift();
+ if (chooseWhenReady && ime && !ime.state().pending && ime.state().connected) {
+ chooseWhenReady = false;
+ if (ime.state().candidates.length) ime.select(0);
+ else if (ime.composing()) ime.key(IME.enter);
+ }
+ if (closeAfterComposition && ime && !ime.composing()) close(true);
+ }
+ return { kb, editing: () => editing, open, close, step };
}
diff --git a/apps/clear/icon-backspace.svg b/apps/clear/icon-backspace.svg
new file mode 100644
index 000000000..500e5d154
--- /dev/null
+++ b/apps/clear/icon-backspace.svg
@@ -0,0 +1,4 @@
+
diff --git a/apps/clear/icon-cancel.svg b/apps/clear/icon-cancel.svg
new file mode 100644
index 000000000..e094128ba
--- /dev/null
+++ b/apps/clear/icon-cancel.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/clear/icon-expand.svg b/apps/clear/icon-expand.svg
new file mode 100644
index 000000000..1e6d8368c
--- /dev/null
+++ b/apps/clear/icon-expand.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/clear/icon-globe.svg b/apps/clear/icon-globe.svg
new file mode 100644
index 000000000..3088a237d
--- /dev/null
+++ b/apps/clear/icon-globe.svg
@@ -0,0 +1,7 @@
+
diff --git a/apps/clear/icon-next.svg b/apps/clear/icon-next.svg
new file mode 100644
index 000000000..81c7bcbec
--- /dev/null
+++ b/apps/clear/icon-next.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/clear/icon-previous.svg b/apps/clear/icon-previous.svg
new file mode 100644
index 000000000..6d9f2f7b5
--- /dev/null
+++ b/apps/clear/icon-previous.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/clear/icon-trackpad.svg b/apps/clear/icon-trackpad.svg
new file mode 100644
index 000000000..9c054e226
--- /dev/null
+++ b/apps/clear/icon-trackpad.svg
@@ -0,0 +1,12 @@
+
diff --git a/apps/clear/images.json b/apps/clear/images.json
new file mode 100644
index 000000000..18f955386
--- /dev/null
+++ b/apps/clear/images.json
@@ -0,0 +1,17 @@
+{
+ "icon-backspace.svg": {
+ "linear": true
+ },
+ "icon-cancel.svg": {
+ "linear": true
+ },
+ "icon-globe.svg": {
+ "linear": true
+ },
+ "icon-expand.svg": {
+ "linear": true
+ },
+ "icon-trackpad.svg": {
+ "linear": true
+ }
+}
diff --git a/apps/clear/kb-layout.ts b/apps/clear/kb-layout.ts
index b0644b1f2..12d345b5c 100644
--- a/apps/clear/kb-layout.ts
+++ b/apps/clear/kb-layout.ts
@@ -12,7 +12,7 @@
// codepoints from literals, so this module is what guarantees the keys (and
// the € £ ¥ • row) can render.
-import { KB_GAP, KB_PAD, KB_ROW_H } from "./keyboard-metrics.ts";
+import { KB_GAP, KB_PAD, KB_ROW_H, KB_W } from "./keyboard-metrics.ts";
export type KbAction = "shift" | "backspace" | "num" | "abc" | "sym" | "globe" | "return";
@@ -82,6 +82,10 @@ export const KB_LAYERS: Record = {
],
};
+// Preserve the authored key proportions on each logical portrait width.
+for (const rows of Object.values(KB_LAYERS)) for (let r = 0; r < rows.length; r++)
+ rows[r] = rows[r].map(key => ({ ...key, x: key.x * KB_W / 320, w: key.w * KB_W / 320 }));
+
export interface KbPos {
row: number;
col: number;
diff --git a/apps/clear/keyboard-metrics.ts b/apps/clear/keyboard-metrics.ts
index 8e9ce876e..8ef56ebe6 100644
--- a/apps/clear/keyboard-metrics.ts
+++ b/apps/clear/keyboard-metrics.ts
@@ -2,8 +2,14 @@
// the same numbers the renderer and hit-testing use (the osk-layout rule).
export const KB_PAD = 6;
-export const KB_GAP = 4;
+export const KB_GAP = 6;
export const KB_ROW_H = 40;
export const KB_ROWS = 4;
-export const KB_H = KB_ROWS * KB_ROW_H + (KB_ROWS - 1) * KB_GAP + 2 * KB_PAD; // 184
-export const KB_W = 320;
+export const KB_H = KB_ROWS * KB_ROW_H + (KB_ROWS - 1) * KB_GAP + 2 * KB_PAD; // 190
+import { SCREEN_W } from "./metrics.ts";
+export const KB_W = SCREEN_W;
+
+export const IME_BAR_H = 44;
+export const IME_LABEL_H = 20;
+export const IME_LABEL_GAP = 4;
+export const IME_INLINE_CANDIDATES = 3;
diff --git a/apps/clear/keyboard-touch.ts b/apps/clear/keyboard-touch.ts
new file mode 100644
index 000000000..5a3c37891
--- /dev/null
+++ b/apps/clear/keyboard-touch.ts
@@ -0,0 +1,80 @@
+/** Contact-owned keyboard holds; time is supplied by the PocketJS virtual clock. */
+export const KEY_HOLD = { space: 0.20, backspace: 0.43, repeat: 0.085, fastAfter: 2, fastRepeat: 0.05,
+ cursorPitch: 10, cursorHysteresis: 2, slop: 12 } as const;
+type Kind = "space" | "backspace" | "other";
+type Rect = { x: number; y: number; w: number; h: number };
+type Hold = { kind: Kind; x: number; y: number; startX: number; startY: number; at: number;
+ next: number; cancelled: boolean; consumed: boolean; rect: Rect };
+export function createKeyboardTouch(handlers: {
+ space(): void; backspace(): void; caret(direction: number): void;
+ trackpad(active: boolean): void;
+}) {
+ const holds = new Map();
+ let cursorOwner: number | undefined, anchor = 0;
+ function drag(hold: Hold) {
+ const threshold = KEY_HOLD.cursorPitch / 2 + KEY_HOLD.cursorHysteresis;
+ let steps = 0;
+ while (Math.abs(hold.x - anchor) >= threshold && steps < 8) {
+ const direction = hold.x > anchor ? 1 : -1;
+ anchor += direction * KEY_HOLD.cursorPitch;
+ handlers.caret(direction); steps++;
+ }
+ // A discontinuous input sample cannot create an unbounded catch-up loop.
+ if (steps === 8) anchor = hold.x;
+ }
+ function release(id: number, cancelled = false) {
+ const hold = holds.get(id);
+ if (!hold) return;
+ holds.delete(id);
+ if (cursorOwner === id) { cursorOwner = undefined; handlers.trackpad(false); }
+ else if (!cancelled && hold.kind === "space" && !hold.cancelled && !hold.consumed) handlers.space();
+ }
+ return {
+ begin(id: number, x: number, y: number, kind: Kind, rect: Rect, now: number) {
+ if (cursorOwner !== undefined || holds.size >= 8) return false;
+ release(id, true);
+ // A rolling two-thumb space/letter chord keeps text in down-edge order.
+ for (const hold of holds.values()) if (hold.kind === "space" && !hold.cancelled && !hold.consumed) {
+ hold.consumed = true; handlers.space();
+ }
+ holds.set(id, { kind, x, y, startX: x, startY: y, at: now, next: now + KEY_HOLD.backspace,
+ cancelled: false, consumed: false, rect });
+ if (kind === "backspace") handlers.backspace();
+ return true;
+ },
+ move(id: number, x: number, y: number) {
+ const hold = holds.get(id);
+ if (!hold) return;
+ hold.x = x; hold.y = y;
+ if (cursorOwner === id) { drag(hold); return; }
+ if (hold.kind === "space" && Math.hypot(x - hold.startX, y - hold.startY) > KEY_HOLD.slop) hold.cancelled = true;
+ if (hold.kind === "backspace") {
+ const r = hold.rect, s = KEY_HOLD.slop;
+ if (x < r.x - s || x > r.x + r.w + s || y < r.y - s || y > r.y + r.h + s) hold.cancelled = true;
+ }
+ },
+ step(now: number) {
+ for (const [id, hold] of holds) {
+ if (hold.cancelled || hold.consumed) continue;
+ if (hold.kind === "space" && cursorOwner === undefined && now - hold.at + 1e-7 >= KEY_HOLD.space) {
+ cursorOwner = id; hold.consumed = true; anchor = hold.x; handlers.trackpad(true);
+ }
+ if (hold.kind === "backspace" && cursorOwner === undefined) {
+ let count = 0;
+ while (now + 1e-7 >= hold.next && count < 2) {
+ handlers.backspace(); count++;
+ hold.next += now - hold.at >= KEY_HOLD.fastAfter ? KEY_HOLD.fastRepeat : KEY_HOLD.repeat;
+ }
+ if (count === 2 && hold.next < now) hold.next = now + KEY_HOLD.repeat;
+ }
+ }
+ },
+ release,
+ cancel() { for (const id of holds.keys()) release(id, true); },
+ tracking: () => cursorOwner !== undefined,
+ holdingSpace() {
+ for (const hold of holds.values()) if (hold.kind === "space" && !hold.cancelled) return true;
+ return false;
+ },
+ };
+}
diff --git a/apps/clear/keyboard.tsx b/apps/clear/keyboard.tsx
index 2989378de..7994ddd49 100644
--- a/apps/clear/keyboard.tsx
+++ b/apps/clear/keyboard.tsx
@@ -8,9 +8,18 @@
// lower); the numbers layer's third-row-left key toggles "#+=" symbols in
// place while the bottom-left key stays "ABC" on both, like the original.
-import { Text, View, type NodeMirror } from "@pocketjs/framework/components";
+import { Image, Text, View, type NodeMirror } from "@pocketjs/framework/components";
import { animate, jump } from "@pocketjs/framework/animation";
-import { shallowRef } from "vue";
+import { virtualNow } from "@pocketjs/framework/clock";
+import { onFrame } from "@pocketjs/framework/lifecycle";
+import { createScroller } from "@pocketjs/framework/kinetics";
+import { shallowRef, onScopeDispose } from "vue";
+import { createKeyboardTouch } from "./keyboard-touch.ts";
+import { remoteText, hasCompanion } from "./remote-text.tsx";
+import type { ImeState, ImeCandidatePage } from "@pocketjs/framework/ime";
+import { createCandidatePanel, candidateSlots, CANDIDATE_ROW_H, type CandidateCell } from "./candidate-panel.ts";
+import { SCREEN_H } from "./metrics.ts";
+import { IME_BAR_H, IME_LABEL_H, IME_LABEL_GAP, IME_INLINE_CANDIDATES } from "./keyboard-metrics.ts";
import { KB_GAP, KB_H, KB_PAD, KB_ROW_H, KB_W } from "./keyboard-metrics.ts";
import { KB_LAYERS, kbKeyAt, type KbKey, type KbLayerName } from "./kb-layout.ts";
@@ -39,21 +48,30 @@ export interface KeyboardHandlers {
onInsert(ch: string): void;
onBackspace(): void;
onEnter(): void;
+ onMode?(): void;
+ onCandidate?(index: number): void;
+ onBrowse?(offset: number, complete: (page: ImeCandidatePage | null) => void): number;
+ onCandidateAbsolute?(index: number): void;
+ onCaret?(direction: number): void;
+ onCancelComposition?(): void;
}
const LAYER_NAMES: readonly KbLayerName[] = ["lower", "upper", "numbers", "symbols"];
export interface Keyboard {
view: JSX.Element;
+ height(): number;
+ setIme(state: ImeState, chinese: boolean): void;
/** Dock/undock the panel (animated). */
setOpen(open: boolean): void;
isOpen(): boolean;
/** The docked panel's screen rect, for the gesture region. */
rect(): { x: number; y: number; w: number; h: number } | null;
/** Route a contact's down edge (screen coordinates) into a key press. */
- pressAt(x: number, y: number, screenH: number): void;
+ pressAt(x: number, y: number, screenH: number, id?: number): void;
+ moveAt(x: number, y: number, id?: number): void;
/** The contact lifted (or was cancelled): dismiss the key-cap popup. */
- release(): void;
+ release(id?: number, cancelled?: boolean): void;
}
type CapKind = "char" | "action" | "engaged";
@@ -72,19 +90,89 @@ const CAP_COLORS: Record = {
};
export function makeKeyboard(handlers: KeyboardHandlers): Keyboard {
+ const imeHeight = hasCompanion() ? IME_BAR_H : 0;
+ const imeStatus = shallowRef("EN");
+ const modeWidth = shallowRef(28), preeditWidth = shallowRef(28);
+ const preedit = shallowRef("");
+ const candidatePending = shallowRef(false), preeditPending = shallowRef(false);
+ const composing = shallowRef(false), expanded = shallowRef(false);
+ const candidates = Array.from({ length: IME_INLINE_CANDIDATES }, () => shallowRef(""));
+ const inlineWidth = (KB_W - 88) / IME_INLINE_CANDIDATES;
+ let inlineClipped = false;
+ const candidateHeight = KB_H;
+ const labelOverhang = () => composing.value ? IME_LABEL_H + IME_LABEL_GAP : 0;
+ let candidatePanel: ReturnType;
+ const candidateScroll = createScroller({ max: () => candidatePanel?.max() ?? 0, extent: () => candidateHeight, overscroll: 20 });
+ candidatePanel = createCandidatePanel({ width: KB_W, height: candidateHeight, scroller: candidateScroll,
+ // Long phrases need the full-width panel even when they are among the first three.
+ firstIndex: () => inlineClipped ? 0 : IME_INLINE_CANDIDATES,
+ browse: (offset, complete) => handlers.onBrowse?.(offset, complete) ?? 0,
+ select: index => handlers.onCandidateAbsolute?.(index) });
+ const panelCells = Array.from({ length: Math.ceil(KB_W / 64) * (Math.ceil(candidateHeight / CANDIDATE_ROW_H) + 2) },
+ () => shallowRef(null));
+ const panelOffset = shallowRef(0), panelMax = shallowRef(0);
+ let lastVisible: readonly CandidateCell[] | undefined;
const layerNodes = new Map();
const keyNodes = new Map();
+ const spaceNodes = new Set();
+ let spacePressed = false;
let panel: NodeMirror | null = null;
let popupNode: NodeMirror | null = null;
const popupText = shallowRef("");
+ const tracking = shallowRef(false);
+ let popupOwner = -1, popupAt = 0, popupHideAt = Infinity;
let open = false;
let layer: KbLayerName = "lower";
+ const touch = createKeyboardTouch({
+ space: () => handlers.onInsert(" "), backspace: () => handlers.onBackspace(),
+ caret: direction => handlers.onCaret?.(direction),
+ trackpad(active) {
+ tracking.value = active;
+ if (active && popupNode) { jump(popupNode, "opacity", 0); popupOwner = -1; popupHideAt = Infinity; }
+ },
+ });
+ onFrame(() => {
+ const now = virtualNow();
+ candidatePanel.step(); expanded.value = candidatePanel.isOpen();
+ panelOffset.value = candidatePanel.offset(); panelMax.value = candidatePanel.max();
+ const visible = candidatePanel.visible();
+ if (visible !== lastVisible) {
+ lastVisible = visible;
+ const slots = candidateSlots(panelCells.map(cell => cell.value), visible);
+ for (let i = 0; i < panelCells.length; i++) if (panelCells[i].value !== slots[i]) panelCells[i].value = slots[i];
+ }
+ touch.step(now);
+ syncSpacePress();
+ if (now >= popupHideAt) {
+ popupHideAt = Infinity;
+ if (popupNode) animate(popupNode, "opacity", 0, { dur: 120, easing: "out" });
+ }
+ });
+ onScopeDispose(() => { touch.cancel(); candidatePanel.close(); });
function applyLayer(next: KbLayerName): void {
layer = next;
for (const name of LAYER_NAMES) {
const node = layerNodes.get(name);
- if (node) jump(node, "translateX", name === layer ? 0 : KB_W + 40);
+ if (node) {
+ jump(node, "translateX", name === layer ? 0 : KB_W + 40);
+ jump(node, "opacity", name === layer ? 1 : 0);
+ }
+ }
+ }
+
+ function syncSpacePress(fade = true): void {
+ const pressed = touch.holdingSpace();
+ if (pressed === spacePressed) return;
+ spacePressed = pressed;
+ for (const node of spaceNodes) {
+ if (pressed || !fade) {
+ jump(node, "gradFrom", pressed ? CAP_PRESS_FROM : CAP_FROM);
+ jump(node, "gradTo", pressed ? CAP_PRESS_TO : CAP_TO);
+ } else {
+ animate(node, "gradFrom", CAP_FROM, { dur: 180, easing: "out" });
+ animate(node, "gradTo", CAP_TO, { dur: 180, easing: "out" });
+ }
}
}
@@ -99,9 +187,10 @@ export function makeKeyboard(handlers: KeyboardHandlers): Keyboard {
animate(node, "gradTo", to, { dur: 180, easing: "out" });
}
- function showPopup(key: KbKey, row: number): void {
+ function showPopup(key: KbKey, row: number, id: number): void {
if (!popupNode || key.ch === undefined || key.ch === " ") return;
popupText.value = key.ch;
+ popupOwner = id; popupAt = virtualNow(); popupHideAt = Infinity;
const x = Math.max(2, Math.min(KB_W - POPUP_W - 2, key.x + key.w / 2 - POPUP_W / 2));
const y = KB_PAD + row * (KB_ROW_H + KB_GAP) - POPUP_H - 6;
jump(popupNode, "translateX", x);
@@ -109,10 +198,14 @@ export function makeKeyboard(handlers: KeyboardHandlers): Keyboard {
jump(popupNode, "opacity", 1);
}
- function press(row: number, col: number): void {
+ function press(row: number, col: number, id: number, x: number, y: number, screenH: number): void {
const key = KB_LAYERS[layer][row][col];
- flashKey(layer, row, col);
- showPopup(key, row);
+ if (!touch.begin(id, x, y, key.ch === " " ? "space" : key.action === "backspace" ? "backspace" : "other",
+ { x: key.x, y: screenH - KB_H + KB_PAD + row * (KB_ROW_H + KB_GAP), w: key.w, h: KB_ROW_H }, virtualNow())) return;
+ if (key.ch === " ") syncSpacePress();
+ else flashKey(layer, row, col);
+ showPopup(key, row, id);
+ if (key.ch === " " || key.action === "backspace") return;
if (key.ch !== undefined) {
handlers.onInsert(key.ch);
if (layer === "upper") applyLayer("lower"); // one-shot shift
@@ -131,28 +224,19 @@ export function makeKeyboard(handlers: KeyboardHandlers): Keyboard {
case "abc":
applyLayer("lower");
break;
- case "backspace":
- handlers.onBackspace();
- break;
case "return":
handlers.onEnter();
break;
case "globe":
- break; // one keyboard only — the flash is the whole effect
+ handlers.onMode?.();
+ break;
}
}
- /** The globe key's icon: an arc ring with crosshair meridians. */
+ /** Baked filled contours keep thin meridians antialiased at native density. */
function globeIcon() {
return (
-
-
-
-
-
+
);
}
@@ -160,10 +244,19 @@ export function makeKeyboard(handlers: KeyboardHandlers): Keyboard {
const kind = capKind(key, name);
const label = key.label ?? key.ch ?? "";
const small = label.length > 1;
+ // Keep the text resource/painter alive across the trackpad branch. Frame
+ // hooks belong to the layer's scope, not a conditional child factory.
+ const modeLabel = key.ch === " " && imeHeight > 0
+ ? remoteText(() => imeStatus.value, key.w - 12, 14, undefined, false, undefined, () => open, 0,
+ width => { modeWidth.value = width; })
+ : null;
return (
{
- if (node) keyNodes.set(`${name}:${r}:${c}`, node);
+ if (node) {
+ keyNodes.set(`${name}:${r}:${c}`, node);
+ if (key.ch === " ") spaceNodes.add(node);
+ }
}}
class={
kind === "char"
@@ -182,8 +275,15 @@ export function makeKeyboard(handlers: KeyboardHandlers): Keyboard {
>
{key.action === "globe" ? (
globeIcon()
- ) : (
+ ) : key.action === "backspace" ? (
+
+ ) : key.ch === " " && tracking.value ? (
+
+
+
+ ) : modeLabel ? null : (
)}
+ {modeLabel ? {modeLabel} : null}
);
}
@@ -206,7 +308,7 @@ export function makeKeyboard(handlers: KeyboardHandlers): Keyboard {
if (node) layerNodes.set(name, node);
}}
class="absolute inset-0"
- style={{ translateX: name === "lower" ? 0 : KB_W + 40 }}
+ style={{ translateX: name === "lower" ? 0 : KB_W + 40, opacity: name === "lower" ? 1 : 0 }}
>
{KB_LAYERS[name].map((row, r) => row.map((key, c) => renderKey(name, key, r, c)))}
@@ -219,10 +321,43 @@ export function makeKeyboard(handlers: KeyboardHandlers): Keyboard {
if (node) panel = node;
}}
class="absolute left-0 right-0 bottom-0 z-40 bg-gradient-to-b from-[#17191d] to-[#0d0f12]"
- style={{ height: KB_H, translateY: KB_H + POPUP_H + 8 }}
+ style={{ height: KB_H, translateY: KB_H + POPUP_H + IME_BAR_H + 8 }}
>
- {LAYER_NAMES.map((name) => renderLayer(name))}
+ {imeHeight > 0 ?
+
+
+ {remoteText(() => preedit.value, KB_W - 24, 12, undefined, false, () => preeditPending.value,
+ () => composing.value, 0, width => { preeditWidth.value = Math.max(16, Math.ceil(width)); })}
+
+
+
+
+ {candidates.map((candidate, i) =>
+ {remoteText(() => candidate.value, inlineWidth - 12, 16, undefined, false, () => candidatePending.value, () => open, 0)}
+ )}
+ : null}
+ {/* The opaque candidate panel owns this area while expanded. Keep key
+ state mounted, but cull its covered draw subtree on every host. */}
+
+ {LAYER_NAMES.map((name) => renderLayer(name))}
+
+ {imeHeight > 0 ?
+
+ {panelCells.map(cell =>
+
+ {remoteText(() => cell.value?.text ?? "", KB_W - 20, 16, undefined, false,
+ () => expanded.value && !!cell.value?.pending, () => expanded.value && !!cell.value, 0)}
+
+
+ )}
+
+ 0 ? 0.7 : 0 }} />
+ : null}
{
if (node) popupNode = node;
@@ -246,25 +381,66 @@ export function makeKeyboard(handlers: KeyboardHandlers): Keyboard {
return {
view,
+ height: () => KB_H + imeHeight + labelOverhang(),
+ setIme(state, chinese) {
+ imeStatus.value = chinese ? "拼音" : "EN";
+ composing.value = chinese && state.composing;
+ if (!state.pending) inlineClipped = state.candidates.slice(0, IME_INLINE_CANDIDATES)
+ .some(text => Array.from(text).length * 16 > inlineWidth - 12);
+ candidatePanel.setState(state, chinese);
+ candidatePending.value = chinese && state.pending;
+ preeditPending.value = chinese && state.connected && state.pending && !state.preedit;
+ preedit.value = !chinese ? "" : state.error ? "Retry / clear" :
+ state.preedit ? `${state.preedit.slice(0, state.caret)}|${state.preedit.slice(state.caret)}` : !state.connected ? "Offline" : "";
+ for (let i = 0; i < candidates.length; i++) candidates[i].value = chinese && !state.pending ? state.candidates[i] ?? "" : "";
+ },
setOpen(next: boolean): void {
if (next === open) return;
open = next;
+ if (!next) candidatePanel.close();
+ touch.cancel(); syncSpacePress(false); popupOwner = -1; popupHideAt = Infinity;
if (open) applyLayer("lower");
if (popupNode) jump(popupNode, "opacity", 0);
if (panel) {
- animate(panel, "translateY", open ? 0 : KB_H + POPUP_H + 8, { dur: 200, easing: "out" });
+ animate(panel, "translateY", open ? 0 : KB_H + POPUP_H + IME_BAR_H + 8, { dur: 200, easing: "out" });
}
},
isOpen: () => open,
rect() {
- return open ? { x: 0, y: 480 - KB_H, w: KB_W, h: KB_H } : null;
+ const height = KB_H + imeHeight + labelOverhang();
+ return open ? { x: 0, y: SCREEN_H - height, w: KB_W, h: height } : null;
},
- pressAt(x: number, y: number, screenH: number): void {
+ pressAt(x: number, y: number, screenH: number, id = 0): void {
+ if (touch.tracking()) return;
+ const barY = y - (screenH - KB_H - imeHeight);
+ if (composing.value && barY < 0 && barY >= -labelOverhang()) {
+ if (x >= 6 && x < preeditWidth.value + 18) handlers.onCaret?.(x < preeditWidth.value / 2 + 12 ? -1 : 1);
+ return;
+ }
+ if (candidatePanel.isOpen() && barY >= IME_BAR_H) {
+ candidatePanel.press(id, x, y - (screenH - candidateHeight), virtualNow()); return;
+ }
+ if (imeHeight && barY >= 0 && barY < imeHeight) {
+ if (composing.value && x >= KB_W - 44) { touch.cancel(); candidatePanel.toggle(); }
+ else if (composing.value && x >= KB_W - 88) handlers.onCancelComposition?.();
+ else if (composing.value && x < KB_W - 88) handlers.onCandidate?.(Math.max(0, Math.min(IME_INLINE_CANDIDATES - 1, Math.floor(x / inlineWidth))));
+ return;
+ }
const pos = kbKeyAt(KB_LAYERS[layer], x, y - (screenH - KB_H));
- if (pos) press(pos.row, pos.col);
+ if (pos) press(pos.row, pos.col, id, x, y, screenH);
},
- release(): void {
- if (popupNode) animate(popupNode, "opacity", 0, { dur: 90, easing: "out" });
+ moveAt(x: number, y: number, id = 0) {
+ if (candidatePanel.isOpen()) { candidatePanel.move(id, x, y - (SCREEN_H - candidateHeight), virtualNow()); return; }
+ touch.move(id, x, y); syncSpacePress();
+ },
+ release(id = 0, cancelled = false): void {
+ if (candidatePanel.release(id, cancelled)) return;
+ touch.release(id, cancelled);
+ syncSpacePress();
+ if (id === popupOwner) {
+ popupOwner = -1;
+ popupHideAt = cancelled ? virtualNow() : Math.max(popupAt + 0.24, virtualNow() + 0.14);
+ }
},
};
}
diff --git a/apps/clear/metrics.ts b/apps/clear/metrics.ts
index d86691d70..4481d2971 100644
--- a/apps/clear/metrics.ts
+++ b/apps/clear/metrics.ts
@@ -3,8 +3,10 @@
// thresholds at one and two row heights of DISPLAYED overscroll, swipes
// committing at one row height of travel.
-export const SCREEN_W = 320;
-export const SCREEN_H = 480;
+import { detectHost, hostViewport } from "@pocketjs/framework/host";
+const viewport = (() => { try { return hostViewport(detectHost().ops); } catch { return null; } })();
+export const SCREEN_W = viewport?.w ?? 320;
+export const SCREEN_H = viewport?.h ?? 480;
/** Row height, todo and list rows alike. */
export const ROW_H = 62;
diff --git a/apps/clear/pocket.android.json b/apps/clear/pocket.android.json
new file mode 100644
index 000000000..df62413ce
--- /dev/null
+++ b/apps/clear/pocket.android.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://pocketjs.dev/schema/pocket-2.json",
+ "pocket": 2,
+ "id": "dev.pocket-stack.clear",
+ "name": "pocketjs-clear",
+ "title": "Pocket Clear",
+ "version": "0.1.0",
+ "engine": {
+ "capabilities": {
+ "requires": [
+ "input.touch",
+ "text.glyphs.baked",
+ "io.offload"
+ ]
+ }
+ },
+ "app": {
+ "entry": "apps/clear/main.tsx",
+ "output": "clear-main",
+ "framework": "vue-vapor",
+ "viewport": {
+ "fixed": {
+ "logical": [
+ 360,
+ 800
+ ],
+ "presentation": "native"
+ }
+ }
+ }
+}
diff --git a/apps/clear/pocket.json b/apps/clear/pocket.json
index c3106f0c7..6289cb89c 100644
--- a/apps/clear/pocket.json
+++ b/apps/clear/pocket.json
@@ -7,7 +7,13 @@
"version": "0.1.0",
"engine": {
"capabilities": {
- "requires": ["input.touch", "text.glyphs.baked"]
+ "requires": [
+ "input.touch",
+ "text.glyphs.baked"
+ ],
+ "enhances": [
+ "io.offload"
+ ]
}
},
"app": {
@@ -16,7 +22,10 @@
"framework": "vue-vapor",
"viewport": {
"fixed": {
- "logical": [320, 480],
+ "logical": [
+ 320,
+ 480
+ ],
"presentation": "native"
}
}
diff --git a/apps/clear/remote-text.tsx b/apps/clear/remote-text.tsx
new file mode 100644
index 000000000..46e4997ff
--- /dev/null
+++ b/apps/clear/remote-text.tsx
@@ -0,0 +1,53 @@
+import { Text, View, type NodeMirror } from "@pocketjs/framework/components";
+import { animate, jump } from "@pocketjs/framework/animation";
+import { virtualNow } from "@pocketjs/framework/clock";
+import { onFrame } from "@pocketjs/framework/lifecycle";
+import { textResources } from "@pocketjs/framework/text";
+import { createTextPainter } from "@pocketjs/framework/text-view";
+import { onScopeDispose } from "vue";
+
+export const hasCompanion = () => !!(globalThis as { offload?: unknown }).offload;
+
+/** Presentation only: framework text resources own glyph identity and reuse. */
+export function remoteText(text: () => string, width: number, size: 12 | 14 | 16 | 20, color: () => string = () => "#ffffff", bold = false,
+ waiting: () => boolean = () => false, visible: () => boolean = () => true, priority: number | (() => number) = 1,
+ measured?: (width: number) => void) {
+ const height = size + 8;
+ if (!hasCompanion()) return
+ {text()}
+ ;
+ const style = { width, size, density: 2, bold, fontSlot: (size === 20 ? 4 : size === 16 ? 2 : size === 14 ? 1 : 0) + (bold ? 7 : 0) };
+ const label = textResources().createLayout(style);
+ let painter: ReturnType | undefined, content: NodeMirror | null = null, skeleton: NodeMirror | null = null;
+ let loading = false, pulseAt = Infinity, pulseHigh = false, retained = "", measuredRevision = -1;
+ onFrame(() => {
+ const busy = waiting(), now = virtualNow();
+ if (!busy) retained = text();
+ label.set(retained, !busy && visible(), typeof priority === "function" ? priority() : priority);
+ const layout = label.snapshot();
+ if (layout.revision !== measuredRevision && !busy) {
+ measuredRevision = layout.revision; measured?.(Math.min(width, layout.width));
+ }
+ if (busy !== loading) {
+ loading = busy;
+ if (content) jump(content, "opacity", busy ? 0 : 1);
+ if (skeleton) {
+ if (busy) animate(skeleton, "opacity", 0.42, { delay: 80, dur: 180, easing: "out" });
+ else jump(skeleton, "opacity", 0);
+ }
+ pulseAt = busy ? now + 0.26 : Infinity; pulseHigh = false;
+ }
+ if (busy && now >= pulseAt) {
+ pulseHigh = !pulseHigh; pulseAt = now + 0.9;
+ if (skeleton) animate(skeleton, "opacity", pulseHigh ? 0.58 : 0.42, { dur: 900, easing: "in-out" });
+ }
+ // A glyph miss affects its own cell. Resident text is never hidden by it.
+ if (!busy) painter?.paint(layout, color());
+ });
+ onScopeDispose(() => { painter?.dispose(); label.dispose(); });
+ return
+ { content = n ?? null; if (n) painter = createTextPainter(n, style); }} class="absolute inset-0" />
+ { skeleton = n ?? null; }} class="absolute rounded-sm bg-[#6c7785]"
+ style={{ insetL: 0, insetT: Math.round((height - size * 0.55) / 2), width: Math.min(width - 4, size * (bold ? 5 : 2.25)), height: Math.round(size * 0.55), opacity: 0 }} />
+ ;
+}
diff --git a/apps/clear/rows.tsx b/apps/clear/rows.tsx
index dd8a20221..0997f2e72 100644
--- a/apps/clear/rows.tsx
+++ b/apps/clear/rows.tsx
@@ -7,6 +7,7 @@
import { shallowRef, type ShallowRef } from "vue";
import { Text, View, type NodeMirror } from "@pocketjs/framework/components";
import { jump } from "@pocketjs/framework/animation";
+import { remoteText } from "./remote-text.tsx";
import { ROW_H } from "./metrics.ts";
/** Off-canvas parking spot for unassigned row slots. */
@@ -31,6 +32,8 @@ export interface RowSlot {
/** Measured title width (strike-through line length). */
textW: number;
textFor: string;
+ textVisible: boolean;
+ textPriority: number;
}
export function makeSlots(count: number): RowSlot[] {
@@ -50,6 +53,8 @@ export function makeSlots(count: number): RowSlot[] {
gradTo: "",
textW: 0,
textFor: "",
+ textVisible: false,
+ textPriority: 2,
}));
}
@@ -106,9 +111,8 @@ export function renderRow(slot: RowSlot) {
-
- {slot.text.value}
-
+ {remoteText(() => slot.text.value ?? "", 296, 20, () => slot.done.value ? "#666666" : "#ffffff", true,
+ undefined, () => slot.textVisible, () => slot.textPriority)}
{
diff --git a/contracts/spec/ime.ts b/contracts/spec/ime.ts
new file mode 100644
index 000000000..45790d624
--- /dev/null
+++ b/contracts/spec/ime.ts
@@ -0,0 +1,27 @@
+/** Replayable composition. Left/right move one raw-input character, clamped
+ * at its bounds. Candidate selections are page-relative Rime keys.
+ * The guest owns the transcript; the provider owns dictionaries and conversion. */
+export const IME = Object.freeze({ keys: 128, candidates: 5, select: 0x1000000,
+ selectAbsolute: 0x2000000, browseSize: 15, browseLimit: 512,
+ backspace: 0xff08, enter: 0xff0d, left: 0xff51, right: 0xff53,
+ pageUp: 0xff55, pageDown: 0xff56 });
+export interface ImeSnapshot {
+ preedit: string;
+ commit: string;
+ candidates: string[];
+ page: number;
+ last: boolean;
+ caret: number;
+}
+export interface ImeCandidatePage { offset: number; candidates: string[]; last: boolean }
+export function validImeBrowse(value: unknown): value is { keys: number[]; offset: number } {
+ const v = value as { keys?: unknown; offset?: number } | null;
+ return !!v && validImeKeys(v.keys) && Number.isSafeInteger(v.offset) && v.offset! >= 0 && v.offset! < IME.browseLimit;
+}
+export function validImeKeys(value: unknown): value is number[] {
+ return Array.isArray(value) && value.length <= IME.keys && value.every(k =>
+ Number.isInteger(k) && ((k >= 32 && k <= 126) ||
+ [IME.backspace, IME.enter, IME.left, IME.right, IME.pageUp, IME.pageDown].includes(k) ||
+ (k >= IME.select && k < IME.select + IME.candidates) ||
+ (k >= IME.selectAbsolute && k < IME.selectAbsolute + IME.browseLimit)));
+}
diff --git a/contracts/spec/offload.ts b/contracts/spec/offload.ts
index 56a4e1333..e8a55af28 100644
--- a/contracts/spec/offload.ts
+++ b/contracts/spec/offload.ts
@@ -17,6 +17,9 @@ export interface OffloadOps {
* Foreground is ABGR; alpha comes from coverage. Optional columns provide one
* lowercase hex palette index per pixel column; palette is 1..16 RGB hex colors.
* Coloring uses the same scratch buffer and one upload. Returns a texture handle. */
+ /** 2-bit coverage; width 4..512 in multiples of 4, height 1..128.
+ * Power-of-two envelope: min width 8, min height 16, at most 8192 pixels.
+ * The shared scratch buffer and one-upload-per-frame budget are unchanged. */
uploadCoverage?(base64: string, width: number, height: number, foreground: number, columns?: string, palette?: string): number;
}
export interface OffloadRequest { v: 1; id: number; method: string; payload: string }
diff --git a/contracts/spec/text.ts b/contracts/spec/text.ts
new file mode 100644
index 000000000..a4e245f8c
--- /dev/null
+++ b/contracts/spec/text.ts
@@ -0,0 +1,12 @@
+/** Reusable scalar-font coverage, matching the core's baked cmap model.
+ * Shaped glyph IDs and clusters are a separate provider contract (docs/TEXT_RESOURCES.md). */
+export const TEXT = Object.freeze({ rasterizerRevision: 1, maxCodeUnits: 256, maxGlyphs: 96, maxRasterSize: 48, maxWidth: 64, maxHeight: 128, maxPixels: 8192 });
+export interface TextFace { id: string; mapping: "scalar" }
+export interface TextGlyphRequest { face: string; text: string; size: number; density: number; bold: boolean }
+export interface TextGlyph { face: string; advance: number; xoff: number; width: number; height: number; mask: string }
+export function validTextGlyph(value: unknown): value is TextGlyphRequest {
+ const v = value as TextGlyphRequest | null;
+ return !!v && typeof v.face === "string" && /^[a-f0-9]{64}$/.test(v.face) && typeof v.text === "string" &&
+ Array.from(v.text).length === 1 && !/[\uD800-\uDFFF]/u.test(v.text) && Number.isInteger(v.size) && v.size >= 8 &&
+ Number.isInteger(v.density) && v.density >= 1 && v.density <= 3 && v.size * v.density <= TEXT.maxRasterSize && typeof v.bold === "boolean";
+}
diff --git a/docs/CLEAR_IME.md b/docs/CLEAR_IME.md
new file mode 100644
index 000000000..23fdd1817
--- /dev/null
+++ b/docs/CLEAR_IME.md
@@ -0,0 +1,146 @@
+# Clear IME and USB companions
+
+Clear supports **application text composition** on the iPod touch 4 and Moto G Play 2024. This is a PocketJS editor API, not an iOS system keyboard extension or Android `InputMethodService`.
+
+## Ownership
+
+| Example | Device owns | Companion owns |
+| --- | --- | --- |
+| Pocket Doc | Page selection, scrolling, resource handles | Library files, document processing, text coverage |
+| Pocket Map | Viewport, gestures, resource lifetime | Map requests, tile processing, resource responses |
+| Pocket Term | Terminal view, input, bounded coverage uploads | A supervisor and authenticated daemon own PTYs; transport workers can reconnect |
+| Clear IME | Keyboard, composition transcript, editor revision, committed text, textures | Rime process, dictionaries, conversion, CJK rasterization |
+
+The inspected source revisions are [Pocket Doc `host/serve.ts`](https://github.com/pocket-stack/pocket-doc/blob/b3a0d72a377226ef01ed95c3162eb83c91024805/host/serve.ts), [Pocket Map `host/serve.ts`](https://github.com/pocket-stack/pocket-map/blob/457a33568a96bc44c24c4f7a7cc55cd2d2cba592/host/serve.ts), and [Pocket Term `host/serve.ts`](https://github.com/pocket-stack/pocket-term/blob/34f27c816ac1903ed3d53b08f8f81f5ec2ec43b4/host/serve.ts), alongside PocketJS `tools/companion-session.ts`, `tools/offload-provider.ts`, and `framework/src/offload.ts`.
+
+**The render thread never opens the companion socket or reads a dictionary.** `hosts/shared/offload_posix.c` owns a pthread, loopback listener, key-file reads, socket authentication and transfers. The guest submits and drains fixed-capacity queues. Each record has a connection generation; a later connection cannot consume a previous connection's response. The 3DS and POSIX hosts share the queue and coverage decoder.
+
+The v1 offload contract bounds records to **4,096 bytes**, pending requests to **8**, accepted submissions to **2 per frame**, deliveries to **1 per frame**, and coverage uploads to **1 per frame**. These are bounded handoffs; network or provider latency can delay candidates. No network latency guarantee follows from the frame budgets.
+
+`tools/ime/serve.ts` owns one native Rime process and an authenticated loopback HTTP endpoint. A per-connection Worker exposes allowlisted methods:
+
+- `ime.compose`: evaluate a transcript of at most 128 key actions; return preedit, UTF-16 caret position, cumulative commit, page state and at most five candidates.
+- `ime.candidates`: read a window of at most 15 candidates without changing the composition transcript. The guest retains at most 512 candidates and fences windows by revision.
+- `text.font` and `text.glyph`: identify the font rendition and return reusable scalar glyph metrics and coverage. The framework owns the cache and uploads. `text.tile` remains available for older guests.
+
+**Rime evaluates each transcript in a fresh session with user learning disabled.** Replaying the same input therefore avoids repeated dictionary mutations. The guest fences replies by editor revision, retains its transcript during disconnection and applies the new suffix of the cumulative commit once. Closing or cancelling the editor rejects pending commits. This replay policy belongs to IME; it does not change the transport's no-replay policy for sent mutations such as terminal input.
+
+The native API comes from [librime](https://github.com/rime/librime/blob/master/src/rime_api.h). `tools/ime/setup.ts` pins the Luna Pinyin, Prelude and Essay dictionary revisions. Schema compilation, dictionary storage, OpenCC and system CJK font access stay on the Mac. System fonts and dictionary artifacts are not packaged into the applications.
+
+An editor creates one client and advances it from its frame callback. For a Solid application, API ownership is:
+
+```ts
+import { createIme, IME } from "@pocketjs/framework/ime";
+import { onFrame } from "@pocketjs/framework/lifecycle";
+import { onCleanup } from "solid-js";
+
+const ime = createIme({ changed: renderComposition, commit: insertAtCaret });
+onFrame(() => ime.step());
+onCleanup(() => ime.dispose());
+// Keyboard handlers call ime.key(code), ime.key(IME.backspace),
+// ime.select(pageRelativeIndex), or ime.reset().
+```
+
+`renderComposition` receives pending/connected/error state alongside the snapshot. `insertAtCaret` receives committed text. The application supplies both callbacks and owns its text model.
+
+## Setup on this Mac
+
+Install Bun, the repository dependencies, Homebrew `librime`, and the platform tools described below. Then run:
+
+```sh
+bun install --frozen-lockfile
+brew install librime
+bun ime:setup
+bun tools/ime/verify.ts
+```
+
+`ime:setup` writes generated data to ignored `.pocket/ime/`. Apple Silicon Homebrew defaults to `/opt/homebrew`; `POCKETJS_RIME_PREFIX` selects another prefix. The companion uses `/System/Library/Fonts/STHeiti Medium.ttc`; direct `tools/ime/serve.ts --font=` selects another installed CJK font.
+
+## iPod touch 4
+
+Follow [IPODTOUCH4.md](IPODTOUCH4.md) for the pinned iOS 6 toolchain, SSH and User-app installation prerequisites. This path requires the prepared device's existing jailbreak and AppSync. Select its exact USB identifier:
+
+```sh
+export POCKETJS_IPODTOUCH4_UDID=
+bun ipodtouch4 doctor
+bun ipodtouch4 deploy
+bun ipodtouch4 launch
+bun clear:companion ipodtouch4 --id="$POCKETJS_IPODTOUCH4_UDID"
+```
+
+The companion command verifies `iPod4,1`, opens USB forwards for SSH and port 8741, finds the installed Clear User-app container, provisions `Documents/offload.key`, verifies its readback and starts the provider. It uses the SSH host identity established by the iPod preparation workflow. Clear retains its native **320×480 logical / 640×960 physical** viewport.
+
+In a second terminal:
+
+```sh
+bun ipodtouch4 status
+bun ipodtouch4 capture
+```
+
+## Moto G Play 2024
+
+The tested device is `fogona`, Android 14, with a **720×1600 physical** display. The host uses arm64, GLES2, QuickJS-C, the shared Rust renderer, native multi-touch and a **360×800 logical** viewport. Clear's keyboard follows the viewport width; gestures continue through the shared input contracts.
+
+Install Android command-line tools, Java 17 and Rust. Enable USB debugging and authorize this Mac on the device. The default SDK is `/opt/homebrew/share/android-commandlinetools`; `POCKETJS_ANDROID_SDK_ROOT` selects another location. `JAVA_HOME` selects Java 17. The setup command installs platform 34, build tools 35.0.0, NDK 27.1.12297006, the pinned QuickJS revision and the Rust target. Install the pinned Rust toolchain before setup:
+
+```sh
+rustup toolchain install nightly-2026-07-02 --profile minimal
+adb devices -l
+bun moto-g-play setup
+bun moto-g-play doctor
+bun moto-g-play build
+bun moto-g-play deploy --id=
+bun moto-g-play launch --id=
+bun clear:companion moto-g-play --id=
+```
+
+The device commands reject a different model. Deployment installs `dev.pocket_stack.clear` and verifies the installed APK SHA-256 against the local build. The development APK permits `run-as`, which writes the key to the app's private files directory. This package targets Android 14 and keeps the signing key in the local toolchain cache for replacement installs.
+
+```sh
+bun moto-g-play status --id=
+bun moto-g-play capture --id=
+```
+
+The two companions can run at the same time: Mac ports 18741 and 28741 forward to each device's loopback port 8741. Each device has its own 256-bit key in a mode-0600 ignored file. Pairing receipts contain a fingerprint, never the key. Stopping a companion leaves local scrolling and editing available. Restoring it resumes the current composition; offline conversion requires the Mac to return.
+
+## Editing
+
+Tap a list, then a row. In PY mode, type pinyin and tap a candidate. Space or Return selects the first candidate; Return after composition closes the editor. The downward arrow expands a scrollable candidate panel over the keyboard; the upward arrow collapses it. Dragging scrolls with inertia, while tapping selects a candidate. Long candidates continue across lines. Tap the left or right half of the preedit to move its caret. The plain cross cancels composition. **The cross and disclosure have 44-point touch targets and appear only during PY composition.** The globe changes between PY and EN; numbers, symbols, shift and committed-text deletion remain available.
+
+**The permanent candidate bar is 44 points high.** It holds three candidates and the two composition controls. Space displays `拼音` or `EN`. During composition, a 12-point preedit label sits at the left above the candidate bar, separated by four points. Its 20-point box follows the measured text width, with a viewport-width cap. The editor adjusts its lift when this label appears or disappears. The expanded panel continues after the three inline candidates; if an inline phrase needs more width, the panel includes it with wrapping. Key rows have six points of vertical spacing and retain their 40-point cap height.
+
+**Space has a 200 ms virtual-time hold threshold.** The cap stays pressed from touch-down through trackpad activation, then returns to its resting colors on release or cancellation. A short tap uses the same held state until release. The trackpad indicator is a recessed slot with a bevelled grip. Horizontal movement advances one Unicode code point per 10 logical pixels; a 2-pixel hysteresis band prevents direction changes from finger jitter. The grip uses fixed shading during dragging. During composition the same gesture moves the Rime preedit caret. Releasing Space exits without inserting a space; a short tap inserts a space or selects the first candidate. A second key pressed before a short Space releases preserves input order.
+
+**Composition caret steps clamp to the raw input bounds.** The companion implements Left and Right through Rime's `get_caret_pos` and `set_caret_pos`, moving one UTF-8 character boundary per action. Schema navigation keys can move by syllable or wrap at a segment boundary; the trackpad does not call that navigator. The returned caret remains a UTF-16 offset in the formatted preedit, whose added syllable spaces are presentation text. Revision checks prevent delayed replies from replacing a newer position.
+
+**The expanded candidate panel culls the covered keyboard subtree.** Its opacity becomes zero while its editor and key state remain mounted. Inactive key layers also have zero opacity. The shared renderer skips these subtrees before emitting draw commands; collapsing the panel restores the active layer.
+
+**Scrolling retains the mounted slots of candidates that remain visible.** Departing cells supply slots for entering rows. Content and the scrollbar use draw transforms; scrolling within a row does not change layout offsets. Shared text resources invalidate readers of the changed glyph and rebuild demand plans when the working set changes. See [the text resource mechanism](TEXT_RESOURCES.md).
+
+**Backspace deletes on press, then repeats after 430 ms.** Repeats are 85 ms apart and accelerate to 50 ms after two seconds. Release, cancellation, leaving the held key or closing the editor stops repeat. Deadlines use the PocketJS virtual clock; processing a delayed frame emits at most two repeats. The key popup remains opaque for at least 240 ms from press and 140 ms from release, then fades over 120 ms. A held character keeps its popup until release.
+
+Keyboard icons are authored filled SVG contours in `apps/clear/`. The globe uses a circular outline, curved meridians and latitude lines, with the iOS 6 [iPod touch user guide, page 127](https://cdsassets.apple.com/live/6GJYWVAV/user/ma1657_ipod_touch_ios6_user_guide.pdf) as a shape reference. They are baked at native density with supersampled coverage. **`images.json` enables bilinear sampling for all five active icons**, preserving edge coverage when a 32-point asset is displayed at 24–26 points. The shared renderer uses density-scaled masks for small rounded gradients; the center uses clipped gradient quads with the same stops. Mask and center commands form separate batches, with no per-row texture switches.
+
+**Pending conversion uses a low-contrast skeleton.** It starts after 80 ms of virtual time, fades in over 180 ms, then breathes between opacity 0.42 and 0.58 over a 1.8-second cycle. The last confirmed preedit stays visible without an appended ellipsis. Pending candidates cannot be selected against newer input. Glyph misses use placeholders in the missing cells; they do not hide resident text.
+
+**CJK glyphs use an alphabetic baseline derived from font and ink ascent.** The label reserves eight logical pixels beyond the font size for ascent, descent and leading; a 16-point candidate has a 24-point line box. A glyph arrives in one bounded coverage envelope. Deleting or moving resident characters reuses their metrics and textures, including while offline. See [text resources and the general shaping architecture](TEXT_RESOURCES.md). Updating this path requires rebuilding the device app and restarting the Mac companion.
+
+`Offline (queued)` means the device retains the current transcript. A composition is bounded to 128 actions; the cancel control clears it if the limit is reached. Clear retains its existing 40-code-point title limit and in-memory demo list model. It does not add list persistence or dictionary learning.
+
+## Validation
+
+`bun run test` includes shared queue/authentication/generation tests, IME revision/reconnect tests, text cache and pixel continuity tests, candidate scroll/selection tests and the Moto viewport plan test. `bun tools/ime/verify.ts` requires the built native Rime data and checks Chinese phrases, candidate selection, deterministic replay, paging, read windows, absolute selection, backspace, caret movement, raw commit and space selection.
+
+**Native validation covered composition, candidate scrolling and selection, caret bounds, offline deletion and reconnect on both devices.** Deployment included byte readback on iPod and APK hash readback on Moto. The implementation's test results, measured performance and selected screenshots are recorded in [PR #396](https://github.com/pocket-stack/pocketjs/pull/396).
+
+To repeat device acceptance after building, installing and starting the companion:
+
+1. Open a list row, type `ni`, expand the candidate panel, scroll, then tap a candidate. Drag release must leave composition active; the later tap commits the selected candidate and restores the keyboard.
+2. Type `haha`, hold Space and drag past each end of the preedit. The caret must stay at the input boundary. Releasing the hold must not insert a space or commit a candidate.
+3. Hold Backspace, then release. Deletion must repeat while held and stop on release. Check the pressed Space cap, mode label and character popup through their transitions.
+4. Commit `你好`, disconnect the companion and delete `好`. The remaining Latin and Han text must retain its pixels. Enter another composition while disconnected, restore the companion and select a candidate; its committed suffix must appear once.
+5. Switch between empty PY and EN. Both modes must hide the composition cross and disclosure; Space must show the active mode.
+
+**Static panels and sustained scrolling require separate timing runs.** On iPod, start from a fresh launch, compose `ni`, expand the panel, wait four seconds, then drag 140 logical points over eight seconds in each direction. Sample device status before taking captures. Use distinct 60-frame heartbeat windows with `touch_down=1`; compute delivered FPS as `window_frames × 1,000,000 / window_us`. `frame_us` measures the guest/core frame before presentation, and `submit_us` measures GL submission. Keep first-pass and reverse-pass results separate. Window means do not establish frame-time percentiles or physical-finger response times.
+
+Capture commands write to ignored `dist/` output. Keep per-run screenshots, logs and device receipts under `.pocket-build/clear-validation/`; attach selected images and a validation summary to the PR. Versioned image fixtures belong with the tests that consume them. iPod capture reads the app's rendered frame; Android capture reads the device display. GraphicsServices and ADB input exercise native input routes; physical-finger testing remains a separate check.
diff --git a/docs/IPODTOUCH4.md b/docs/IPODTOUCH4.md
index 5d3345775..d4110e1d8 100644
--- a/docs/IPODTOUCH4.md
+++ b/docs/IPODTOUCH4.md
@@ -13,6 +13,11 @@ The bundled application is Pocket Clear (`apps/clear`), a Vue Vapor guest
whose input is entirely gestures; its acceptance receipt is the
`clear_gesture` action counter.
+Clear also supports **companion-backed Chinese pinyin composition** through
+`io.offload`. The device owns the editor and a bounded input transcript; a
+POSIX worker transfers requests to the Mac, where Rime and a CJK font produce
+candidates and coverage tiles. See [Clear IME setup and device validation](CLEAR_IME.md).
+
## Multi-contact touch
This target is the reason the legacy UIKit runtime tracks a touch slot table
diff --git a/docs/TEXT_RESOURCES.md b/docs/TEXT_RESOURCES.md
new file mode 100644
index 000000000..11ee21775
--- /dev/null
+++ b/docs/TEXT_RESOURCES.md
@@ -0,0 +1,78 @@
+# Text layout and reusable glyph resources
+
+**Editing a label must not invalidate every pixel in that label.** Clear's former `remoteText` implementation keyed coverage by the whole string. Replacing `Tap to Edit 你好` with `Tap to Edit 你` discarded the complete tile grid and showed a skeleton while the companion rasterized another string. The same invalidation occurred when the inline caret moved.
+
+## Current implementation
+
+`@pocketjs/framework/text` owns a realm-wide glyph resource cache and creates bounded line layouts. `@pocketjs/framework/text-view` paints those layouts into a retained child pool. These modules have no Solid, Vue or IME dependency. Clear's Vue adapter supplies lifecycle, color and conversion-wait presentation.
+
+| Owner | Data and work |
+| --- | --- |
+| Editor | Unicode source text, revision, selection and composition |
+| Line layout | Source offsets, advances and positions in logical pixels |
+| Framework text resources | Immutable coverage identity, demand, references, retries and texture disposal |
+| Text painter | Local text spans, positioned coverage images and placeholders for missing cells |
+| Companion text provider | Font-file reads, font metrics and coverage rasterization |
+| Rime provider | Preedit, candidate windows and committed Unicode text |
+
+**A text change updates positions before requesting resources.** Latin spans use the core's baked font metrics and text nodes. Other scalar values use cached advances and coverage. Deleting a resident Han character, moving the caret, or reordering resident Han characters requires no new raster request. A cache miss affects that cell; existing Latin and Han content stays visible. Color and container alignment are presentation state.
+
+The companion exposes `text.font` and `text.glyph` through `tools/text-provider.ts`. `text.font` returns an identity derived from the font contents and rasterizer revision, alongside the declared `scalar` mapping. Glyph identity includes that face, scalar value, logical size, weight and raster density. The first valid face response admits requests. Reconnection verifies the face identity while resident coverage remains usable offline. A changed face creates different cache keys.
+
+**One `createResourceScheduler` owns glyph work per realm.** It allows two concurrent reads, one new read per frame and one materialization per frame. The cache admits at most 96 glyphs and reserves at most 32 KiB per glyph before allocation. Visible layouts pin their demands; the editor and candidate viewport have priority over surrounding rows. Clear supplies its row viewport with one row of overscan. Completed entries remain cached after a label releases them, subject to eviction.
+
+Coverage remains packed at two bits per pixel. The native decoder accepts a power-of-two envelope of at most **8,192 pixels**, using the same 32 KiB scratch buffer and one-upload-per-frame budget. A glyph can use a tall rectangle instead of several 16-pixel strips. This extends the existing coverage operation without adding a renderer opcode. The device app must be rebuilt for the expanded rectangle bounds.
+
+`TextLayout` retains the source string and UTF-16 ranges alongside positioned parts. `createTextPainter` retains the source in the framework mirror for inspection. Glyph handles belong to the resource cache; a painter cannot free a handle borrowed by another label. The current painter preserves Clear's white coverage and dimmed completion palette. Arbitrary glyph colors need per-draw mask tint in the shared core's text contract.
+
+## Framework architecture for shaped text
+
+**Scalar lookup is not a complete Unicode shaping model.** The current core's baked font path maps code points through a cmap and sums advances. The native text backend can install host measurement and wrapping. The implementation above extends the baked scalar path; it does not add bidi resolution, ligatures, contextual Arabic shaping or grapheme-aware editing.
+
+The general text system needs separate source, shaping, glyph and line-layout records:
+
+```mermaid
+flowchart LR
+ Source[Text buffer and selection] --> Runs[Script and direction runs]
+ Runs --> Shape[Shaping and cluster map]
+ Shape --> Lines[Line breaks and caret positions]
+ Shape --> Glyphs[Shared glyph resources]
+ Lines --> Paint[Retained text paint]
+ Glyphs --> Paint
+ Provider[Local worker or companion] --> Shape
+ Provider --> Glyphs
+```
+
+| Record | Identity and contents |
+| --- | --- |
+| Text buffer | Unicode source, revision, composition range and selection |
+| Shaping request | Source range, surrounding context, font-set revision, script, language, direction and features |
+| Shaped run | Source-to-cluster map, glyph IDs, advances, offsets and boundaries requiring reshaping |
+| Glyph resource | Font content revision, face index, glyph ID, variation axes, rasterizer revision, pixel size and sampling settings |
+| Line layout | Run references, line breaks, baselines, visual positions and caret stops |
+
+**Glyph IDs and source characters are different identities.** A ligature can cover several characters; a character can produce several glyphs. HarfBuzz exposes clusters and flags boundaries that require reshaping after a break. Editing boundaries also need Unicode grapheme segmentation. These facts prevent a universal implementation from treating `Array.from(text)` as a shaping or editing algorithm. See the [HarfBuzz shaping guide](https://harfbuzz.github.io/getting-started.html) and [Unicode text segmentation](https://www.unicode.org/reports/tr29/).
+
+The portable implementation should place segmentation, shaping, line breaking and cluster-to-caret mapping in one Rust module compiled for native hosts and WASM. A capable host runs it in a worker; a constrained host sends the same bounded requests to a companion. The guest receives plain records with source revisions and positions, never a platform font object. Rime continues to return Unicode text and candidates; it does not own font selection or layout.
+
+An edit invalidates the affected shaping runs and any context required by their shaping boundaries. Unaffected runs retain their metrics and glyph references. A line-width change can reuse shaping and glyph resources while recomputing line breaks. A color change affects paint alone. A density change can request another raster rendition without changing logical caret positions.
+
+**No deleted character may remain visible while a replacement is pending.** The source model applies the edit at the input edge. For an independent resident cluster, local layout removes it and moves the surviving glyphs in the same update. For contextual text, the shaper determines the affected range; the UI retains unaffected runs and confines any temporary presentation to that range. A universal guarantee of zero network work requires a resident shaper and the required font resources, not a whole-string bitmap cache.
+
+## API migration
+
+The cache and painter in this change are usable by any PocketJS UI framework through explicit lifecycle calls. They provide the scalar line path used by Clear on both devices. The next native text contract should accept shaped runs and glyph references through the shared core, so `` can use these resources with the same measurement and paint records. That work also needs glyph-upload admission, native/WASM equivalence tests, cluster-aware selection and wrapping tests. It is separate from the scalar coverage implementation shipped here.
+
+Acceptance for that contract should include Latin kerning and ligatures, Han insertion/deletion, combining marks, emoji sequences, Arabic joining, mixed-direction selection, font fallback, font revision changes, density changes and cache eviction with multiple views. Each case must compare source offsets, metrics and rendered output across native and WASM providers.
+
+## Clear candidate panel
+
+**Candidate browsing is a read, not a composition key.** `createIme.browse(offset)` issues `ime.candidates` against the current bounded transcript. Each response contains at most 15 candidates; the guest retains at most 512 candidate entries. Rime caps the response's text budget. Browsing does not append page keys or change preedit. `selectAbsolute(index)` accepts only candidates obtained for the current revision, then appends one absolute selection action to the transcript. Typing, cancellation and reconnect fence older windows.
+
+The keyboard's 44-point candidate bar shows three candidates and, during PY composition, a plain cross and a disclosure arrow with 44-point targets. Space carries the mode label. A 20-point preedit badge above the candidate bar uses the text layout's measured width; empty composition removes the badge and its editor clearance. The disclosure replaces the key grid with a candidate viewport. This viewport continues after the inline candidates, or includes them when an inline phrase needs more width. Cells wrap according to phrase length; long candidates continue across lines with the same selection index. A fixed view pool presents visible cells and overscan. **Candidates that remain in the viewport retain their mounted slots.** Departing cells supply slots for entering cells; appending a candidate window preserves the existing cell objects. Binary bounds find the visible slice, whose identity stays unchanged between row crossings. The framework kinetic scroller moves the content container; dragging suppresses candidate selection until a later tap. Collapsing the panel restores the keyboard without changing composition.
+
+**A glyph completion invalidates layouts that read that glyph.** Each layout records its glyph identities and resident values. Request start and unrelated glyph completion leave the layout revision unchanged. A font-face change invalidates layouts with remote glyphs; eviction invalidates readers before the next paint. This keeps a candidate arriving from the companion from rebuilding other candidates and the committed editor text.
+
+**Text demand planning runs when glyph identities, visibility, priority or ownership change.** Stable layouts reuse the admitted working set. The scheduler continues to process its bounded starts and completions once per frame. Candidate content and its scrollbar move through `translateY`; scrolling between row crossings does not write layout offsets. An entering row can update its recycled cells without repositioning the surviving rows.
+
+See [Clear setup and controls](CLEAR_IME.md). Regression tests cover raster bounds, shared-cache reuse, deletion while offline, unchanged pixels outside the edit, candidate-window revision fences, scroll cancellation and absolute selection.
diff --git a/engine/core/src/draw.rs b/engine/core/src/draw.rs
index d59aa91a4..84cf0de2e 100644
--- a/engine/core/src/draw.rs
+++ b/engine/core/src/draw.rs
@@ -2135,8 +2135,8 @@ impl<'a> Walker<'a> {
}
// Flat fills: four baked-disc corner sprites + three rects — O(1)
// ops per box instead of per-row coverage spans (the spans cost
- // ~7 ms/frame of PSP CPU on rounded-heavy screens). Gradients keep
- // the exact span path below.
+ // ~7 ms/frame of PSP CPU on rounded-heavy screens). High-density
+ // gradients use tinted mask strips below.
if let Fill::Flat(color) = fill {
let r_px = roundf(r).max(1.0) as u32;
// Bake discs only for small radii: UI corner radii recur and
@@ -2186,6 +2186,52 @@ impl<'a> Walker<'a> {
}
}
}
+ // At high raster density, logical-pixel coverage spans magnify the
+ // corner staircase. Reuse the density-scaled mask, tinting strips in
+ // the gradient's global coordinates. Colour animation shares the same
+ // bounded radius cache; neither the wire format nor layout changes.
+ let r_px = roundf(r).max(1.0) as u32;
+ if self.raster_density > 1 && r_px <= 32 && matches!(fill, Fill::Grad { .. }) {
+ let qx0 = roundf(sx0);
+ let qy0 = roundf(sy0);
+ let qx1 = roundf(sx1);
+ let qy1 = roundf(sy1);
+ // Integer splits avoid overlapping half-pixel strips on odd sizes.
+ let rf = (r_px as f32).min(floorf((qx1 - qx0) * 0.5)).min(floorf((qy1 - qy0) * 0.5));
+ if rf >= 1.0 {
+ if let Some((tex, dim)) = disc_texture(self.discs, self.textures, self.tex_free, r_px, self.raster_density) {
+ let du = (r_px * self.raster_density) as f32 / dim as f32;
+ let vertical = vertical_gradient(&fill);
+ let (a0, a1) = if vertical {
+ (qy0.max(floorf(clip.y0)).max(0.0), qy1.min(ceilf(clip.y1)).min(self.screen.1))
+ } else {
+ (qx0.max(floorf(clip.x0)).max(0.0), qx1.min(ceilf(clip.x1)).min(self.screen.0))
+ };
+ // Group masks into one texture batch. The center uses
+ // clipped full-box gradients: a constant number of quads,
+ // with the same global stops as the corner strips.
+ for a in a0 as i32..a1 as i32 {
+ let strip = if vertical {
+ Clip { x0: clip.x0, y0: clip.y0.max(a as f32), x1: clip.x1, y1: clip.y1.min((a + 1) as f32) }
+ } else {
+ Clip { x0: clip.x0.max(a as f32), y0: clip.y0, x1: clip.x1.min((a + 1) as f32), y1: clip.y1 }
+ };
+ let color = fill_color_at(&fill, sx0, sy0, sx1, sy1, a, a, a + 1, 255);
+ for &(cx, cy, u, v) in &[(qx0, qy0, 0.0, 0.0), (qx1-rf, qy0, du, 0.0),
+ (qx0, qy1-rf, 0.0, du), (qx1-rf, qy1-rf, du, du)] {
+ self.emit_corner_quad(dl, tex, cx, cy, rf, u, v, du, color, &strip);
+ }
+ }
+ for &(x0, y0, x1, y1) in &[(qx0, qy0 + rf, qx1, qy1 - rf),
+ (qx0 + rf, qy0, qx1 - rf, qy0 + rf), (qx0 + rf, qy1 - rf, qx1 - rf, qy1)] {
+ let center = Clip { x0: x0.max(clip.x0), y0: y0.max(clip.y0),
+ x1: x1.min(clip.x1), y1: y1.min(clip.y1) };
+ self.emit_screen_rect(dl, sx0, sy0, sx1, sy1, fill, ¢er);
+ }
+ return;
+ }
+ }
+ }
let ix0 = floorf(sx0).max(floorf(clip.x0)).max(0.0) as i32;
let iy0 = floorf(sy0).max(floorf(clip.y0)).max(0.0) as i32;
let ix1 = ceilf(sx1).min(ceilf(clip.x1)).min(self.screen.0) as i32;
diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs
index ac549a880..07f1ef63f 100644
--- a/engine/core/src/tests.rs
+++ b/engine/core/src/tests.rs
@@ -1047,6 +1047,81 @@ fn rounded_gradients_emit_rect_coverage_spans() {
assert!(via_segment, "the rounded span path must preserve the middle stop");
}
+#[test]
+fn dense_rounded_gradients_share_smooth_masks_and_preserve_global_stops() {
+ for density in [2, 3] {
+ for dir in [spec::GradDir::ToBottom, spec::GradDir::ToTop, spec::GradDir::ToRight, spec::GradDir::ToLeft] {
+ let mut ui = Ui::new_with_raster_density(density);
+ let n = ui.create_node(0);
+ let from = abgr(20, 40, 60, 255);
+ let via = abgr(240, 230, 220, 255);
+ let to = abgr(80, 100, 120, 255);
+ for (prop, value) in [(spec::prop::WIDTH, 36.0), (spec::prop::HEIGHT, 20.0),
+ (spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64),
+ (spec::prop::INSET_L, 10.0), (spec::prop::INSET_T, 10.0), (spec::prop::RADIUS, 6.0),
+ (spec::prop::GRAD_FROM, from as f64), (spec::prop::GRAD_VIA, via as f64),
+ (spec::prop::GRAD_VIA_POS, 0.5), (spec::prop::GRAD_TO, to as f64),
+ (spec::prop::GRAD_DIR, dir as u32 as f64)] { ui.set_prop(n, prop, value); }
+ ui.insert_before(spec::ROOT_ID, n, 0);
+ ui.tick();
+ let words = ui.draw().words.clone();
+ let counts = validate_drawlist(&words);
+ assert_eq!(counts[spec::draw_op::TEX_QUAD as usize], 24, "six strips per corner");
+ assert!(counts[spec::draw_op::GRAD_RECT as usize] <= 6, "center geometry must be bounded per box");
+ let horizontal = matches!(dir, spec::GradDir::ToRight | spec::GradDir::ToLeft);
+ let reverse = matches!(dir, spec::GradDir::ToTop | spec::GradDir::ToLeft);
+ let mut texture = None;
+ let mut center_started = false;
+ let mut i = 0;
+ while i < words.len() {
+ let (xy, wh, color) = match words[i] {
+ spec::draw_op::GRAD_RECT => {
+ center_started = true;
+ let (x, y) = decode_xy(words[i+1]);
+ let (w, h) = decode_wh(words[i+2]);
+ let (a, b, length) = if horizontal { (x, x+w, 36.0) } else { (y, y+h, 20.0) };
+ for (point, color) in [(a, if reverse { words[i+4] } else { words[i+3] }),
+ (b, if reverse { words[i+3] } else { words[i+4] })] {
+ let t = (point as f32 - 10.0) / length;
+ let t = if reverse { 1.0 - t } else { t };
+ let expected = if t < 0.5 { crate::anim::interp(from, via, t * 2.0, true) }
+ else { crate::anim::interp(via, to, (t - 0.5) * 2.0, true) };
+ assert_eq!(color, expected, "clipped center retains full-box gradient stops");
+ }
+ i += 6; continue;
+ }
+ spec::draw_op::TEX_QUAD => {
+ assert!(!center_started, "mask/solid switches must be bounded per box, not per row");
+ let handle = words[i+1] as i32;
+ assert_eq!(*texture.get_or_insert(handle), handle, "all colors share a mask");
+ let view = ui.texture(handle).unwrap();
+ assert!(view.linear);
+ let dim = (12 * density).next_power_of_two();
+ assert_eq!((view.w, view.h), (dim, dim));
+ assert!(view.pixels.chunks_exact(4).any(|p| p[3] > 0 && p[3] < 255));
+ let data = (words[i+2], words[i+3], words[i+8]); i += 9; data
+ }
+ spec::draw_op::SCISSOR => { i += 3; continue; }
+ _ => { i += 1; continue; }
+ };
+ let (x, y) = decode_xy(xy);
+ let (w, h) = decode_wh(wh);
+ let t = if horizontal { assert_eq!(w, 1); (x as f32 + 0.5 - 10.0) / 36.0 }
+ else { assert_eq!(h, 1); (y as f32 + 0.5 - 10.0) / 20.0 };
+ let t = if reverse { 1.0 - t } else { t };
+ let expected = if t < 0.5 { crate::anim::interp(from, via, t * 2.0, true) }
+ else { crate::anim::interp(via, to, (t - 0.5) * 2.0, true) };
+ assert_eq!(color, expected, "corners and center use the same gradient coordinates");
+ }
+ ui.set_prop(n, spec::prop::GRAD_FROM, to as f64);
+ ui.tick();
+ let next = ui.draw().words.clone();
+ let i = next.iter().position(|&w| w == spec::draw_op::TEX_QUAD).unwrap();
+ assert_eq!(Some(next[i+1] as i32), texture, "animated color must not mint textures");
+ }
+ }
+}
+
#[test]
fn overflow_hidden_emits_balanced_intersected_scissors() {
let mut ui = Ui::new();
diff --git a/engine/quickjs-c/offload_qjs.h b/engine/quickjs-c/offload_qjs.h
new file mode 100644
index 000000000..c4b210250
--- /dev/null
+++ b/engine/quickjs-c/offload_qjs.h
@@ -0,0 +1,76 @@
+/* Included by pocket_runtime.c only on hosts with the POSIX worker. */
+#include "offload_posix.h"
+#include "offload_coverage.h"
+#include
+#include
+static unsigned offload_submissions, offload_deliveries, offload_uploads;
+static char offload_key_path[1024];
+static uint8_t offload_pixels[512 * 16 * 4];
+static int offload_bounded_string(JSContext *ctx, JSValueConst value) {
+ if (!JS_IsString(value)) return 0;
+ JSValue count = JS_GetPropertyStr(ctx, value, "length");
+ int32_t length = -1;
+ int ok = JS_ToInt32(ctx, &length, count) == 0 && length >= 0 && length <= 4096;
+ JS_FreeValue(ctx, count);
+ return ok;
+}
+void pocket_runtime_offload_key(const char *path) {
+ if (path && strlen(path) < sizeof offload_key_path) strcpy(offload_key_path, path);
+}
+static JSValue offload_operation(JSContext *ctx, JSValueConst self, int argc, JSValueConst *argv, int op) {
+ (void)self;
+ if (op == 0) return JS_NewUint32(ctx, pocket_offload_session());
+ if (op == 2) {
+ char bytes[4096];
+ if (offload_deliveries++) return JS_UNDEFINED;
+ size_t length = pocket_offload_take(bytes);
+ return length ? JS_NewStringLen(ctx, bytes, length) : JS_UNDEFINED;
+ }
+ if (argc < 1 || !offload_bounded_string(ctx, argv[0])) return JS_UNDEFINED;
+ size_t length;
+ const char *text = JS_ToCStringLen(ctx, &length, argv[0]);
+ if (!text) return JS_EXCEPTION;
+ if (op == 1) {
+ int accepted = offload_submissions < 2 && pocket_offload_submit(text, length);
+ if (accepted) offload_submissions++;
+ JS_FreeCString(ctx, text);
+ return JS_NewBool(ctx, accepted);
+ }
+ uint32_t width = 0, height = 0, color = 0;
+ int texture = 0;
+ if (argc >= 4 && !offload_uploads && JS_ToUint32(ctx, &width, argv[1]) == 0 &&
+ JS_ToUint32(ctx, &height, argv[2]) == 0 && JS_ToUint32(ctx, &color, argv[3]) == 0) {
+ int envelope = coverage_decode(text, length, width, height, color, offload_pixels);
+ if (envelope && argc >= 6 && (!JS_IsUndefined(argv[4]) || !JS_IsUndefined(argv[5]))) {
+ size_t cn = 0, pn = 0;
+ const char *columns = offload_bounded_string(ctx, argv[4]) ? JS_ToCStringLen(ctx, &cn, argv[4]) : NULL;
+ const char *palette = offload_bounded_string(ctx, argv[5]) ? JS_ToCStringLen(ctx, &pn, argv[5]) : NULL;
+ if (!columns || !palette || !coverage_colorize(columns, cn, palette, pn, width, height, (unsigned)envelope, offload_pixels)) envelope = 0;
+ if (columns) JS_FreeCString(ctx, columns);
+ if (palette) JS_FreeCString(ctx, palette);
+ }
+ if (envelope) {
+ unsigned padded_height = coverage_height(height);
+ texture = ui_upload_texture(offload_pixels, (size_t)envelope * padded_height * 4, (unsigned)envelope, padded_height, 3);
+ offload_uploads++;
+ }
+ }
+ JS_FreeCString(ctx, text);
+ return JS_NewInt32(ctx, texture);
+}
+static int install_offload(JSContext *ctx, JSValue target) {
+ JSValue ops = JS_NewObject(ctx);
+ const char *names[] = {"session", "submit", "take", "uploadCoverage"};
+ int arities[] = {0, 1, 0, 6};
+ for (int i = 0; i < 4; i++) {
+ JSValue fn = JS_NewCFunctionMagic(ctx, offload_operation, names[i], arities[i], JS_CFUNC_generic_magic, i);
+ if (JS_SetPropertyStr(ctx, ops, names[i], fn) < 0) { JS_FreeValue(ctx, ops); return 0; }
+ }
+ if (JS_SetPropertyStr(ctx, target, "offload", ops) < 0) return 0;
+ if (!offload_key_path[0]) {
+ const char *directory = getenv("HOME");
+ if (directory) snprintf(offload_key_path, sizeof offload_key_path, "%s/Documents/offload.key", directory);
+ }
+ pocket_offload_start(offload_key_path, 8741);
+ return 1;
+}
diff --git a/engine/quickjs-c/pocket_runtime.c b/engine/quickjs-c/pocket_runtime.c
index 388acafca..8f7b6eae7 100644
--- a/engine/quickjs-c/pocket_runtime.c
+++ b/engine/quickjs-c/pocket_runtime.c
@@ -10,6 +10,9 @@
#include
#include
#include
+#ifdef POCKET_OFFLOAD_POSIX
+#include "offload_qjs.h"
+#endif
#ifndef POCKETJS_TARGET_ID
#error "POCKETJS_TARGET_ID must come from the verified ResolvedBuildPlan"
@@ -488,6 +491,9 @@ static int add_host_operation(
}
static int install_host(int width, int height) {
+#ifdef POCKET_OFFLOAD_POSIX
+ if (!install_offload(context, global)) return 0;
+#endif
JSValue ui = JS_NewObject(context);
if (JS_IsException(ui)) return 0;
if (!add_host_operation(context, ui, "createNode", 1, HostCreateNode) ||
@@ -575,6 +581,9 @@ static int drain_jobs(void) {
}
void pocket_runtime_shutdown(void) {
+#ifdef POCKET_OFFLOAD_POSIX
+ pocket_offload_stop();
+#endif
if (context != 0) {
#if defined(POCKET_RUNTIME_HARNESS)
if (!JS_IsUndefined(harness_function)) JS_FreeValue(context, harness_function);
@@ -703,6 +712,9 @@ static int run_frame(
) {
unsigned int tick;
unsigned int index;
+#ifdef POCKET_OFFLOAD_POSIX
+ offload_submissions = offload_deliveries = offload_uploads = 0;
+#endif
if (runtime == 0 || context == 0 || runtime_failed) return 0;
#ifdef POCKET_SVC_WIRE
/* Bounded, non-blocking: discovery, connect, rx and tx progress once per
diff --git a/engine/quickjs-c/pocket_runtime.h b/engine/quickjs-c/pocket_runtime.h
index 10b44301e..d4cd4068a 100644
--- a/engine/quickjs-c/pocket_runtime.h
+++ b/engine/quickjs-c/pocket_runtime.h
@@ -19,6 +19,10 @@
void pocket_bench_stage(int stage);
#endif
+#ifdef POCKET_OFFLOAD_POSIX
+void pocket_runtime_offload_key(const char *path);
+#endif
+
int pocket_runtime_boot(
const char *java_script,
size_t java_script_length,
diff --git a/framework/compiler/subpaths.ts b/framework/compiler/subpaths.ts
index b611da78b..3e9a6a36d 100644
--- a/framework/compiler/subpaths.ts
+++ b/framework/compiler/subpaths.ts
@@ -58,6 +58,10 @@ export const SUBPATHS: Record = {
classic: { file: { solid: "framework/src/classic.ts" } },
"offload/provider": { file: "tools/offload-provider.ts" },
"offload/capabilities": { file: "tools/offload-capabilities.ts" },
+ ime: { file: "framework/src/ime.ts", aliases: TWINS },
+ text: { file: "framework/src/text.ts", aliases: TWINS },
+ "text-view": { file: "framework/src/text-view.ts", aliases: TWINS },
+ "text/provider": { file: "tools/text-provider.ts" },
offload: { file: "framework/src/offload.ts", aliases: TWINS },
"resource-state": { file: "framework/src/resource-state.ts", aliases: TWINS },
"resource-cache": { file: "framework/src/resource-cache.ts", aliases: TWINS },
diff --git a/framework/src/ime.ts b/framework/src/ime.ts
new file mode 100644
index 000000000..dfdd4ecf6
--- /dev/null
+++ b/framework/src/ime.ts
@@ -0,0 +1,106 @@
+import { IME, validImeKeys, type ImeSnapshot, type ImeCandidatePage } from "../../contracts/spec/ime.ts";
+import { offload } from "./offload.ts";
+export { IME };
+export type { ImeSnapshot, ImeCandidatePage };
+type Channel = Pick, "request" | "cancel" | "session">;
+export type ImeState = ImeSnapshot & { pending: boolean; connected: boolean; error: string; revision: number; composing: boolean };
+const empty = (): ImeSnapshot => ({ preedit: "", commit: "", candidates: [], page: 0, last: true, caret: 0 });
+
+/** One editor owns one bounded transcript. A reconnect recomputes the current
+ * transcript, and revision checks fence callbacks from a closed or edited field. */
+export function createIme(options: {
+ io?: Channel;
+ changed(state: ImeState): void;
+ commit(text: string): void;
+}) {
+ const io = options.io ?? offload();
+ let keys: number[] = [], revision = 0, request = 0, requestRevision = -1;
+ let session = 0, applied = "", dirty = false, snapshot = empty(), error = "";
+ let retry = 0;
+ const browsing = new Set(), knownCandidates = new Map();
+ function clearBrowsing() { for (const id of browsing) io.cancel(id); browsing.clear(); knownCandidates.clear(); }
+ const notify = () => options.changed({ ...snapshot, pending: dirty || request > 0,
+ connected: io.session() > 0, error, revision, composing: keys.length > 0 });
+ const api = {
+ composing: () => keys.length > 0,
+ state: (): ImeState => ({ ...snapshot, pending: dirty || request > 0, connected: io.session() > 0, error, revision, composing: keys.length > 0 }),
+ key(key: number) {
+ if (!validImeKeys([key])) return false;
+ if (keys.length >= IME.keys) { error = "Composition limit reached"; notify(); return false; }
+ clearBrowsing(); keys.push(key); revision++; dirty = true; error = "";
+ // Old candidate labels are never selectable against a newer transcript.
+ snapshot = { ...snapshot, candidates: [] };
+ notify(); return true;
+ },
+ select(index: number) {
+ if (dirty || request || index < 0 || index >= snapshot.candidates.length) return false;
+ return api.key(IME.select + index);
+ },
+ /** A read-only window. Browsing does not change preedit or consume keys. */
+ browse(offset: number, complete: (page: ImeCandidatePage | null) => void): number {
+ if (dirty || request || !keys.length || io.session() <= 0 || browsing.size >= 2 ||
+ !Number.isSafeInteger(offset) || offset < 0 || offset >= IME.browseLimit) return 0;
+ const version = revision;
+ const id = io.request("ime.candidates", JSON.stringify({ keys, offset }), result => {
+ browsing.delete(id);
+ if (version !== revision) return;
+ if (result.ok) try {
+ const page = JSON.parse(result.value) as ImeCandidatePage;
+ if (page.offset !== offset || !Array.isArray(page.candidates) || page.candidates.length > IME.browseSize ||
+ offset + page.candidates.length > IME.browseLimit || typeof page.last !== "boolean" ||
+ page.candidates.some(c => typeof c !== "string" || c.length > 128) || (!page.last && !page.candidates.length)) throw new Error();
+ page.candidates.forEach((value, i) => knownCandidates.set(offset + i, value));
+ complete(page); return;
+ } catch { /* Invalid windows never enter the selectable set. */ }
+ complete(null);
+ });
+ if (id) browsing.add(id);
+ return id;
+ },
+ selectAbsolute(index: number) {
+ if (dirty || request || !knownCandidates.has(index)) return false;
+ return api.key(IME.selectAbsolute + index);
+ },
+ reset() {
+ clearBrowsing();
+ if (request) io.cancel(request);
+ request = 0; revision++; keys = []; applied = ""; dirty = false; snapshot = empty(); error = ""; notify();
+ },
+ /** Called by the editor once per frame, after the realm offload pump. */
+ step() {
+ const current = io.session();
+ if (current !== session) {
+ clearBrowsing(); revision++;
+ session = current;
+ if (request) io.cancel(request);
+ request = 0; dirty = keys.length > 0; retry = 0; notify();
+ }
+ if (retry > 0) { retry--; return; }
+ if (!dirty || request || current <= 0) return;
+ const version = revision; requestRevision = version;
+ request = io.request("ime.compose", JSON.stringify(keys), result => {
+ if (requestRevision !== version) return;
+ request = 0;
+ if (revision !== version) return;
+ if (!result.ok) { error = result.error; retry = 60; notify(); return; }
+ try {
+ const next = JSON.parse(result.value) as ImeSnapshot;
+ if (typeof next.preedit !== "string" || next.preedit.length > 256 ||
+ typeof next.commit !== "string" || next.commit.length > 512 ||
+ !next.commit.startsWith(applied) || !Array.isArray(next.candidates) ||
+ next.candidates.length > IME.candidates || next.candidates.some(c => typeof c !== "string" || c.length > 128) ||
+ !Number.isSafeInteger(next.page) || next.page < 0 || !Number.isInteger(next.caret) ||
+ next.caret < 0 || next.caret > next.preedit.length || typeof next.last !== "boolean") throw new Error("Invalid IME snapshot");
+ const suffix = next.commit.slice(applied.length);
+ applied = next.commit; snapshot = next; dirty = false; error = "";
+ next.candidates.forEach((value, i) => knownCandidates.set(next.page * IME.candidates + i, value));
+ if (suffix) options.commit(suffix);
+ if (!next.preedit) { keys = []; applied = ""; snapshot = empty(); }
+ } catch { error = "Invalid IME reply"; retry = 60; }
+ notify();
+ });
+ },
+ dispose() { api.reset(); },
+ };
+ return api;
+}
diff --git a/framework/src/text-view.ts b/framework/src/text-view.ts
new file mode 100644
index 000000000..5980faea9
--- /dev/null
+++ b/framework/src/text-view.ts
@@ -0,0 +1,47 @@
+import { createElement, createTextNode, insertNode, removeNode, replaceText, setProp, type NodeMirror } from "./native-tree.ts";
+import { getOps } from "./host.ts";
+import type { TextLayout, TextStyle } from "./text.ts";
+
+/** Bounded child pool, owned by the supplied view. Does not own glyph handles. */
+export function createTextPainter(parent: NodeMirror, style: TextStyle) {
+ const nodes: { node: NodeMirror; kind: string; label?: NodeMirror; text?: string; handle?: number; style?: Record }[] = [];
+ let revision = -1, lastColor = "";
+ return {
+ paint(layout: TextLayout, color: string) {
+ if (revision === layout.revision && color === lastColor) return;
+ revision = layout.revision; lastColor = color;
+ // Retain source text in the mirror for inspection; native paint uses the
+ // positioned children, and source offsets remain in TextLayout.parts.
+ parent.text = layout.text;
+ const parts = layout.parts.filter(p => p.x < style.width);
+ while (nodes.length > parts.length) removeNode(parent, nodes.pop()!.node);
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i], kind = part.kind === "local" ? "text" : part.glyph ? "image" : "view";
+ if (nodes[i] && nodes[i].kind !== kind) { removeNode(parent, nodes[i].node); nodes[i] = undefined!; }
+ if (!nodes[i]) {
+ const node = createElement(kind === "text" ? "view" : kind);
+ const label = kind === "text" ? createTextNode(part.text) : undefined;
+ if (label) insertNode(node, label);
+ insertNode(parent, node); nodes[i] = { node, label, kind, text: kind === "text" ? part.text : undefined };
+ }
+ const cell = nodes[i];
+ const props: Record = { posType: 1, insetL: part.x, insetT: 0 };
+ if (part.kind === "local") {
+ if (cell.text !== part.text) { replaceText(cell.label!, part.text); cell.text = part.text; }
+ Object.assign(props, { width: part.width, height: style.size + 8, flexDir: 0, align: 1 });
+ setProp(cell.label!, "style", { fontSlot: style.fontSlot, textColor: color });
+ } else if (part.glyph) {
+ const glyph = part.glyph;
+ Object.assign(props, { insetL: part.x - glyph.xoff, width: glyph.envelope / style.density,
+ height: glyph.height / style.density, opacity: color === "#666666" ? 0.4 : 1 });
+ if (cell.handle !== glyph.handle) { getOps().setImage(cell.node.id, glyph.handle); cell.handle = glyph.handle; }
+ } else {
+ Object.assign(props, { insetL: part.x + 2, insetT: (style.size + 8 - 7) / 2, width: Math.max(4, part.width - 4),
+ height: 7, bgColor: "#6c7785", radius: 2, opacity: 0.4 });
+ }
+ setProp(cell.node, "style", props, cell.style); cell.style = props;
+ }
+ },
+ dispose() { for (const { node } of nodes) removeNode(parent, node); nodes.length = 0; },
+ };
+}
diff --git a/framework/src/text.ts b/framework/src/text.ts
new file mode 100644
index 000000000..5e27ccc31
--- /dev/null
+++ b/framework/src/text.ts
@@ -0,0 +1,150 @@
+import { TEXT, type TextFace, type TextGlyph, type TextGlyphRequest } from "../../contracts/spec/text.ts";
+import { createResourceScheduler, type ResourceDemand } from "./resource-cache.ts";
+import { offloadResource } from "./resource-offload.ts";
+import { offload, uploadCoverage } from "./offload.ts";
+import { getOps } from "./host.ts";
+import { registerServicePump } from "./services.ts";
+
+export type { TextFace, TextGlyphRequest };
+export interface ResidentGlyph extends Omit { handle: number; envelope: number }
+export type TextPart = { text: string; x: number; width: number; start: number; end: number } &
+ ({ kind: "local" } | { kind: "glyph"; glyph?: ResidentGlyph });
+export interface TextLayout { text: string; parts: TextPart[]; width: number; pending: boolean; revision: number }
+export interface TextStyle { size: number; density: number; bold: boolean; fontSlot: number; width: number }
+type Channel = Pick, "request" | "cancel" | "session">;
+
+/** Realm-owned immutable glyph resources. Layout reads never submit I/O. */
+export function createTextResources(options: {
+ io: Channel;
+ measure(text: string, slot: number): number;
+ upload(mask: string, width: number, height: number): number | undefined;
+ free(handle: number): void;
+ maxGlyphs?: number;
+}) {
+ const io = options.io, limit = options.maxGlyphs ?? TEXT.maxGlyphs;
+ if (!Number.isInteger(limit) || limit < 1 || limit > TEXT.maxGlyphs) throw new Error("Invalid text cache budget");
+ let face = "", session = 0, faceRequest = 0, facePending = false, retry = 0, dead = false;
+ let demandsDirty = true;
+ const owners = new Set<{ demands: ResourceDemand[]; glyphs: Map; stale: boolean }>();
+ const key = (r: TextGlyphRequest) => `${r.face}/${r.size}/${r.density}/${+r.bold}/${r.text}`;
+ const scheduler = createResourceScheduler({ maxCollections: 1, maxConcurrent: 2, startsPerFrame: 1, completionsPerFrame: 1,
+ available: () => io.session() > 0 && !facePending });
+ const cache = scheduler.createCache({
+ key, maxEntries: limit, maxCost: limit * TEXT.maxPixels * 4, maxResponseBytes: 7800,
+ cost: () => TEXT.maxPixels * 4,
+ load: offloadResource(io, "text.glyph", r => JSON.stringify(r)),
+ materialize(raw, request) {
+ const g = JSON.parse(raw) as TextGlyph;
+ if (g.face !== request.face || !Number.isFinite(g.advance) || g.advance < 0 || g.advance > 64 ||
+ !Number.isFinite(g.xoff) || g.xoff < 0 || g.xoff > 32 || !Number.isInteger(g.width) || g.width < 4 ||
+ g.width > TEXT.maxWidth || g.width % 4 || !Number.isInteger(g.height) || g.height < 16 ||
+ g.height > TEXT.maxHeight || (g.height & (g.height - 1)) || typeof g.mask !== "string" ||
+ g.mask.length !== Math.ceil(g.width * g.height / 12) * 4) throw new Error("Invalid glyph coverage");
+ const handle = options.upload(g.mask, g.width, g.height);
+ if (handle === undefined || handle <= 0) throw new Error("Glyph upload unavailable");
+ const { mask, ...metrics } = g;
+ return { ...metrics, handle, envelope: Math.max(8, 2 ** Math.ceil(Math.log2(g.width))) };
+ },
+ dispose: g => options.free(g.handle), changed: request => {
+ const id = key(request), state = cache.state(request), glyph = state.status === "ready" ? state.value : undefined;
+ // A request starting is not a layout change. Only readers of coverage
+ // whose resident value changed need to rebuild and repaint their parts.
+ for (const owner of owners) if (owner.glyphs.has(id) && owner.glyphs.get(id) !== glyph) owner.stale = true;
+ },
+ });
+ return {
+ createLayout(style: TextStyle) {
+ if (dead || !Number.isInteger(style.size) || style.size < 8 || !Number.isInteger(style.density) ||
+ style.density < 1 || style.density > 3 || style.size * style.density > TEXT.maxRasterSize ||
+ !Number.isFinite(style.width) || style.width <= 0 || style.width > 4096 || !Number.isInteger(style.fontSlot) ||
+ style.fontSlot < 0 || style.fontSlot >= 24) throw new Error("Invalid text layout style");
+ const owner = { demands: [] as ResourceDemand[], glyphs: new Map(), stale: true }; owners.add(owner);
+ let text = "", priority = 1, active = true, previous = "\0", serial = 0;
+ let layout: TextLayout = { text: "", parts: [], width: 0, pending: false, revision: 0 };
+ return {
+ set(value: string, visible = true, rank = 1) {
+ if (text === value && active === visible && priority === rank) return;
+ let bounded = "";
+ for (const scalar of value) { if (bounded.length + scalar.length > TEXT.maxCodeUnits) break; bounded += scalar; }
+ if (text !== bounded || active !== visible || priority !== rank) { text = bounded; active = visible; priority = rank; previous = "\0"; }
+ },
+ snapshot(): TextLayout {
+ if (text === previous && !owner.stale) return layout;
+ previous = text; owner.stale = false; owner.glyphs.clear();
+ const parts: TextPart[] = [], demands: ResourceDemand[] = [];
+ let x = 0, start = 0, pending = false;
+ // Same scalar cmap model as the core's baked fonts. Shaping runs and
+ // grapheme caret boundaries must come from a shaper, not this loop.
+ for (const token of text.match(/[\x20-\x7e]+|[^\x20-\x7e]/gu) ?? []) {
+ const end = start + token.length;
+ if (/^[\x20-\x7e]+$/.test(token)) {
+ const width = options.measure(token, style.fontSlot);
+ parts.push({ kind: "local", text: token, x, width, start, end }); x += width;
+ } else {
+ const request = { face, text: token, size: style.size, density: style.density, bold: style.bold };
+ const state = face ? cache.state(request) : { status: "pending" as const };
+ const glyph = state.status === "ready" ? state.value : undefined, width = glyph?.advance ?? style.size;
+ // Clipped glyphs still contribute to the reported text width.
+ owner.glyphs.set(key(request), glyph);
+ if (x < style.width && active && face) demands.push({ input: request, priority, pin: true });
+ if (x < style.width) { parts.push({ kind: "glyph", text: token, x, width, start, end, glyph }); pending ||= !glyph; }
+ x += width;
+ }
+ start = end;
+ }
+ if (demands.length !== owner.demands.length || demands.some((d, i) => d.priority !== owner.demands[i].priority || key(d.input) !== key(owner.demands[i].input))) demandsDirty = true;
+ owner.demands = demands;
+ layout = { text, parts, width: x, pending, revision: ++serial };
+ return layout;
+ },
+ dispose() { owners.delete(owner); owner.demands = []; demandsDirty = true; },
+ };
+ },
+ step() {
+ if (dead) return;
+ const current = io.session();
+ if (current !== session) {
+ session = current; scheduler.cancel(); if (faceRequest) io.cancel(faceRequest);
+ faceRequest = 0; facePending = current > 0; retry = 0;
+ }
+ if (facePending && !faceRequest && retry-- <= 0) {
+ faceRequest = io.request("text.font", "{}", result => {
+ faceRequest = 0;
+ if (result.ok) try {
+ const next = JSON.parse(result.value) as TextFace;
+ if (next.mapping !== "scalar" || !/^[a-f0-9]{64}$/.test(next.id)) throw new Error();
+ if (face !== next.id) {
+ face = next.id;
+ for (const owner of owners) if (owner.glyphs.size) owner.stale = true;
+ }
+ facePending = false; return;
+ } catch { /* Keep immutable resident glyphs until a valid face arrives. */ }
+ retry = 60;
+ });
+ }
+ if (demandsDirty) {
+ const unique = new Map>();
+ for (const owner of owners) for (const demand of owner.demands) {
+ const id = key(demand.input), old = unique.get(id);
+ if (!old || demand.priority < old.priority) unique.set(id, demand);
+ }
+ cache.reconcile([...unique.values()].sort((a, b) => a.priority - b.priority).slice(0, limit));
+ demandsDirty = false;
+ }
+ scheduler.step();
+ },
+ stats: cache.stats,
+ dispose() { dead = true; if (faceRequest) io.cancel(faceRequest); scheduler.dispose(); owners.clear(); },
+ };
+}
+
+let resources: ReturnType | undefined;
+/** One cache and upload scheduler shared by labels in every UI framework. */
+export function textResources() {
+ if (!resources) {
+ resources = createTextResources({ io: offload(), measure: (s, slot) => getOps().measureText(s, slot),
+ upload: (mask, w, h) => uploadCoverage(mask, w, h, 0xffffffff), free: h => getOps().freeTexture?.(h) });
+ registerServicePump(() => resources!.step());
+ }
+ return resources;
+}
diff --git a/hosts/3ds/src/offload_coverage.h b/hosts/3ds/src/offload_coverage.h
index 4d5ce772e..08dedc710 100644
--- a/hosts/3ds/src/offload_coverage.h
+++ b/hosts/3ds/src/offload_coverage.h
@@ -1,67 +1 @@
-#ifndef POCKET_OFFLOAD_COVERAGE_H
-#define POCKET_OFFLOAD_COVERAGE_H
-#include
-#include
-#include
-static inline int coverage_digit(char c) {
- if (c >= 'A' && c <= 'Z') return c - 'A';
- if (c >= 'a' && c <= 'z') return c - 'a' + 26;
- if (c >= '0' && c <= '9') return c - '0' + 52;
- return c == '+' ? 62 : c == '/' ? 63 : -1;
-}
-/* Maximum envelope 512x16 RGBA. The caller owns one reusable scratch buffer.
- * Input is 2-bit alpha, four pixels per byte, low bits first. */
-static inline int coverage_decode(const char *base64, size_t length, unsigned width, unsigned height, uint32_t color, uint8_t *rgba) {
- if (!width || width > 512 || width % 4 || !height || height > 16) return 0;
- unsigned count = width * height, bytes = count / 4;
- if (length != ((bytes + 2) / 3) * 4) return 0;
- unsigned envelope = 8; while (envelope < width) envelope *= 2;
- memset(rgba, 0, 512 * 16 * 4);
- unsigned pixel = 0;
- for (size_t i = 0; i < length; i += 4) {
- uint32_t value = 0;
- for (unsigned j = 0; j < 4; j++) {
- int digit = coverage_digit(base64[i + j]);
- if (digit < 0) {
- if (base64[i + j] != '=' || i + 4 != length || j < 2) return 0;
- digit = 0;
- }
- value = (value << 6) | (unsigned)digit;
- }
- for (int byte = 2; byte >= 0 && pixel < count; byte--) {
- unsigned packed = (value >> (byte * 8)) & 255;
- for (unsigned part = 0; part < 4; part++, pixel++) {
- uint8_t *p = rgba + ((pixel / width) * envelope + pixel % width) * 4;
- p[0] = color; p[1] = color >> 8; p[2] = color >> 16;
- p[3] = ((packed >> (part * 2)) & 3) * 85;
- }
- }
- }
- return (int)envelope;
-}
-static inline int coverage_hex(char c) {
- return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1;
-}
-/* Optional horizontal palette: one hex index per column, up to 16 RGB colors.
- * Fixed work, same scratch allocation and one uploaded texture. */
-static inline int coverage_colorize(const char *columns, size_t columns_length,
- const char *palette, size_t palette_length, unsigned width, unsigned height, unsigned envelope, uint8_t *rgba) {
- if (!width || width > 512 || !height || height > 16 || envelope < width || envelope > 512 ||
- columns_length != width || !palette_length || palette_length > 96 || palette_length % 6) return 0;
- uint8_t colors[16][3];
- for (size_t i = 0; i < palette_length; i += 2) {
- int a = coverage_hex(palette[i]), b = coverage_hex(palette[i + 1]);
- if (a < 0 || b < 0) return 0;
- colors[i / 6][(i % 6) / 2] = (uint8_t)((a << 4) | b);
- }
- for (unsigned x = 0; x < width; x++) {
- int ink = coverage_hex(columns[x]);
- if (ink < 0 || (unsigned)ink >= palette_length / 6) return 0;
- }
- for (unsigned y = 0; y < height; y++) for (unsigned x = 0; x < width; x++) {
- uint8_t *p = rgba + (y * envelope + x) * 4;
- memcpy(p, colors[coverage_hex(columns[x])], 3);
- }
- return 1;
-}
-#endif
+#include "../../shared/offload_coverage.h"
diff --git a/hosts/3ds/src/offload_queue.h b/hosts/3ds/src/offload_queue.h
index 33742b972..9e579f131 100644
--- a/hosts/3ds/src/offload_queue.h
+++ b/hosts/3ds/src/offload_queue.h
@@ -1,33 +1 @@
-#ifndef POCKET_OFFLOAD_QUEUE_H
-#define POCKET_OFFLOAD_QUEUE_H
-#include
-#include
-#include
-#include
-#define OFFLOAD_BYTES 4096
-#define OFFLOAD_SLOTS 8
-typedef struct { uint32_t generation, length; char bytes[OFFLOAD_BYTES]; } OffloadRecord;
-/* Single producer, single consumer. Neither endpoint waits on the other.
- * A published slot is immutable until its consumer releases it. */
-typedef struct {
- _Atomic uint32_t read, write;
- OffloadRecord slots[OFFLOAD_SLOTS];
-} OffloadQueue;
-static inline bool offload_push(OffloadQueue *q, const char *p, uint32_t n, uint32_t generation) {
- uint32_t w = atomic_load_explicit(&q->write, memory_order_relaxed);
- uint32_t r = atomic_load_explicit(&q->read, memory_order_acquire);
- if (n == 0 || n > OFFLOAD_BYTES || w - r >= OFFLOAD_SLOTS) return false;
- OffloadRecord *s = &q->slots[w % OFFLOAD_SLOTS];
- s->length = n; s->generation = generation; memcpy(s->bytes, p, n);
- atomic_store_explicit(&q->write, w + 1, memory_order_release);
- return true;
-}
-static inline bool offload_pop(OffloadQueue *q, OffloadRecord *out) {
- uint32_t r = atomic_load_explicit(&q->read, memory_order_relaxed);
- uint32_t w = atomic_load_explicit(&q->write, memory_order_acquire);
- if (r == w) return false;
- *out = q->slots[r % OFFLOAD_SLOTS];
- atomic_store_explicit(&q->read, r + 1, memory_order_release);
- return true;
-}
-#endif
+#include "../../shared/offload_queue.h"
diff --git a/hosts/3ds/src/qjs.c b/hosts/3ds/src/qjs.c
index 0a4df46d0..6a1643feb 100644
--- a/hosts/3ds/src/qjs.c
+++ b/hosts/3ds/src/qjs.c
@@ -490,7 +490,7 @@ static JSValue host_operation(
if (columns) JS_FreeCString(ctx, columns); if (palette) JS_FreeCString(ctx, palette);
if (!valid) return JS_NewInt32(ctx, -1);
}
- unsigned padded_height = 8; while (padded_height < (unsigned)height) padded_height *= 2;
+ unsigned padded_height = coverage_height((unsigned)height);
return JS_NewInt32(ctx, ui_upload_texture(coverage_pixels, envelope * padded_height * 4, envelope, padded_height, 3));
}
case HostOffloadSession: return JS_NewInt32(ctx, offload_session());
diff --git a/hosts/android/app/AndroidManifest.xml b/hosts/android/app/AndroidManifest.xml
new file mode 100644
index 000000000..5866e4829
--- /dev/null
+++ b/hosts/android/app/AndroidManifest.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hosts/android/app/jni/runtime.c b/hosts/android/app/jni/runtime.c
new file mode 100644
index 000000000..e1751495a
--- /dev/null
+++ b/hosts/android/app/jni/runtime.c
@@ -0,0 +1,361 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "pocket_input.h"
+#include "pocket_runtime.h"
+#include "pocket_spec.h"
+
+#define LOG_TAG "PocketJSClassic"
+#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
+
+/* The logical viewport comes from the resolved build plan (blackberry-android.ts);
+ * the defaults match the private blackberry-android-dev profile. */
+#ifndef POCKET_LOGICAL_WIDTH
+#define POCKET_LOGICAL_WIDTH 360
+#endif
+#ifndef POCKET_LOGICAL_HEIGHT
+#define POCKET_LOGICAL_HEIGHT 360
+#endif
+
+#define KEYCODE_BACK 4
+#define KEYCODE_DPAD_UP 19
+#define KEYCODE_DPAD_DOWN 20
+#define KEYCODE_DPAD_LEFT 21
+#define KEYCODE_DPAD_RIGHT 22
+#define KEYCODE_DPAD_CENTER 23
+#define KEYCODE_SPACE 62
+#define KEYCODE_ENTER 66
+#define KEYCODE_MENU 82
+#define KEYCODE_NUMPAD_ENTER 160
+
+#define ACTION_DOWN 0
+#define ACTION_UP 1
+#define ACTION_CANCEL 3
+#define ACTION_POINTER_DOWN 5
+#define ACTION_POINTER_UP 6
+#define BUTTON_PRIMARY 1
+
+/* Trackball and scroll-axis deltas are fractional; this much accumulated
+ * motion is one focus pulse (provisional until a device run records the
+ * Android Runtime's actual trackpad events). */
+#define RELATIVE_PULSE_THRESHOLD 0.35f
+
+static pthread_mutex_t input_mutex = PTHREAD_MUTEX_INITIALIZER;
+static PocketInputState input;
+static int input_ready;
+static int surface_width = 720;
+static int surface_height = 720;
+
+static uint8_t *guest_js;
+static size_t guest_js_length;
+static uint8_t *guest_pack;
+static size_t guest_pack_length;
+static int runtime_booted;
+static int gl_initialized;
+static char android_error[512];
+static char receipt_path[1024];
+static unsigned long frames, touch_sequences;
+typedef struct { int used, platform_id, ending, sampled; float x, y; int hit; } Contact;
+static Contact contacts[POCKET_RUNTIME_MAX_CONTACTS];
+JNIEXPORT jint JNICALL Java_dev_pocketstack_android_PocketActivity_nativeLogicalWidth(JNIEnv *env, jclass owner) {
+ (void)env; (void)owner; return POCKET_LOGICAL_WIDTH;
+}
+JNIEXPORT jint JNICALL Java_dev_pocketstack_android_PocketActivity_nativeLogicalHeight(JNIEnv *env, jclass owner) {
+ (void)env; (void)owner; return POCKET_LOGICAL_HEIGHT;
+}
+JNIEXPORT void JNICALL Java_dev_pocketstack_android_PocketActivity_nativeConfigure(JNIEnv *env, jclass owner, jstring directory) {
+ (void)owner;
+ const char *path = (*env)->GetStringUTFChars(env, directory, NULL);
+ if (!path) return;
+ snprintf(receipt_path, sizeof receipt_path, "%s/runtime.txt", path);
+#ifdef POCKET_OFFLOAD_POSIX
+ char key[1024]; snprintf(key, sizeof key, "%s/offload.key", path);
+ pocket_runtime_offload_key(key);
+#endif
+ (*env)->ReleaseStringUTFChars(env, directory, path);
+}
+JNIEXPORT void JNICALL Java_dev_pocketstack_android_PocketActivity_nativeCancelTouches(JNIEnv *env, jclass owner) {
+ (void)env; (void)owner;
+ pthread_mutex_lock(&input_mutex);
+ for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++) if (contacts[i].used) contacts[i].ending = 1;
+ pthread_mutex_unlock(&input_mutex);
+}
+
+
+static void set_android_error(const char *message)
+{
+ size_t length = message == NULL ? 0 : strlen(message);
+ if (length >= sizeof(android_error)) length = sizeof(android_error) - 1;
+ if (length > 0) memcpy(android_error, message, length);
+ android_error[length] = '\0';
+ LOGE("%s", android_error);
+}
+
+static uint8_t *copy_java_bytes(
+ JNIEnv *env,
+ jbyteArray source,
+ size_t *length
+)
+{
+ if (source == NULL) return NULL;
+ jsize source_length = (*env)->GetArrayLength(env, source);
+ if (source_length <= 0) return NULL;
+ uint8_t *bytes = (uint8_t *)malloc((size_t)source_length);
+ if (bytes == NULL) return NULL;
+ (*env)->GetByteArrayRegion(env, source, 0, source_length, (jbyte *)bytes);
+ if ((*env)->ExceptionCheck(env)) {
+ (*env)->ExceptionClear(env);
+ free(bytes);
+ return NULL;
+ }
+ *length = (size_t)source_length;
+ return bytes;
+}
+
+/* Android key codes onto the portable mask (pocket_spec.h). */
+static uint32_t button_for_key(int key_code)
+{
+ switch (key_code) {
+ case KEYCODE_DPAD_UP: return POCKET_BTN_UP;
+ case KEYCODE_DPAD_RIGHT: return POCKET_BTN_RIGHT;
+ case KEYCODE_DPAD_DOWN: return POCKET_BTN_DOWN;
+ case KEYCODE_DPAD_LEFT: return POCKET_BTN_LEFT;
+ case KEYCODE_DPAD_CENTER:
+ case KEYCODE_ENTER:
+ case KEYCODE_NUMPAD_ENTER:
+ return POCKET_BTN_CIRCLE;
+ case KEYCODE_SPACE: return POCKET_BTN_START;
+ case KEYCODE_MENU: return POCKET_BTN_TRIANGLE;
+ default: return 0;
+ }
+}
+
+/* Input callbacks can arrive before the surface exists; the state machine is
+ * initialized lazily under the mutex. */
+static void ensure_input(void)
+{
+ if (input_ready) return;
+ pocket_input_init(&input, RELATIVE_PULSE_THRESHOLD);
+ input_ready = 1;
+}
+
+JNIEXPORT jstring JNICALL
+Java_dev_pocketstack_android_PocketActivity_nativeSurfaceCreated(
+ JNIEnv *env,
+ jclass owner,
+ jbyteArray guest_java_script,
+ jbyteArray guest_asset_pack
+)
+{
+ (void)owner;
+ android_error[0] = '\0';
+ if (runtime_booted) {
+ pocket_runtime_gl_reset();
+ gl_initialized = pocket_runtime_gl_initialize();
+ if (!gl_initialized) set_android_error("GLES2 backend reinitialization failed");
+ return (*env)->NewStringUTF(env, gl_initialized ? "ok" : android_error);
+ }
+
+ uint8_t *new_guest_js = copy_java_bytes(
+ env,
+ guest_java_script,
+ &guest_js_length
+ );
+ uint8_t *new_guest_pack = copy_java_bytes(
+ env,
+ guest_asset_pack,
+ &guest_pack_length
+ );
+ if (new_guest_js == NULL || new_guest_pack == NULL) {
+ free(new_guest_js);
+ free(new_guest_pack);
+ set_android_error("APK assets/app.js or assets/app.pak could not be copied");
+ return (*env)->NewStringUTF(env, android_error);
+ }
+ free(guest_js);
+ free(guest_pack);
+ guest_js = new_guest_js;
+ guest_pack = new_guest_pack;
+
+ if (!pocket_runtime_boot(
+ (const char *)guest_js,
+ guest_js_length,
+ guest_pack,
+ guest_pack_length,
+ POCKET_LOGICAL_WIDTH,
+ POCKET_LOGICAL_HEIGHT
+ )) {
+ set_android_error(pocket_runtime_error());
+ return (*env)->NewStringUTF(env, android_error);
+ }
+ runtime_booted = 1;
+ gl_initialized = pocket_runtime_gl_initialize();
+ if (!gl_initialized) {
+ set_android_error("PocketJS GLES2 backend initialization failed");
+ }
+ return (*env)->NewStringUTF(env, gl_initialized ? "ok" : android_error);
+}
+
+JNIEXPORT void JNICALL
+Java_dev_pocketstack_android_PocketActivity_nativeSurfaceChanged(
+ JNIEnv *env,
+ jclass owner,
+ jint width,
+ jint height
+)
+{
+ (void)env;
+ (void)owner;
+ pthread_mutex_lock(&input_mutex);
+ surface_width = width > 0 ? width : 1;
+ surface_height = height > 0 ? height : 1;
+ pthread_mutex_unlock(&input_mutex);
+}
+
+JNIEXPORT jboolean JNICALL
+Java_dev_pocketstack_android_PocketActivity_nativeFrame(
+ JNIEnv *env,
+ jclass owner
+)
+{
+ (void)env;
+ (void)owner;
+ if (!runtime_booted || !gl_initialized) return JNI_FALSE;
+
+ PocketInputSample sample;
+ PocketRuntimeContactsInput frame;
+ memset(&frame, 0, sizeof frame);
+ int width;
+ int height;
+ unsigned long sequences;
+ pthread_mutex_lock(&input_mutex);
+ ensure_input();
+ pocket_input_sample(&input, &sample);
+ width = surface_width;
+ height = surface_height;
+ sequences = touch_sequences;
+ frame.buttons = sample.buttons;
+ for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++) {
+ Contact *contact = &contacts[i];
+ if (!contact->used) continue;
+ if (contact->ending && contact->sampled) { memset(contact, 0, sizeof *contact); continue; }
+ int x = (int)(contact->x * POCKET_LOGICAL_WIDTH / width);
+ int y = (int)(contact->y * POCKET_LOGICAL_HEIGHT / height);
+ if (!contact->sampled) contact->hit = pocket_runtime_hit_test_bounds((float)x, (float)y);
+ PocketRuntimeContact *out = &frame.contacts[frame.contact_count++];
+ out->id = (int)i; out->x = x; out->y = y; out->hit = contact->hit;
+ contact->sampled = 1;
+ }
+ pthread_mutex_unlock(&input_mutex);
+ if (!pocket_runtime_tick_contacts(&frame)) {
+ set_android_error(pocket_runtime_error());
+ return JNI_FALSE;
+ }
+ if (!pocket_runtime_gl_render(width, height)) {
+ set_android_error("PocketJS GLES2 frame submission failed");
+ return JNI_FALSE;
+ }
+ frames++;
+ if (frames % 60 == 0 && receipt_path[0]) {
+ FILE *receipt = fopen(receipt_path, "w");
+ if (receipt) {
+ fprintf(receipt, "state=running\nrenderer=gles2\nframes=%lu\ntouch_sequences=%lu\nlogical_width=%d\nlogical_height=%d\nsurface_width=%d\nsurface_height=%d\naction_name=%s\naction_value=%d\n",
+ frames, sequences, POCKET_LOGICAL_WIDTH, POCKET_LOGICAL_HEIGHT, width, height,
+ pocket_runtime_action_name(), pocket_runtime_action_value());
+ fclose(receipt);
+ }
+ }
+ return JNI_TRUE;
+}
+
+JNIEXPORT jstring JNICALL
+Java_dev_pocketstack_android_PocketActivity_nativeError(JNIEnv *env, jclass owner)
+{
+ (void)owner;
+ const char *message = android_error[0] != '\0'
+ ? android_error
+ : pocket_runtime_error();
+ return (*env)->NewStringUTF(env, message == NULL ? "unknown error" : message);
+}
+
+JNIEXPORT void JNICALL
+Java_dev_pocketstack_android_PocketActivity_nativeKey(
+ JNIEnv *env,
+ jclass owner,
+ jint action,
+ jint key_code,
+ jint scan_code,
+ jint unicode,
+ jint repeat
+)
+{
+ (void)env;
+ (void)owner;
+ (void)scan_code;
+ (void)unicode;
+ uint32_t button = button_for_key(key_code);
+ if (button == 0 || key_code == KEYCODE_BACK) return;
+ if (action != ACTION_DOWN && action != ACTION_UP) return;
+ pthread_mutex_lock(&input_mutex);
+ ensure_input();
+ pocket_input_button(&input, button, action == ACTION_DOWN, repeat != 0);
+ pthread_mutex_unlock(&input_mutex);
+}
+
+JNIEXPORT void JNICALL
+Java_dev_pocketstack_android_PocketActivity_nativeTouch(
+ JNIEnv *env,
+ jclass owner,
+ jint action,
+ jint pointer_id,
+ jfloat x,
+ jfloat y
+)
+{
+ (void)env;
+ (void)owner;
+ PocketTouchPhase phase;
+ if (action == ACTION_DOWN || action == ACTION_POINTER_DOWN) phase = POCKET_TOUCH_DOWN;
+ else if (action == ACTION_UP || action == ACTION_POINTER_UP) phase = POCKET_TOUCH_UP;
+ else if (action == ACTION_CANCEL) phase = POCKET_TOUCH_CANCEL;
+ else phase = POCKET_TOUCH_MOVE;
+ pthread_mutex_lock(&input_mutex);
+ ensure_input();
+ Contact *contact = NULL;
+ for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++)
+ if (contacts[i].used && contacts[i].platform_id == pointer_id) { contact = &contacts[i]; break; }
+ if (phase == POCKET_TOUCH_DOWN && !contact && x >= 0 && y >= 0 && x < surface_width && y < surface_height) {
+ for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++) if (!contacts[i].used) {
+ contact = &contacts[i]; memset(contact, 0, sizeof *contact); contact->used = 1; contact->platform_id = pointer_id;
+ touch_sequences++; break;
+ }
+ }
+ if (contact) { contact->x = x; contact->y = y; if (phase == POCKET_TOUCH_UP || phase == POCKET_TOUCH_CANCEL) contact->ending = 1; }
+ pthread_mutex_unlock(&input_mutex);
+}
+
+JNIEXPORT void JNICALL
+Java_dev_pocketstack_android_PocketActivity_nativeRelative(
+ JNIEnv *env,
+ jclass owner,
+ jfloat delta_x,
+ jfloat delta_y,
+ jint action,
+ jint button_state
+)
+{
+ (void)env;
+ (void)owner;
+ int primary = (button_state & BUTTON_PRIMARY) != 0 || action == ACTION_DOWN;
+ if (action == ACTION_UP || action == ACTION_CANCEL) primary = 0;
+ pthread_mutex_lock(&input_mutex);
+ ensure_input();
+ pocket_input_relative(&input, delta_x, delta_y);
+ pocket_input_primary(&input, primary);
+ pthread_mutex_unlock(&input_mutex);
+}
diff --git a/hosts/android/app/res/values/strings.xml b/hosts/android/app/res/values/strings.xml
new file mode 100644
index 000000000..55762cc6b
--- /dev/null
+++ b/hosts/android/app/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+
+
+ @POCKET_TITLE@
+
diff --git a/hosts/android/app/src/dev/pocketstack/android/PocketActivity.java b/hosts/android/app/src/dev/pocketstack/android/PocketActivity.java
new file mode 100644
index 000000000..402414c30
--- /dev/null
+++ b/hosts/android/app/src/dev/pocketstack/android/PocketActivity.java
@@ -0,0 +1,293 @@
+package dev.pocketstack.android;
+
+import android.app.Activity;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.opengl.GLSurfaceView;
+import android.os.Bundle;
+import android.view.Gravity;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/** Android surface and lifecycle adapter for the shared PocketJS runtime. */
+public class PocketActivity extends Activity {
+ private static boolean nativeLoaded;
+ private static String nativeLoadError = "";
+
+ static {
+ try {
+ System.loadLibrary("pocketjs");
+ nativeLoaded = true;
+ } catch (Throwable error) {
+ nativeLoaded = false;
+ nativeLoadError = error.getClass().getSimpleName() + ": " + error.getMessage();
+ }
+ }
+
+ private PocketSurfaceView surfaceView;
+ private TextView errorView;
+
+ private static native void nativeConfigure(String directory);
+ private static native int nativeLogicalWidth();
+ private static native int nativeLogicalHeight();
+ private static native void nativeCancelTouches();
+ private static native String nativeSurfaceCreated(byte[] guestJavaScript, byte[] guestPack);
+ private static native void nativeSurfaceChanged(int width, int height);
+ private static native boolean nativeFrame();
+ private static native String nativeError();
+ private static native void nativeKey(
+ int action,
+ int keyCode,
+ int scanCode,
+ int unicode,
+ int repeat
+ );
+ private static native void nativeTouch(int action, int pointerId, float x, float y);
+ private static native void nativeRelative(
+ float deltaX,
+ float deltaY,
+ int action,
+ int buttonState
+ );
+
+ @Override
+ protected void onCreate(Bundle state) {
+ super.onCreate(state);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ getWindow().setFlags(
+ WindowManager.LayoutParams.FLAG_FULLSCREEN,
+ WindowManager.LayoutParams.FLAG_FULLSCREEN
+ );
+
+ if (android.os.Build.VERSION.SDK_INT >= 28) {
+ WindowManager.LayoutParams attributes = getWindow().getAttributes();
+ try { attributes.getClass().getField("layoutInDisplayCutoutMode").setInt(attributes, 1); getWindow().setAttributes(attributes); }
+ catch (Exception ignored) {}
+ }
+ if (android.os.Build.VERSION.SDK_INT >= 19) getWindow().getDecorView().setSystemUiVisibility(0x1000 | 0x200 | 0x400 | 0x100 | 0x4 | 0x2);
+ if (nativeLoaded) nativeConfigure(getFilesDir().getAbsolutePath());
+ FrameLayout root = new FrameLayout(this);
+ root.setBackgroundColor(Color.BLACK);
+ surfaceView = new PocketSurfaceView();
+ root.addView(
+ surfaceView,
+ new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ Gravity.CENTER
+ )
+ );
+
+ errorView = new TextView(this);
+ errorView.setTextColor(Color.WHITE);
+ errorView.setTextSize(15.0f);
+ errorView.setTypeface(Typeface.MONOSPACE);
+ errorView.setGravity(Gravity.CENTER);
+ errorView.setPadding(28, 28, 28, 28);
+ errorView.setBackgroundColor(0xff250d12);
+ errorView.setText(nativeLoaded ? "BOOTING POCKETJS…" : "JNI FAILED\n" + nativeLoadError);
+ root.addView(
+ errorView,
+ new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ );
+ setContentView(root);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ surfaceView.onResume();
+ surfaceView.requestFocus();
+ }
+
+ @Override
+ protected void onPause() {
+ if (nativeLoaded) nativeCancelTouches();
+ surfaceView.onPause();
+ super.onPause();
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean focused) {
+ super.onWindowFocusChanged(focused);
+ if (focused) {
+ surfaceView.requestFocus();
+ if (android.os.Build.VERSION.SDK_INT >= 19) getWindow().getDecorView().setSystemUiVisibility(0x1000 | 0x200 | 0x400 | 0x100 | 0x4 | 0x2);
+ }
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ if (nativeLoaded) {
+ nativeKey(
+ event.getAction(),
+ event.getKeyCode(),
+ event.getScanCode(),
+ event.getUnicodeChar(event.getMetaState()),
+ event.getRepeatCount()
+ );
+ }
+ if (event.getKeyCode() == KeyEvent.KEYCODE_BACK ||
+ event.getKeyCode() == KeyEvent.KEYCODE_HOME ||
+ event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP ||
+ event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_DOWN) {
+ return super.dispatchKeyEvent(event);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent event) {
+ if (!nativeLoaded) return super.dispatchTouchEvent(event);
+ int action = event.getActionMasked();
+ int changed = event.getActionIndex();
+ if (action == MotionEvent.ACTION_MOVE) {
+ for (int index = 0; index < event.getPointerCount(); index++) {
+ nativeTouch(
+ action,
+ event.getPointerId(index),
+ event.getX(index) - surfaceView.getLeft(),
+ event.getY(index) - surfaceView.getTop()
+ );
+ }
+ } else if (action == MotionEvent.ACTION_CANCEL) {
+ for (int index = 0; index < event.getPointerCount(); index++) {
+ nativeTouch(
+ action,
+ event.getPointerId(index),
+ event.getX(index) - surfaceView.getLeft(),
+ event.getY(index) - surfaceView.getTop()
+ );
+ }
+ } else {
+ nativeTouch(
+ action,
+ event.getPointerId(changed),
+ event.getX(changed) - surfaceView.getLeft(),
+ event.getY(changed) - surfaceView.getTop()
+ );
+ }
+ return true;
+ }
+
+ @Override
+ public boolean dispatchGenericMotionEvent(MotionEvent event) {
+ if (!nativeLoaded) return super.dispatchGenericMotionEvent(event);
+ float horizontal = event.getAxisValue(MotionEvent.AXIS_HSCROLL);
+ float vertical = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
+ nativeRelative(horizontal, vertical, event.getActionMasked(), event.getButtonState());
+ return true;
+ }
+
+ @Override
+ public boolean onTrackballEvent(MotionEvent event) {
+ if (!nativeLoaded) return super.onTrackballEvent(event);
+ nativeRelative(event.getX(), event.getY(), event.getActionMasked(), event.getButtonState());
+ return true;
+ }
+
+ private void showBootResult(final String result) {
+ runOnUiThread(new Runnable() {
+ public void run() {
+ if ("ok".equals(result)) {
+ errorView.setVisibility(View.GONE);
+ } else {
+ errorView.setText("POCKETJS BOOT FAILED\n\n" + result);
+ errorView.setVisibility(View.VISIBLE);
+ }
+ }
+ });
+ }
+
+ private void showRuntimeError(final String error) {
+ runOnUiThread(new Runnable() {
+ public void run() {
+ errorView.setText("POCKETJS RUNTIME FAILED\n\n" + error);
+ errorView.setVisibility(View.VISIBLE);
+ }
+ });
+ }
+
+ private byte[] readAsset(String name) throws IOException {
+ InputStream input = getAssets().open(name);
+ try {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int count;
+ while ((count = input.read(buffer)) != -1) {
+ output.write(buffer, 0, count);
+ }
+ return output.toByteArray();
+ } finally {
+ input.close();
+ }
+ }
+
+ private final class PocketSurfaceView extends GLSurfaceView {
+ @Override protected void onMeasure(int widthSpec, int heightSpec) {
+ int width = MeasureSpec.getSize(widthSpec), height = MeasureSpec.getSize(heightSpec);
+ int logicalWidth = nativeLoaded ? nativeLogicalWidth() : 320;
+ int logicalHeight = nativeLoaded ? nativeLogicalHeight() : 480;
+ if ((long)width * logicalHeight > (long)height * logicalWidth) width = height * logicalWidth / logicalHeight;
+ else height = width * logicalHeight / logicalWidth;
+ setMeasuredDimension(width, height);
+ }
+ PocketSurfaceView() {
+ super(PocketActivity.this);
+ setEGLContextClientVersion(2);
+ setPreserveEGLContextOnPause(true);
+ setFocusable(true);
+ setFocusableInTouchMode(true);
+ setRenderer(new PocketRenderer());
+ setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
+ }
+ }
+
+ private final class PocketRenderer implements GLSurfaceView.Renderer {
+ private boolean failed;
+
+ public void onSurfaceCreated(
+ javax.microedition.khronos.opengles.GL10 ignored,
+ javax.microedition.khronos.egl.EGLConfig config
+ ) {
+ if (!nativeLoaded) return;
+ String result;
+ try {
+ result = nativeSurfaceCreated(readAsset("app.js"), readAsset("app.pak"));
+ } catch (IOException error) {
+ result = "APK asset read failed: " + error.getMessage();
+ }
+ failed = !"ok".equals(result);
+ showBootResult(result);
+ }
+
+ public void onSurfaceChanged(
+ javax.microedition.khronos.opengles.GL10 ignored,
+ int width,
+ int height
+ ) {
+ if (nativeLoaded) nativeSurfaceChanged(width, height);
+ }
+
+ public void onDrawFrame(javax.microedition.khronos.opengles.GL10 ignored) {
+ if (!nativeLoaded || failed) return;
+ if (!nativeFrame()) {
+ failed = true;
+ showRuntimeError(nativeError());
+ }
+ }
+ }
+}
diff --git a/hosts/blackberry-classic-android/app/jni/runtime.c b/hosts/blackberry-classic-android/app/jni/runtime.c
index 790dbdcb6..607c73a18 100644
--- a/hosts/blackberry-classic-android/app/jni/runtime.c
+++ b/hosts/blackberry-classic-android/app/jni/runtime.c
@@ -1,307 +1,2 @@
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#include "pocket_input.h"
-#include "pocket_runtime.h"
-#include "pocket_spec.h"
-
-#define LOG_TAG "PocketJSClassic"
-#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
-
-/* The logical viewport comes from the resolved build plan (blackberry-android.ts);
- * the defaults match the private blackberry-android-dev profile. */
-#ifndef POCKET_LOGICAL_WIDTH
-#define POCKET_LOGICAL_WIDTH 360
-#endif
-#ifndef POCKET_LOGICAL_HEIGHT
-#define POCKET_LOGICAL_HEIGHT 360
-#endif
-
-#define KEYCODE_BACK 4
-#define KEYCODE_DPAD_UP 19
-#define KEYCODE_DPAD_DOWN 20
-#define KEYCODE_DPAD_LEFT 21
-#define KEYCODE_DPAD_RIGHT 22
-#define KEYCODE_DPAD_CENTER 23
-#define KEYCODE_SPACE 62
-#define KEYCODE_ENTER 66
-#define KEYCODE_MENU 82
-#define KEYCODE_NUMPAD_ENTER 160
-
-#define ACTION_DOWN 0
-#define ACTION_UP 1
-#define ACTION_CANCEL 3
-#define ACTION_POINTER_DOWN 5
-#define ACTION_POINTER_UP 6
-#define BUTTON_PRIMARY 1
-
-/* Trackball and scroll-axis deltas are fractional; this much accumulated
- * motion is one focus pulse (provisional until a device run records the
- * Android Runtime's actual trackpad events). */
-#define RELATIVE_PULSE_THRESHOLD 0.35f
-
-static pthread_mutex_t input_mutex = PTHREAD_MUTEX_INITIALIZER;
-static PocketInputState input;
-static int input_ready;
-static int surface_width = 720;
-static int surface_height = 720;
-
-static uint8_t *guest_js;
-static size_t guest_js_length;
-static uint8_t *guest_pack;
-static size_t guest_pack_length;
-static int runtime_booted;
-static int gl_initialized;
-static char android_error[512];
-
-static void set_android_error(const char *message)
-{
- size_t length = message == NULL ? 0 : strlen(message);
- if (length >= sizeof(android_error)) length = sizeof(android_error) - 1;
- if (length > 0) memcpy(android_error, message, length);
- android_error[length] = '\0';
- LOGE("%s", android_error);
-}
-
-static uint8_t *copy_java_bytes(
- JNIEnv *env,
- jbyteArray source,
- size_t *length
-)
-{
- if (source == NULL) return NULL;
- jsize source_length = (*env)->GetArrayLength(env, source);
- if (source_length <= 0) return NULL;
- uint8_t *bytes = (uint8_t *)malloc((size_t)source_length);
- if (bytes == NULL) return NULL;
- (*env)->GetByteArrayRegion(env, source, 0, source_length, (jbyte *)bytes);
- if ((*env)->ExceptionCheck(env)) {
- (*env)->ExceptionClear(env);
- free(bytes);
- return NULL;
- }
- *length = (size_t)source_length;
- return bytes;
-}
-
-/* Android key codes onto the portable mask (pocket_spec.h). */
-static uint32_t button_for_key(int key_code)
-{
- switch (key_code) {
- case KEYCODE_DPAD_UP: return POCKET_BTN_UP;
- case KEYCODE_DPAD_RIGHT: return POCKET_BTN_RIGHT;
- case KEYCODE_DPAD_DOWN: return POCKET_BTN_DOWN;
- case KEYCODE_DPAD_LEFT: return POCKET_BTN_LEFT;
- case KEYCODE_DPAD_CENTER:
- case KEYCODE_ENTER:
- case KEYCODE_NUMPAD_ENTER:
- return POCKET_BTN_CIRCLE;
- case KEYCODE_SPACE: return POCKET_BTN_START;
- case KEYCODE_MENU: return POCKET_BTN_TRIANGLE;
- default: return 0;
- }
-}
-
-/* Input callbacks can arrive before the surface exists; the state machine is
- * initialized lazily under the mutex. */
-static void ensure_input(void)
-{
- if (input_ready) return;
- pocket_input_init(&input, RELATIVE_PULSE_THRESHOLD);
- input_ready = 1;
-}
-
-JNIEXPORT jstring JNICALL
-Java_dev_pocketstack_blackberry_PocketActivity_nativeSurfaceCreated(
- JNIEnv *env,
- jclass owner,
- jbyteArray guest_java_script,
- jbyteArray guest_asset_pack
-)
-{
- (void)owner;
- android_error[0] = '\0';
- if (runtime_booted) {
- pocket_runtime_gl_reset();
- gl_initialized = pocket_runtime_gl_initialize();
- if (!gl_initialized) set_android_error("GLES2 backend reinitialization failed");
- return (*env)->NewStringUTF(env, gl_initialized ? "ok" : android_error);
- }
-
- uint8_t *new_guest_js = copy_java_bytes(
- env,
- guest_java_script,
- &guest_js_length
- );
- uint8_t *new_guest_pack = copy_java_bytes(
- env,
- guest_asset_pack,
- &guest_pack_length
- );
- if (new_guest_js == NULL || new_guest_pack == NULL) {
- free(new_guest_js);
- free(new_guest_pack);
- set_android_error("APK assets/app.js or assets/app.pak could not be copied");
- return (*env)->NewStringUTF(env, android_error);
- }
- free(guest_js);
- free(guest_pack);
- guest_js = new_guest_js;
- guest_pack = new_guest_pack;
-
- if (!pocket_runtime_boot(
- (const char *)guest_js,
- guest_js_length,
- guest_pack,
- guest_pack_length,
- POCKET_LOGICAL_WIDTH,
- POCKET_LOGICAL_HEIGHT
- )) {
- set_android_error(pocket_runtime_error());
- return (*env)->NewStringUTF(env, android_error);
- }
- runtime_booted = 1;
- gl_initialized = pocket_runtime_gl_initialize();
- if (!gl_initialized) {
- set_android_error("PocketJS GLES2 backend initialization failed");
- }
- return (*env)->NewStringUTF(env, gl_initialized ? "ok" : android_error);
-}
-
-JNIEXPORT void JNICALL
-Java_dev_pocketstack_blackberry_PocketActivity_nativeSurfaceChanged(
- JNIEnv *env,
- jclass owner,
- jint width,
- jint height
-)
-{
- (void)env;
- (void)owner;
- pthread_mutex_lock(&input_mutex);
- surface_width = width > 0 ? width : 1;
- surface_height = height > 0 ? height : 1;
- pthread_mutex_unlock(&input_mutex);
-}
-
-JNIEXPORT jboolean JNICALL
-Java_dev_pocketstack_blackberry_PocketActivity_nativeFrame(
- JNIEnv *env,
- jclass owner
-)
-{
- (void)env;
- (void)owner;
- if (!runtime_booted || !gl_initialized) return JNI_FALSE;
-
- PocketInputSample sample;
- PocketRuntimeInput frame;
- int width;
- int height;
- pthread_mutex_lock(&input_mutex);
- ensure_input();
- pocket_input_sample(&input, &sample);
- width = surface_width;
- height = surface_height;
- pthread_mutex_unlock(&input_mutex);
-
- frame.buttons = sample.buttons;
- frame.touch_down = sample.touch_down;
- frame.touch_x = (int)(sample.touch_x * POCKET_LOGICAL_WIDTH / width);
- frame.touch_y = (int)(sample.touch_y * POCKET_LOGICAL_HEIGHT / height);
- frame.touch_hit = sample.touch_down
- ? pocket_runtime_hit_test_bounds((float)frame.touch_x, (float)frame.touch_y)
- : 0;
- if (!pocket_runtime_tick(&frame)) {
- set_android_error(pocket_runtime_error());
- return JNI_FALSE;
- }
- if (!pocket_runtime_gl_render(width, height)) {
- set_android_error("PocketJS GLES2 frame submission failed");
- return JNI_FALSE;
- }
- return JNI_TRUE;
-}
-
-JNIEXPORT jstring JNICALL
-Java_dev_pocketstack_blackberry_PocketActivity_nativeError(JNIEnv *env, jclass owner)
-{
- (void)owner;
- const char *message = android_error[0] != '\0'
- ? android_error
- : pocket_runtime_error();
- return (*env)->NewStringUTF(env, message == NULL ? "unknown error" : message);
-}
-
-JNIEXPORT void JNICALL
-Java_dev_pocketstack_blackberry_PocketActivity_nativeKey(
- JNIEnv *env,
- jclass owner,
- jint action,
- jint key_code,
- jint scan_code,
- jint unicode,
- jint repeat
-)
-{
- (void)env;
- (void)owner;
- (void)scan_code;
- (void)unicode;
- uint32_t button = button_for_key(key_code);
- if (button == 0 || key_code == KEYCODE_BACK) return;
- if (action != ACTION_DOWN && action != ACTION_UP) return;
- pthread_mutex_lock(&input_mutex);
- ensure_input();
- pocket_input_button(&input, button, action == ACTION_DOWN, repeat != 0);
- pthread_mutex_unlock(&input_mutex);
-}
-
-JNIEXPORT void JNICALL
-Java_dev_pocketstack_blackberry_PocketActivity_nativeTouch(
- JNIEnv *env,
- jclass owner,
- jint action,
- jint pointer_id,
- jfloat x,
- jfloat y
-)
-{
- (void)env;
- (void)owner;
- PocketTouchPhase phase;
- if (action == ACTION_DOWN || action == ACTION_POINTER_DOWN) phase = POCKET_TOUCH_DOWN;
- else if (action == ACTION_UP || action == ACTION_POINTER_UP) phase = POCKET_TOUCH_UP;
- else if (action == ACTION_CANCEL) phase = POCKET_TOUCH_CANCEL;
- else phase = POCKET_TOUCH_MOVE;
- pthread_mutex_lock(&input_mutex);
- ensure_input();
- pocket_input_touch(&input, phase, pointer_id, x, y);
- pthread_mutex_unlock(&input_mutex);
-}
-
-JNIEXPORT void JNICALL
-Java_dev_pocketstack_blackberry_PocketActivity_nativeRelative(
- JNIEnv *env,
- jclass owner,
- jfloat delta_x,
- jfloat delta_y,
- jint action,
- jint button_state
-)
-{
- (void)env;
- (void)owner;
- int primary = (button_state & BUTTON_PRIMARY) != 0 || action == ACTION_DOWN;
- if (action == ACTION_UP || action == ACTION_CANCEL) primary = 0;
- pthread_mutex_lock(&input_mutex);
- ensure_input();
- pocket_input_relative(&input, delta_x, delta_y);
- pocket_input_primary(&input, primary);
- pthread_mutex_unlock(&input_mutex);
-}
+/* Compatibility source path; implementation belongs to the Android host. */
+#include "../../../../android/app/jni/runtime.c"
diff --git a/hosts/blackberry-classic-android/app/src/dev/pocketstack/blackberry/PocketActivity.java b/hosts/blackberry-classic-android/app/src/dev/pocketstack/blackberry/PocketActivity.java
index 10f63d1ef..b1722393f 100644
--- a/hosts/blackberry-classic-android/app/src/dev/pocketstack/blackberry/PocketActivity.java
+++ b/hosts/blackberry-classic-android/app/src/dev/pocketstack/blackberry/PocketActivity.java
@@ -1,268 +1,3 @@
package dev.pocketstack.blackberry;
-
-import android.app.Activity;
-import android.graphics.Color;
-import android.graphics.Typeface;
-import android.opengl.GLSurfaceView;
-import android.os.Bundle;
-import android.view.Gravity;
-import android.view.KeyEvent;
-import android.view.MotionEvent;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.Window;
-import android.view.WindowManager;
-import android.widget.FrameLayout;
-import android.widget.TextView;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-
-/** Android 4.3 shell for the BlackBerry Classic PocketJS guest. */
-public final class PocketActivity extends Activity {
- private static boolean nativeLoaded;
- private static String nativeLoadError = "";
-
- static {
- try {
- System.loadLibrary("pocketjs");
- nativeLoaded = true;
- } catch (Throwable error) {
- nativeLoaded = false;
- nativeLoadError = error.getClass().getSimpleName() + ": " + error.getMessage();
- }
- }
-
- private PocketSurfaceView surfaceView;
- private TextView errorView;
-
- private static native String nativeSurfaceCreated(byte[] guestJavaScript, byte[] guestPack);
- private static native void nativeSurfaceChanged(int width, int height);
- private static native boolean nativeFrame();
- private static native String nativeError();
- private static native void nativeKey(
- int action,
- int keyCode,
- int scanCode,
- int unicode,
- int repeat
- );
- private static native void nativeTouch(int action, int pointerId, float x, float y);
- private static native void nativeRelative(
- float deltaX,
- float deltaY,
- int action,
- int buttonState
- );
-
- @Override
- protected void onCreate(Bundle state) {
- super.onCreate(state);
- requestWindowFeature(Window.FEATURE_NO_TITLE);
- getWindow().setFlags(
- WindowManager.LayoutParams.FLAG_FULLSCREEN,
- WindowManager.LayoutParams.FLAG_FULLSCREEN
- );
-
- FrameLayout root = new FrameLayout(this);
- surfaceView = new PocketSurfaceView();
- root.addView(
- surfaceView,
- new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT
- )
- );
-
- errorView = new TextView(this);
- errorView.setTextColor(Color.WHITE);
- errorView.setTextSize(15.0f);
- errorView.setTypeface(Typeface.MONOSPACE);
- errorView.setGravity(Gravity.CENTER);
- errorView.setPadding(28, 28, 28, 28);
- errorView.setBackgroundColor(0xff250d12);
- errorView.setText(nativeLoaded ? "BOOTING POCKETJS…" : "JNI FAILED\n" + nativeLoadError);
- root.addView(
- errorView,
- new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT
- )
- );
- setContentView(root);
- }
-
- @Override
- protected void onResume() {
- super.onResume();
- surfaceView.onResume();
- surfaceView.requestFocus();
- }
-
- @Override
- protected void onPause() {
- surfaceView.onPause();
- super.onPause();
- }
-
- @Override
- public void onWindowFocusChanged(boolean focused) {
- super.onWindowFocusChanged(focused);
- if (focused) surfaceView.requestFocus();
- }
-
- @Override
- public boolean dispatchKeyEvent(KeyEvent event) {
- if (nativeLoaded) {
- nativeKey(
- event.getAction(),
- event.getKeyCode(),
- event.getScanCode(),
- event.getUnicodeChar(event.getMetaState()),
- event.getRepeatCount()
- );
- }
- if (event.getKeyCode() == KeyEvent.KEYCODE_BACK ||
- event.getKeyCode() == KeyEvent.KEYCODE_HOME ||
- event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP ||
- event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_DOWN) {
- return super.dispatchKeyEvent(event);
- }
- return true;
- }
-
- @Override
- public boolean dispatchTouchEvent(MotionEvent event) {
- if (!nativeLoaded) return super.dispatchTouchEvent(event);
- int action = event.getActionMasked();
- int changed = event.getActionIndex();
- if (action == MotionEvent.ACTION_MOVE) {
- for (int index = 0; index < event.getPointerCount(); index++) {
- nativeTouch(
- action,
- event.getPointerId(index),
- event.getX(index),
- event.getY(index)
- );
- }
- } else if (action == MotionEvent.ACTION_CANCEL) {
- for (int index = 0; index < event.getPointerCount(); index++) {
- nativeTouch(
- action,
- event.getPointerId(index),
- event.getX(index),
- event.getY(index)
- );
- }
- } else {
- nativeTouch(
- action,
- event.getPointerId(changed),
- event.getX(changed),
- event.getY(changed)
- );
- }
- return true;
- }
-
- @Override
- public boolean dispatchGenericMotionEvent(MotionEvent event) {
- if (!nativeLoaded) return super.dispatchGenericMotionEvent(event);
- float horizontal = event.getAxisValue(MotionEvent.AXIS_HSCROLL);
- float vertical = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
- nativeRelative(horizontal, vertical, event.getActionMasked(), event.getButtonState());
- return true;
- }
-
- @Override
- public boolean onTrackballEvent(MotionEvent event) {
- if (!nativeLoaded) return super.onTrackballEvent(event);
- nativeRelative(event.getX(), event.getY(), event.getActionMasked(), event.getButtonState());
- return true;
- }
-
- private void showBootResult(final String result) {
- runOnUiThread(new Runnable() {
- public void run() {
- if ("ok".equals(result)) {
- errorView.setVisibility(View.GONE);
- } else {
- errorView.setText("POCKETJS BOOT FAILED\n\n" + result);
- errorView.setVisibility(View.VISIBLE);
- }
- }
- });
- }
-
- private void showRuntimeError(final String error) {
- runOnUiThread(new Runnable() {
- public void run() {
- errorView.setText("POCKETJS RUNTIME FAILED\n\n" + error);
- errorView.setVisibility(View.VISIBLE);
- }
- });
- }
-
- private byte[] readAsset(String name) throws IOException {
- InputStream input = getAssets().open(name);
- try {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- byte[] buffer = new byte[8192];
- int count;
- while ((count = input.read(buffer)) != -1) {
- output.write(buffer, 0, count);
- }
- return output.toByteArray();
- } finally {
- input.close();
- }
- }
-
- private final class PocketSurfaceView extends GLSurfaceView {
- PocketSurfaceView() {
- super(PocketActivity.this);
- setEGLContextClientVersion(2);
- setPreserveEGLContextOnPause(true);
- setFocusable(true);
- setFocusableInTouchMode(true);
- setRenderer(new PocketRenderer());
- setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
- }
- }
-
- private final class PocketRenderer implements GLSurfaceView.Renderer {
- private boolean failed;
-
- public void onSurfaceCreated(
- javax.microedition.khronos.opengles.GL10 ignored,
- javax.microedition.khronos.egl.EGLConfig config
- ) {
- if (!nativeLoaded) return;
- String result;
- try {
- result = nativeSurfaceCreated(readAsset("app.js"), readAsset("app.pak"));
- } catch (IOException error) {
- result = "APK asset read failed: " + error.getMessage();
- }
- failed = !"ok".equals(result);
- showBootResult(result);
- }
-
- public void onSurfaceChanged(
- javax.microedition.khronos.opengles.GL10 ignored,
- int width,
- int height
- ) {
- if (nativeLoaded) nativeSurfaceChanged(width, height);
- }
-
- public void onDrawFrame(javax.microedition.khronos.opengles.GL10 ignored) {
- if (!nativeLoaded || failed) return;
- if (!nativeFrame()) {
- failed = true;
- showRuntimeError(nativeError());
- }
- }
- }
-}
+/** Retains the installed Classic activity name across host updates. */
+public final class PocketActivity extends dev.pocketstack.android.PocketActivity {}
diff --git a/hosts/shared/offload_coverage.h b/hosts/shared/offload_coverage.h
new file mode 100644
index 000000000..86a622a19
--- /dev/null
+++ b/hosts/shared/offload_coverage.h
@@ -0,0 +1,72 @@
+#ifndef POCKET_OFFLOAD_COVERAGE_H
+#define POCKET_OFFLOAD_COVERAGE_H
+#include
+#include
+#include
+static inline int coverage_digit(char c) {
+ if (c >= 'A' && c <= 'Z') return c - 'A';
+ if (c >= 'a' && c <= 'z') return c - 'a' + 26;
+ if (c >= '0' && c <= '9') return c - '0' + 52;
+ return c == '+' ? 62 : c == '/' ? 63 : -1;
+}
+/* Maximum envelope area 8192 RGBA pixels. The caller owns one scratch buffer.
+ * Input is 2-bit alpha, four pixels per byte, low bits first. */
+static inline unsigned coverage_height(unsigned height) {
+ unsigned padded = 16; while (padded < height && padded < 128) padded *= 2;
+ return padded;
+}
+static inline int coverage_decode(const char *base64, size_t length, unsigned width, unsigned height, uint32_t color, uint8_t *rgba) {
+ if (!width || width > 512 || width % 4 || !height || height > 128) return 0;
+ unsigned envelope = 8; while (envelope < width) envelope *= 2;
+ if (envelope * coverage_height(height) > 8192) return 0;
+ unsigned count = width * height, bytes = count / 4;
+ if (length != ((bytes + 2) / 3) * 4) return 0;
+ memset(rgba, 0, 512 * 16 * 4);
+ unsigned pixel = 0;
+ for (size_t i = 0; i < length; i += 4) {
+ uint32_t value = 0;
+ for (unsigned j = 0; j < 4; j++) {
+ int digit = coverage_digit(base64[i + j]);
+ if (digit < 0) {
+ if (base64[i + j] != '=' || i + 4 != length || j < 2) return 0;
+ digit = 0;
+ }
+ value = (value << 6) | (unsigned)digit;
+ }
+ for (int byte = 2; byte >= 0 && pixel < count; byte--) {
+ unsigned packed = (value >> (byte * 8)) & 255;
+ for (unsigned part = 0; part < 4; part++, pixel++) {
+ uint8_t *p = rgba + ((pixel / width) * envelope + pixel % width) * 4;
+ p[0] = color; p[1] = color >> 8; p[2] = color >> 16;
+ p[3] = ((packed >> (part * 2)) & 3) * 85;
+ }
+ }
+ }
+ return (int)envelope;
+}
+static inline int coverage_hex(char c) {
+ return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1;
+}
+/* Optional horizontal palette: one hex index per column, up to 16 RGB colors.
+ * Fixed work, same scratch allocation and one uploaded texture. */
+static inline int coverage_colorize(const char *columns, size_t columns_length,
+ const char *palette, size_t palette_length, unsigned width, unsigned height, unsigned envelope, uint8_t *rgba) {
+ if (!width || width > 512 || !height || height > 128 || envelope < width || envelope > 512 || envelope * coverage_height(height) > 8192 ||
+ columns_length != width || !palette_length || palette_length > 96 || palette_length % 6) return 0;
+ uint8_t colors[16][3];
+ for (size_t i = 0; i < palette_length; i += 2) {
+ int a = coverage_hex(palette[i]), b = coverage_hex(palette[i + 1]);
+ if (a < 0 || b < 0) return 0;
+ colors[i / 6][(i % 6) / 2] = (uint8_t)((a << 4) | b);
+ }
+ for (unsigned x = 0; x < width; x++) {
+ int ink = coverage_hex(columns[x]);
+ if (ink < 0 || (unsigned)ink >= palette_length / 6) return 0;
+ }
+ for (unsigned y = 0; y < height; y++) for (unsigned x = 0; x < width; x++) {
+ uint8_t *p = rgba + (y * envelope + x) * 4;
+ memcpy(p, colors[coverage_hex(columns[x])], 3);
+ }
+ return 1;
+}
+#endif
diff --git a/hosts/shared/offload_posix.c b/hosts/shared/offload_posix.c
new file mode 100644
index 000000000..a0fca72b4
--- /dev/null
+++ b/hosts/shared/offload_posix.c
@@ -0,0 +1,141 @@
+#include "offload_posix.h"
+#include "offload_queue.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static OffloadQueue outgoing, incoming;
+static _Atomic unsigned generation, stopping;
+static pthread_t worker;
+static int started;
+static char key_file[1024];
+static unsigned listen_port;
+
+static int transfer(int fd, void *data, size_t size, int sending) {
+ char *p = data;
+#ifdef MSG_NOSIGNAL
+ int flags = MSG_NOSIGNAL;
+#else
+ int flags = 0;
+#endif
+ while (size && !atomic_load(&stopping)) {
+ ssize_t n = sending ? send(fd, p, size, flags) : recv(fd, p, size, 0);
+ if (n < 0 && errno == EINTR) continue;
+ if (n <= 0) return 0;
+ p += n; size -= (size_t)n;
+ }
+ return size == 0;
+}
+static int read_key(char key[64]) {
+ FILE *f = fopen(key_file, "rb");
+ if (!f) return 0;
+ size_t n = fread(key, 1, 64, f);
+ int extra = fgetc(f);
+ fclose(f);
+ if (n != 64 || (extra != EOF && extra != '\n')) return 0;
+ for (unsigned i = 0; i < 64; i++)
+ if (!((key[i] >= '0' && key[i] <= '9') || (key[i] >= 'a' && key[i] <= 'f'))) return 0;
+ return 1;
+}
+static void *serve(void *unused) {
+ (void)unused;
+ unsigned next_generation = 0;
+ int listener = socket(AF_INET, SOCK_STREAM, 0);
+ if (listener < 0) return NULL;
+ int yes = 1;
+ setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
+ struct sockaddr_in address;
+ memset(&address, 0, sizeof address);
+ address.sin_family = AF_INET;
+ /* USB forwards connect to device loopback. No LAN text-input listener. */
+ address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ address.sin_port = htons((uint16_t)listen_port);
+ if (bind(listener, (struct sockaddr *)&address, sizeof address) || listen(listener, 1)) {
+ close(listener); return NULL;
+ }
+ while (!atomic_load(&stopping)) {
+ struct pollfd ready = {listener, POLLIN, 0};
+ if (poll(&ready, 1, 20) <= 0) continue;
+ int fd = accept(listener, NULL, NULL);
+ if (fd < 0) continue;
+ struct timeval timeout = {1, 0};
+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout);
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof timeout);
+ setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &yes, sizeof yes);
+#ifdef SO_NOSIGPIPE
+ setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &yes, sizeof yes);
+#endif
+ char expected[64], received[64];
+ int authenticated = read_key(expected) && transfer(fd, received, 64, 0);
+ unsigned difference = 0;
+ if (authenticated) for (unsigned i = 0; i < 64; i++) difference |= (unsigned char)expected[i] ^ (unsigned char)received[i];
+ if (!authenticated || difference) { close(fd); continue; }
+ unsigned current = ++next_generation;
+ if (!current) current = ++next_generation;
+ atomic_store(&generation, current);
+ time_t heartbeat = 0;
+ while (!atomic_load(&stopping)) {
+ struct timeval now; gettimeofday(&now, NULL);
+ if (now.tv_sec != heartbeat) {
+ heartbeat = now.tv_sec;
+ char metrics[160];
+ int n = snprintf(metrics, sizeof metrics, "{\"v\":1,\"id\":0,\"method\":\"offload.metrics\",\"payload\":\"session=%u\"}", current);
+ uint32_t header = htonl((uint32_t)n);
+ if (!transfer(fd, &header, 4, 1) || !transfer(fd, metrics, (size_t)n, 1)) break;
+ }
+ OffloadRecord record;
+ if (offload_pop(&outgoing, &record) && record.generation == current) {
+ uint32_t header = htonl(record.length);
+ if (!transfer(fd, &header, 4, 1) || !transfer(fd, record.bytes, record.length, 1)) break;
+ }
+ ready.fd = fd; ready.events = POLLIN; ready.revents = 0;
+ int available = poll(&ready, 1, 2);
+ if (available < 0 && errno != EINTR) break;
+ if (available <= 0) continue;
+ uint32_t header;
+ if (!transfer(fd, &header, 4, 0)) break;
+ unsigned length = ntohl(header);
+ if (!length || length > OFFLOAD_BYTES || !transfer(fd, record.bytes, length, 0)) break;
+ if (!offload_push(&incoming, record.bytes, length, current)) break;
+ }
+ atomic_store(&generation, 0);
+ close(fd);
+ }
+ close(listener);
+ return NULL;
+}
+void pocket_offload_start(const char *path, unsigned port) {
+ if (started || !path || !*path || strlen(path) >= sizeof key_file || !port || port > 65535) return;
+ strcpy(key_file, path); listen_port = port;
+ atomic_store(&stopping, 0);
+ started = pthread_create(&worker, NULL, serve, NULL) == 0;
+}
+void pocket_offload_stop(void) {
+ if (!started) return;
+ atomic_store(&stopping, 1);
+ pthread_join(worker, NULL);
+ started = 0;
+ atomic_store(&generation, 0);
+ memset(&outgoing, 0, sizeof outgoing);
+ memset(&incoming, 0, sizeof incoming);
+}
+unsigned pocket_offload_session(void) { return atomic_load(&generation); }
+int pocket_offload_submit(const char *bytes, size_t length) {
+ unsigned session = pocket_offload_session();
+ return session && length <= OFFLOAD_BYTES && offload_push(&outgoing, bytes, (uint32_t)length, session);
+}
+size_t pocket_offload_take(char *bytes) {
+ OffloadRecord record;
+ if (!offload_pop(&incoming, &record) || record.generation != pocket_offload_session()) return 0;
+ memcpy(bytes, record.bytes, record.length);
+ return record.length;
+}
diff --git a/hosts/shared/offload_posix.h b/hosts/shared/offload_posix.h
new file mode 100644
index 000000000..d5cbc7be8
--- /dev/null
+++ b/hosts/shared/offload_posix.h
@@ -0,0 +1,11 @@
+#ifndef POCKET_OFFLOAD_POSIX_H
+#define POCKET_OFFLOAD_POSIX_H
+#include
+#include
+/* The worker owns sockets and key-file reads. Guest calls only copy queues. */
+void pocket_offload_start(const char *key_path, unsigned port);
+void pocket_offload_stop(void);
+unsigned pocket_offload_session(void);
+int pocket_offload_submit(const char *bytes, size_t length);
+size_t pocket_offload_take(char *bytes);
+#endif
diff --git a/hosts/shared/offload_queue.h b/hosts/shared/offload_queue.h
new file mode 100644
index 000000000..33742b972
--- /dev/null
+++ b/hosts/shared/offload_queue.h
@@ -0,0 +1,33 @@
+#ifndef POCKET_OFFLOAD_QUEUE_H
+#define POCKET_OFFLOAD_QUEUE_H
+#include
+#include
+#include
+#include
+#define OFFLOAD_BYTES 4096
+#define OFFLOAD_SLOTS 8
+typedef struct { uint32_t generation, length; char bytes[OFFLOAD_BYTES]; } OffloadRecord;
+/* Single producer, single consumer. Neither endpoint waits on the other.
+ * A published slot is immutable until its consumer releases it. */
+typedef struct {
+ _Atomic uint32_t read, write;
+ OffloadRecord slots[OFFLOAD_SLOTS];
+} OffloadQueue;
+static inline bool offload_push(OffloadQueue *q, const char *p, uint32_t n, uint32_t generation) {
+ uint32_t w = atomic_load_explicit(&q->write, memory_order_relaxed);
+ uint32_t r = atomic_load_explicit(&q->read, memory_order_acquire);
+ if (n == 0 || n > OFFLOAD_BYTES || w - r >= OFFLOAD_SLOTS) return false;
+ OffloadRecord *s = &q->slots[w % OFFLOAD_SLOTS];
+ s->length = n; s->generation = generation; memcpy(s->bytes, p, n);
+ atomic_store_explicit(&q->write, w + 1, memory_order_release);
+ return true;
+}
+static inline bool offload_pop(OffloadQueue *q, OffloadRecord *out) {
+ uint32_t r = atomic_load_explicit(&q->read, memory_order_relaxed);
+ uint32_t w = atomic_load_explicit(&q->write, memory_order_acquire);
+ if (r == w) return false;
+ *out = q->slots[r % OFFLOAD_SLOTS];
+ atomic_store_explicit(&q->read, r + 1, memory_order_release);
+ return true;
+}
+#endif
diff --git a/package.json b/package.json
index 37aba4c41..f448e0671 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,8 @@
"hosts/blackberry-classic-android",
"hosts/blackberry-classic-qnx",
"hosts/web",
+ "hosts/android",
+ "hosts/shared",
"docs/APPLE.md",
"docs/IPHONE2G.md",
"docs/IPHONE4S.md",
@@ -150,6 +152,10 @@
"./classic": "./framework/src/classic.ts",
"./offload/provider": "./tools/offload-provider.ts",
"./offload/capabilities": "./tools/offload-capabilities.ts",
+ "./ime": "./framework/src/ime.ts",
+ "./text": "./framework/src/text.ts",
+ "./text-view": "./framework/src/text-view.ts",
+ "./text/provider": "./tools/text-provider.ts",
"./offload": "./framework/src/offload.ts",
"./resource-state": "./framework/src/resource-state.ts",
"./resource-cache": "./framework/src/resource-cache.ts",
@@ -190,6 +196,9 @@
"./solid/renderer": "./framework/src/renderer-solid.ts",
"./vue-vapor": "./framework/src/index-vue-vapor.ts",
"./vue-vapor/animation": "./framework/src/animation.ts",
+ "./vue-vapor/ime": "./framework/src/ime.ts",
+ "./vue-vapor/text": "./framework/src/text.ts",
+ "./vue-vapor/text-view": "./framework/src/text-view.ts",
"./vue-vapor/offload": "./framework/src/offload.ts",
"./vue-vapor/resource-state": "./framework/src/resource-state.ts",
"./vue-vapor/resource-cache": "./framework/src/resource-cache.ts",
@@ -209,6 +218,9 @@
"./vue-vapor/renderer": "./framework/src/renderer-vue-vapor.ts",
"./octane": "./framework/src/index-octane.ts",
"./octane/animation": "./framework/src/animation.ts",
+ "./octane/ime": "./framework/src/ime.ts",
+ "./octane/text": "./framework/src/text.ts",
+ "./octane/text-view": "./framework/src/text-view.ts",
"./octane/offload": "./framework/src/offload.ts",
"./octane/resource-state": "./framework/src/resource-state.ts",
"./octane/resource-cache": "./framework/src/resource-cache.ts",
@@ -297,7 +309,11 @@
"esp-idf:native": "bun tools/esp-idf-native.ts",
"esp-idf:release": "bun tools/esp-idf-release.ts",
"vapor:dev": "bun vapor/scripts/dev.ts",
- "vapor:check": "bun vapor/compiler/cli.ts check vapor/examples/todo/todo.tsx"
+ "vapor:check": "bun vapor/compiler/cli.ts check vapor/examples/todo/todo.tsx",
+ "android": "bun tools/android.ts",
+ "moto-g-play": "bun tools/moto-g-play.ts",
+ "ime:setup": "bun tools/ime/setup.ts",
+ "clear:companion": "bun tools/ime/device.ts"
},
"dependencies": {
"@vue/compiler-sfc": "3.6.0-rc.1",
diff --git a/tests/clear-candidate-panel.test.ts b/tests/clear-candidate-panel.test.ts
new file mode 100644
index 000000000..d6d14c78f
--- /dev/null
+++ b/tests/clear-candidate-panel.test.ts
@@ -0,0 +1,47 @@
+import { expect, test } from "bun:test";
+import { candidateGrid, candidateSlots, createCandidatePanel } from "../apps/clear/candidate-panel.ts";
+import { createScrollerWith } from "../framework/src/kinetics-core.ts";
+import type { ImeState } from "../framework/src/ime.ts";
+
+test("candidate grid wraps phrases and keeps at least 44-point row hit targets", () => {
+ const cells = candidateGrid(["你", "你好", "你好吗", "这是更长的候选词"], 320);
+ expect(cells.every(c => c.w >= 64 && c.x + c.w <= 320)).toBe(true);
+ expect(cells.at(-1)!.y).toBe(44);
+ const phrase = "这是一个需要占据多行才能完整显示的候选词";
+ const lines = candidateGrid([phrase, "你"], 320);
+ expect(lines.filter(c => c.index === 0).map(c => c.text).join("")).toBe(phrase);
+ expect(lines.filter(c => c.index === 0).length).toBeGreaterThan(1);
+ const continued = candidateGrid(["你", "你好", "你好吗", "第四个", "第五个"], 320, 0, 3);
+ expect(continued.map(c => [c.index, c.x, c.y])).toEqual([[3, 0, 0], [4, 72, 0]]);
+});
+test("scrolling retains candidate slots and recycles only the departing row", () => {
+ const cells = candidateGrid(Array.from({ length: 40 }, (_, i) => `你${i}`), 320);
+ const slots = candidateSlots(Array(25).fill(null), cells.slice(0, 25));
+ const next = candidateSlots(slots, cells.slice(5, 30));
+ expect(next.slice(5)).toEqual(slots.slice(5));
+ expect(next.slice(0, 5)).toEqual(cells.slice(25, 30));
+ expect(candidateSlots(next, cells.slice(0, 25))).toEqual(slots);
+ const lines = candidateGrid(["这是一个需要占据多行才能完整显示的候选词", "你"], 320);
+ expect(candidateSlots(Array(3).fill(null), lines).filter(Boolean)).toEqual(lines);
+});
+test("candidate panel scroll does not select; tap uses absolute index; mode changes close it", () => {
+ let panel: ReturnType;
+ const scroll = createScrollerWith(initial => { let value = initial; return [() => value, n => { value = n; }] as const; },
+ { max: () => panel?.max() ?? 0, extent: () => 180, overscroll: 0 });
+ const selected: number[] = [], reads: number[] = [];
+ let complete: Parameters[0]["browse"]>[1];
+ panel = createCandidatePanel({ width: 320, height: 180, scroller: scroll, select: index => selected.push(index),
+ browse(offset, callback) { reads.push(offset); complete = callback; return 1; } });
+ const state: ImeState = { preedit: "ni", caret: 2, candidates: ["你"], commit: "", page: 0, last: false,
+ pending: false, connected: true, error: "", revision: 1, composing: true };
+ panel.setState(state, true); panel.toggle(); panel.step();
+ expect(reads).toEqual([0]);
+ complete!({ offset: 0, candidates: Array.from({ length: 15 }, (_, i) => `候选${i}`), last: false });
+ panel.press(0, 30, 150, 1); panel.move(0, 30, 40, 1.1); panel.release(0, false);
+ expect(selected).toEqual([]); expect(scroll.offset()).toBeGreaterThan(0);
+ scroll.scrollTo(44, { immediate: true }); panel.press(0, 30, 10, 2); panel.release(0, false);
+ expect(selected).toEqual([4]); expect(panel.isOpen()).toBe(false);
+ panel.toggle(); panel.setState({ ...state, composing: false, preedit: "", revision: 2 }, true);
+ expect(panel.isOpen()).toBe(false); panel.toggle(); expect(panel.isOpen()).toBe(false);
+ panel.setState(state, false); panel.toggle(); expect(panel.isOpen()).toBe(false);
+});
diff --git a/tests/clear-ime-loading.test.ts b/tests/clear-ime-loading.test.ts
new file mode 100644
index 000000000..d6300a350
--- /dev/null
+++ b/tests/clear-ime-loading.test.ts
@@ -0,0 +1,141 @@
+import { afterAll, expect, test } from "bun:test";
+import { bootWorld, treeHasText } from "../hosts/sim/sim.ts";
+import { __packTouchWide } from "../framework/src/touch.ts";
+import type { HostOps } from "../framework/src/host.ts";
+import type { OffloadRequest } from "../contracts/spec/offload.ts";
+import { IME } from "../contracts/spec/ime.ts";
+import { PROP } from "../contracts/spec/spec.ts";
+import { KB_H, KB_PAD, KB_ROW_H, KB_GAP, IME_BAR_H, IME_LABEL_H, IME_LABEL_GAP } from "../apps/clear/keyboard-metrics.ts";
+
+afterAll(() => { delete (globalThis as { offload?: unknown }).offload; });
+for (const [width, height] of [[320, 480], [360, 800]]) test(`Clear retains text and paints compact IME controls at ${width}x${height}`, async () => {
+ const sent: OffloadRequest[] = [], replies: string[] = [], held: OffloadRequest[] = [], uploads: number[] = [];
+ let textWrites = 0, topWrites = 0, allowGlyphs = false, frame = 0, lastUpload = -1, session = 1, ops: HostOps;
+ function answerGlyph(request: OffloadRequest) {
+ const { face, size, density } = JSON.parse(request.payload);
+ const width = size * density, height = 2 ** Math.ceil(Math.log2((size + 8) * density));
+ replies.push(JSON.stringify({ id: request.id, payload: JSON.stringify({ face, advance: size, xoff: 0, width, height,
+ mask: Buffer.alloc(width * height / 4, 255).toString("base64") }) }));
+ }
+ const world = await bootWorld("clear-main.vue-vapor", 60, { offload: {
+ session: () => session, take: () => replies.shift(),
+ submit(raw: string) {
+ const request = JSON.parse(raw) as OffloadRequest; sent.push(request);
+ if (request.method === "text.font") replies.push(JSON.stringify({ id: request.id, payload: JSON.stringify({ id: "a".repeat(64), mapping: "scalar" }) }));
+ if (request.method === "text.glyph") { if (allowGlyphs) answerGlyph(request); else held.push(request); }
+ if (request.method === "ime.candidates") {
+ const { offset } = JSON.parse(request.payload);
+ const candidates = Array.from({ length: 15 }, (_, i) => offset + i === 0 ? "你好" : `你${offset + i}`);
+ replies.push(JSON.stringify({ id: request.id, payload: JSON.stringify({ offset, candidates, last: offset >= 45 }) }));
+ }
+ return true;
+ },
+ uploadCoverage(mask: string, width: number, height: number) {
+ if (lastUpload === frame) return 0;
+ lastUpload = frame; uploads.push(frame);
+ const envelope = 2 ** Math.ceil(Math.log2(width)), data = new Uint8Array(envelope * height * 4), packed = Buffer.from(mask, "base64");
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
+ const i = y * width + x, j = (y * envelope + x) * 4;
+ data[j] = data[j + 1] = data[j + 2] = 255; data[j + 3] = ((packed[i >> 2] >> ((i & 3) * 2)) & 3) * 85;
+ }
+ return ops.uploadTexture(data, envelope, height, 3);
+ },
+ } }, native => {
+ ops = native as unknown as HostOps;
+ const setProp = ops.setProp.bind(ops), batch = ops.setPropBatch?.bind(ops);
+ ops.setProp = (id, prop, value) => { if (prop === PROP.insetT) topWrites++; setProp(id, prop, value); };
+ if (batch) ops.setPropBatch = records => {
+ const values = new Float64Array(records);
+ for (let i = 0; i < values.length; i += 3) if (values[i + 1] === PROP.insetT) topWrites++;
+ batch(records);
+ };
+ for (const method of ["setText", "replaceText"] as const) {
+ const original = ops[method].bind(ops);
+ ops[method] = (id, text) => { if (text) textWrites++; return original(id, text); };
+ }
+ }, { width, height, rasterDensity: 2 });
+ async function step(x?: number, y?: number) {
+ frame++; world.frame(0, undefined, x === undefined ? undefined : [__packTouchWide(0, x, y!)]); world.tick(); await Promise.resolve();
+ }
+ async function idle(n: number) { for (let i = 0; i < n; i++) await step(); }
+ async function tap(x: number, y: number) { await step(x, y); await step(x, y); await step(); }
+ const barTop = height - KB_H - IME_BAR_H, candidateY = barTop + 22;
+ const keyY = (row: number) => height - KB_H + KB_PAD + row * (KB_ROW_H + KB_GAP) + KB_ROW_H / 2;
+ const pixel = (x = 12, y = candidateY) => Array.from(world.render().slice((y * width + x) * 4, (y * width + x) * 4 + 3));
+ const glyphs = () => sent.filter(r => r.method === "text.glyph" && JSON.parse(r.payload).size === 16);
+ const compositions = () => sent.filter(r => r.method === "ime.compose");
+ function answerComposition(preedit: string, commit = "") {
+ const request = compositions().at(-1)!;
+ replies.push(JSON.stringify({ id: request.id, payload: JSON.stringify({ preedit, caret: preedit.length, commit,
+ candidates: preedit ? ["你好", "呢"] : [], page: 0, last: false }) }));
+ }
+ function controlsInk() {
+ const pixels = world.render(); let ink = 0;
+ for (let y = barTop + 1; y < barTop + 43; y++) for (let x = width - 87; x < width - 1; x++) if (pixels[(y * width + x) * 4] > 90) ink++;
+ return ink;
+ }
+ await idle(8); await tap(100, 31); await idle(25); await tap(100, 31); await idle(25);
+ expect(controlsInk()).toBe(0); // empty PY exposes no useless controls
+ const rest = pixel();
+ expect(treeHasText(world.getTree(), "Pinyin")).toBe(false);
+ await tap(224 * width / 320, keyY(2)); await idle(18); // n, conversion held
+ expect(treeHasText(world.getTree(), "...")).toBe(false);
+ const low = pixel(); expect(low[0]).toBeGreaterThan(rest[0]); expect(Math.max(...low)).toBeLessThan(100);
+ await idle(35); const high = pixel(); expect(high).not.toEqual(low);
+ expect(Math.max(...high.map((v, i) => Math.abs(v - low[i])))).toBeLessThan(20);
+ answerComposition("n"); await idle(20);
+ expect(held.length).toBe(2); // scheduler allows two concurrent glyph reads
+ expect(controlsInk()).toBeGreaterThan(0);
+ allowGlyphs = true; for (const request of held.splice(0)) answerGlyph(request);
+ await idle(60); expect(pixel()).toEqual([255, 255, 255]);
+ expect(pixel(Math.round(166 * width / 320), keyY(3))).toEqual([255, 255, 255]); // mode glyphs must paint inside the Space cap
+ expect(glyphs()).toHaveLength(3); // 你 好 呢, one resident texture each
+ const count = glyphs().length;
+ const badgeY = barTop - IME_LABEL_H - IME_LABEL_GAP + 2;
+ const shortBadge = pixel(38, badgeY);
+ await tap(240 * width / 320, keyY(0)); await idle(12); answerComposition("nihao"); await idle(12);
+ expect(pixel(38, badgeY)).not.toEqual(shortBadge); // measured label grows from the left
+ expect(pixel(200, badgeY)).toEqual(pixel(250, badgeY)); // no full-width second row
+ expect(glyphs()).toHaveLength(count);
+ await tap(width - 22, candidateY); await idle(30); // 44-point disclosure target
+ expect(sent.some(r => r.method === "ime.candidates")).toBe(true);
+ await step(30, height - 40);
+ let peakWrites = 0;
+ for (let dy = 1; dy <= 120; dy++) {
+ const before = textWrites, topBefore = topWrites; await step(30, height - 40 - dy);
+ peakWrites = Math.max(peakWrites, textWrites - before);
+ if (dy >= 10 && dy <= 30) expect(topWrites).toBe(topBefore); // scroll inside one row moves paint, not layout
+ }
+ await step(); await idle(20);
+ // A row entering the viewport creates at most one row of text; surviving
+ // candidates must not all be rebound in a frame at the overscan boundary.
+ expect(peakWrites).toBeGreaterThan(0);
+ expect(peakWrites).toBeLessThanOrEqual(Math.floor(width / 64));
+ const beforeDrag = compositions().length;
+ await step(30, height - 40); for (let y = height - 50; y >= height - 180; y -= 10) await step(30, y); await step(); await idle(30);
+ expect(compositions()).toHaveLength(beforeDrag); // scroll release never commits
+ expect(sent.filter(r => r.method === "ime.candidates").some(r => JSON.parse(r.payload).offset >= 15)).toBe(true);
+ await tap(width - 22, candidateY); await idle(5); await tap(width - 22, candidateY); await idle(5);
+ await tap(30, height - KB_H + 22); await idle(5);
+ expect(JSON.parse(compositions().at(-1)!.payload).at(-1)).toBe(IME.selectAbsolute + 3); // continue after inline candidates
+ answerComposition("", "你好"); await idle(40);
+ expect(treeHasText(world.getTree(), "Swipe right to complete你好|")).toBe(true);
+ expect(controlsInk()).toBe(0);
+ const prefixWidth = ops!.measureText("Swipe right to complete", 11), x = Math.floor(12 + prefixWidth);
+ const before = world.render(), requestCount = glyphs().length;
+ session = 0; await idle(2); await tap(width - 20, keyY(2)); await idle(2); // delete 好 offline
+ expect(treeHasText(world.getTree(), "Swipe right to complete你|")).toBe(true);
+ const after = world.render();
+ for (let y = 12; y < 48; y++) for (let px = 12; px < x + 19; px++) {
+ const i = (y * width + px) * 4;
+ expect(Array.from(after.slice(i, i + 4))).toEqual(Array.from(before.slice(i, i + 4)));
+ }
+ expect(pixel(x + 34, 31)).not.toEqual([255, 255, 255]);
+ expect(glyphs()).toHaveLength(requestCount);
+ const spaceX = Math.round(166 * width / 320);
+ for (let i = 0; i < 16; i++) await step(spaceX, keyY(3));
+ await step(spaceX - 10, keyY(3)); await step(); await idle(20);
+ expect(pixel(spaceX, keyY(3))).toEqual([255, 255, 255]); // mode label survives the trackpad branch
+ await tap(72 * width / 320, keyY(3)); await idle(4); expect(controlsInk()).toBe(0); // EN
+ expect(new Set(uploads).size).toBe(uploads.length);
+});
diff --git a/tests/clear-keyboard-touch.test.ts b/tests/clear-keyboard-touch.test.ts
new file mode 100644
index 000000000..1a0eaa98a
--- /dev/null
+++ b/tests/clear-keyboard-touch.test.ts
@@ -0,0 +1,68 @@
+import { expect, test } from "bun:test";
+import { createKeyboardTouch, KEY_HOLD } from "../apps/clear/keyboard-touch.ts";
+function fixture() {
+ const events: (string | number | boolean)[] = [];
+ const touch = createKeyboardTouch({ space: () => events.push("space"), backspace: () => events.push("delete"),
+ caret: d => events.push(d), trackpad: a => events.push(a) });
+ const begin = (kind: "space" | "backspace" | "other", id = 0, now = 0) =>
+ touch.begin(id, 100, 100, kind, { x: 80, y: 80, w: 60, h: 40 }, now);
+ return { touch, events, begin };
+}
+test("short space commits on release, rolling two-thumb typing keeps order", () => {
+ const { touch, events, begin } = fixture();
+ begin("space"); touch.step(.15); expect(events).toEqual([]);
+ touch.release(0); expect(events).toEqual(["space"]);
+ begin("space"); begin("other", 1, .1); events.push("letter"); touch.release(0); touch.release(1);
+ expect(events).toEqual(["space", "space", "letter"]);
+});
+test("space enters caret mode at 200 ms without inserting; detents reject jitter and allow reversal", () => {
+ const { touch, events, begin } = fixture();
+ expect(KEY_HOLD.space).toBe(.2);
+ begin("space"); touch.step(.199); expect(touch.tracking()).toBe(false);
+ touch.step(.2); expect(touch.tracking()).toBe(true);
+ touch.move(0, 106, 100); expect(events).toEqual([true]);
+ touch.move(0, 107, 100); touch.move(0, 106, 100); touch.move(0, 107, 100);
+ expect(events).toEqual([true, 1]);
+ touch.move(0, 103, 100); expect(events).toEqual([true, 1, -1]);
+ touch.move(0, 70, 140); expect(events).toEqual([true, 1, -1, -1, -1, -1]);
+ expect(begin("backspace", 1)).toBe(false); touch.release(0);
+ expect(events.at(-1)).toBe(false); expect(events).not.toContain("space");
+});
+test("cancel, leaving a held key and ending trackpad stop their pending actions", () => {
+ const { touch, events, begin } = fixture();
+ begin("space"); touch.move(0, 130, 100); touch.step(1); touch.release(0); expect(events).toEqual([]);
+ begin("backspace"); touch.move(0, 160, 100); touch.step(2); touch.release(0);
+ expect(events).toEqual(["delete"]);
+ begin("space"); touch.step(.4); touch.cancel(); touch.move(0, 0, 0); touch.step(10);
+ expect(events).toEqual(["delete", true, false]);
+});
+for (const hz of [30, 60, 120]) test(`backspace repeats with time, accelerates, and stops on release at ${hz} Hz`, () => {
+ const { touch, events, begin } = fixture();
+ begin("backspace"); expect(events).toEqual(["delete"]);
+ for (let i = 1; i <= hz; i++) touch.step(i / hz);
+ expect(events.length).toBe(8); // down + .43/.515/.60/.685/.77/.855/.94
+ for (let i = hz + 1; i <= hz * 3; i++) touch.step(i / hz);
+ expect(events.length).toBeGreaterThanOrEqual(39);
+ expect(events.length).toBeLessThanOrEqual(40);
+ const count = events.length; touch.release(0); touch.step(100); expect(events.length).toBe(count);
+});
+test("a stalled frame cannot burst an unbounded backlog of deletes or caret moves", () => {
+ const { touch, events, begin } = fixture();
+ begin("backspace"); touch.step(60); expect(events.length).toBe(3); touch.cancel();
+ begin("space"); touch.step(.4); touch.move(0, 10000, 100);
+ expect(events.filter(e => e === 1).length).toBe(8);
+});
+
+test("space stays held through trackpad activation and a two-thumb chord until release or cancel", () => {
+ const { touch, begin } = fixture();
+ expect(touch.holdingSpace()).toBe(false);
+ begin("space"); expect(touch.holdingSpace()).toBe(true);
+ touch.step(.25); expect(touch.holdingSpace()).toBe(true);
+ touch.step(.4); expect(touch.holdingSpace()).toBe(true);
+ touch.move(0, 70, 140); expect(touch.holdingSpace()).toBe(true);
+ touch.release(0); expect(touch.holdingSpace()).toBe(false);
+ begin("space"); begin("other", 1, .1); expect(touch.holdingSpace()).toBe(true);
+ touch.release(1); expect(touch.holdingSpace()).toBe(true);
+ touch.cancel(); expect(touch.holdingSpace()).toBe(false);
+ begin("space"); touch.move(0, 130, 100); expect(touch.holdingSpace()).toBe(false);
+});
diff --git a/tests/clear.test.ts b/tests/clear.test.ts
index e7d77269b..ae884956e 100644
--- a/tests/clear.test.ts
+++ b/tests/clear.test.ts
@@ -96,6 +96,19 @@ describe("Pocket Clear on the sim", () => {
const tree = world.getTree();
expect(treeHasText(tree, "Swipe right to complete")).toBe(true);
expect(treeHasText(tree, "Pinch two rows apart to insert")).toBe(true);
+ // Short and long titles share the row's 12-point left inset.
+ const pixels = world.render();
+ for (let row = 0; row < 4; row++) {
+ let left = W;
+ for (let y = row * ROW_H; y < (row + 1) * ROW_H; y++) {
+ for (let x = 0; x < W; x++) {
+ const i = (y * W + x) * 4;
+ if (pixels[i] > 210 && pixels[i + 1] > 210 && pixels[i + 2] > 210) left = Math.min(left, x);
+ }
+ }
+ expect(left).toBeGreaterThanOrEqual(12);
+ expect(left).toBeLessThanOrEqual(16);
+ }
});
test("swipe right completes the row under the finger", async () => {
@@ -178,6 +191,55 @@ describe("Pocket Clear on the sim", () => {
expect(treeHasText(world.getTree(), "Z5€m|")).toBe(false);
});
+ test("held space scrubs the real editor; held backspace deletes and stops at lift", async () => {
+ await tap(160, rowCenterY(0));
+ await idle(20);
+ const [bx, by] = keyCenter(key => key.action === "backspace");
+ for (let i = 0; i < 180; i++) await step([__packTouch(0, bx, by)]);
+ await step();
+ // Probe a blank part of q's popup: quick taps retain it, then fade;
+ // holding a character keeps it up beyond the minimum display duration.
+ const [qx, qy] = keyCenter(key => key.ch === "q");
+ const pixel = () => Array.from(world.render().slice(((qy - 64) * W + qx) * 4, ((qy - 64) * W + qx) * 4 + 3));
+ const empty = pixel();
+ await step([__packTouch(0, qx, qy)]); await step();
+ const raised = pixel(); expect(raised).not.toEqual(empty);
+ await idle(10); expect(pixel()).toEqual(raised);
+ await idle(20); expect(pixel()).toEqual(empty);
+ for (let i = 0; i < 35; i++) await step([__packTouch(0, qx, qy)]);
+ expect(pixel()).toEqual(raised);
+ await step(); await idle(5); expect(pixel()).toEqual(raised);
+ await idle(20); expect(pixel()).toEqual(empty);
+ await tapKey("lower", key => key.action === "backspace");
+ await tapKey("lower", key => key.action === "backspace");
+ for (const ch of "abcdef") await tapKey("lower", key => key.ch === ch);
+ expect(treeHasText(world.getTree(), "abcdef|")).toBe(true);
+ const [sx, sy] = keyCenter(key => key.ch === " ");
+ const spacePixel = () => Array.from(world.render().slice((sy * W + sx - 60) * 4, (sy * W + sx - 60) * 4 + 3));
+ const restingCap = spacePixel();
+ await step([__packTouch(0, sx, sy)]);
+ const pressedCap = spacePixel(); expect(pressedCap).not.toEqual(restingCap);
+ // The cap stays pressed before and across the shorter 200 ms activation.
+ for (let i = 0; i < 8; i++) await step([__packTouch(0, sx, sy)]);
+ expect(spacePixel()).toEqual(pressedCap);
+ for (let i = 0; i < 6; i++) await step([__packTouch(0, sx, sy)]);
+ expect(spacePixel()).toEqual(pressedCap); // activation must not release the cap
+ await step([__packTouch(0, sx - 30, sy)]);
+ await step(); await idle(13);
+ expect(spacePixel()).toEqual(restingCap);
+ expect(treeHasText(world.getTree(), "abc|def")).toBe(true);
+ await tapKey("lower", key => key.ch === "x");
+ expect(treeHasText(world.getTree(), "abcx|def")).toBe(true);
+ for (let i = 0; i < 40; i++) await step([__packTouch(0, bx, by)]);
+ await step(); await idle(30);
+ expect(treeHasText(world.getTree(), "|def")).toBe(true);
+ await tapKey("lower", key => key.ch === "q");
+ await idle(30); // no delayed repeat may erase q
+ expect(treeHasText(world.getTree(), "q|def")).toBe(true);
+ await tapKey("lower", key => key.action === "return");
+ await idle(20);
+ });
+
test("pull up past the end clears the done pile", async () => {
// One done row exists ("Swipe right to complete"); 9 rows total, so the
// range max is 558-480=78 and the clear needs ~382px of finger travel.
diff --git a/tests/companion-session.test.ts b/tests/companion-session.test.ts
index 24c9bb1fd..d5f8ddd75 100644
--- a/tests/companion-session.test.ts
+++ b/tests/companion-session.test.ts
@@ -14,7 +14,8 @@ test("paired sessions exchange records, fence reconnect and never retry applicat
socket.on("close", () => sockets.delete(socket));
let auth = Buffer.alloc(0), paired = false;
const decoder = new OffloadDecoder();
- socket.on("data", chunk => {
+ socket.on("data", data => {
+ let chunk = typeof data === "string" ? Buffer.from(data) : data;
if (!paired) {
auth = Buffer.concat([auth, chunk]);
if (auth.length < 64) return;
diff --git a/tests/fixtures/offload-queue.c b/tests/fixtures/offload-queue.c
index 95e041524..65f936853 100644
--- a/tests/fixtures/offload-queue.c
+++ b/tests/fixtures/offload-queue.c
@@ -38,6 +38,12 @@ int main(void) {
assert(!coverage_decode("!!!!", 4, 12, 1, 0, rgba));
assert(!coverage_decode("5OTk", 4, 516, 1, 0, rgba));
assert(!coverage_decode("5OTk", 4, 12, 17, 0, rgba));
+ /* Whole glyphs use the same 8192-pixel scratch budget as wide text strips. */
+ char glyph[684]; memset(glyph, 'A', sizeof glyph); glyph[683] = '=';
+ assert(coverage_decode(glyph, sizeof glyph, 32, 64, 0xffffffff, rgba) == 32);
+ assert(coverage_height(48) == 64);
+ assert(!coverage_decode(glyph, sizeof glyph, 512, 64, 0xffffffff, rgba));
+ assert(!coverage_decode(glyph, sizeof glyph, 64, 129, 0xffffffff, rgba));
char byte = 0; OffloadRecord record;
assert(!offload_pop(&queue, &record));
assert(!offload_push(&queue, &byte, OFFLOAD_BYTES + 1, 0));
diff --git a/tests/ime-text-tile.test.ts b/tests/ime-text-tile.test.ts
new file mode 100644
index 000000000..f601c0c4d
--- /dev/null
+++ b/tests/ime-text-tile.test.ts
@@ -0,0 +1,73 @@
+import { expect, test } from "bun:test";
+import { existsSync } from "node:fs";
+import { resolve } from "node:path";
+import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
+import { createTextTileRenderer } from "../tools/ime/text-tile.ts";
+import { createTextProvider } from "../tools/text-provider.ts";
+const inter = resolve(import.meta.dir, "../assets/fonts/Inter-Regular.ttf");
+GlobalFonts.registerFromPath(inter, "IME Tile Test");
+const cjk = "/System/Library/Fonts/STHeiti Medium.ttc";
+if (existsSync(cjk)) GlobalFonts.registerFromPath(cjk, "IME CJK Test");
+for (const [font, text, size, bold] of [
+ ["IME Tile Test", "Ágj|", 32, false], ["IME Tile Test", "ÉÊÅgj", 40, true],
+ ...(existsSync(cjk) ? [["IME CJK Test", "你好高赢", 32, false], ["IME CJK Test", "國富草測", 40, true]] : []),
+] as [string, string, number, boolean][]) test(`tile stitching preserves all ink: ${text} ${size}px`, () => {
+ const width = 192, render = createTextTileRenderer(font);
+ const rows: number[][] = [];
+ for (let row = 0; row < 4; row++) {
+ const mask = Buffer.from(render(JSON.stringify({text, width, size, row, bold})), "base64");
+ expect(mask.length).toBe(width * 4);
+ for (let y = 0; y < 16; y++) rows.push(Array.from({length: width}, (_, x) => {
+ const i = y * width + x; return mask[i >> 2] >> ((i & 3) * 2) & 3;
+ }));
+ }
+ // Draw in an oversized reference canvas, then compare the ink independent
+ // of its vertical origin. A lost top stroke or descender fails this check.
+ const context = createCanvas(width, 128).getContext("2d");
+ context.font = `${bold ? "bold " : ""}${size}px "${font}"`;
+ context.fillStyle = "white"; context.textBaseline = "alphabetic";
+ context.fillText(text, 0, 64);
+ const pixels = context.getImageData(0, 0, width, 128).data;
+ const reference = Array.from({length: 128}, (_, y) => Array.from({length: width}, (_, x) => Math.round(pixels[(y * width + x) * 4 + 3] / 85)));
+ const trim = (rows: number[][]) => {
+ const first = rows.findIndex(row => row.some(Boolean));
+ let end = rows.length; while (end > first && !rows[end - 1].some(Boolean)) end--;
+ return rows.slice(first, end);
+ };
+ expect(rows[0].some(Boolean)).toBe(false);
+ expect(rows.at(-1)!.some(Boolean)).toBe(false);
+ expect(trim(rows)).toEqual(trim(reference));
+ const lineHeight = size + 16;
+ expect(rows.slice(lineHeight).some(row => row.some(Boolean))).toBe(false);
+});
+test("text tiles reject dimensions beyond the bounded record/canvas", () => {
+ const render = createTextTileRenderer("IME Tile Test");
+ const request = {text:"a", width:120, size:32, row:0, bold:false};
+ for (const invalid of [{width:321}, {width:3}, {row:4}, {size:64}, {column:2}, {text:"x".repeat(257)}])
+ expect(() => render(JSON.stringify({...request, ...invalid}))).toThrow("Invalid text tile");
+});
+
+test("reusable glyph records preserve top strokes and descenders in complete coverage envelopes", () => {
+ const font = existsSync(cjk) ? cjk : inter, provider = createTextProvider(font);
+ const face = JSON.parse(provider["text.font"]()).id;
+ const family = `Pocket-${face}`;
+ for (const text of existsSync(cjk) ? ["你", "好", "高", "赢", "g", "Á"] : ["g", "Á"]) for (const size of [12, 14, 16, 20]) {
+ const glyph = JSON.parse(provider["text.glyph"](JSON.stringify({ face, text, size, density: 2, bold: size === 20 })));
+ const packed = Buffer.from(glyph.mask, "base64"), actual: number[][] = [];
+ for (let y = 0; y < glyph.height; y++) actual.push(Array.from({ length: glyph.width }, (_, x) => {
+ const i = y * glyph.width + x; return packed[i >> 2] >> ((i & 3) * 2) & 3;
+ }));
+ const c = createCanvas(160, 160).getContext("2d");
+ c.font = `${size === 20 ? "bold " : ""}${size * 2}px "${family}"`; c.fillStyle = "white"; c.textBaseline = "alphabetic"; c.fillText(text, 64, 80);
+ const rgba = c.getImageData(0, 0, 160, 160).data;
+ const reference = Array.from({ length: 160 }, (_, y) => Array.from({ length: 160 }, (_, x) => Math.round(rgba[(y * 160 + x) * 4 + 3] / 85)));
+ function ink(rows: number[][]) {
+ let x0 = Infinity, y0 = Infinity, x1 = 0, y1 = 0;
+ rows.forEach((row, y) => row.forEach((alpha, x) => { if (alpha) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); } }));
+ return rows.slice(y0, y1 + 1).map(row => row.slice(x0, x1 + 1));
+ }
+ expect(ink(actual)).toEqual(ink(reference));
+ expect(actual[0].some(Boolean)).toBe(false);
+ expect(actual.slice((size + 8) * 2).some(row => row.some(Boolean))).toBe(false);
+ }
+});
diff --git a/tests/ime.test.ts b/tests/ime.test.ts
new file mode 100644
index 000000000..caa0c7a37
--- /dev/null
+++ b/tests/ime.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, test } from "bun:test";
+import { createIme, IME } from "../framework/src/ime.ts";
+import { createOffloadClient } from "../framework/src/offload.ts";
+import type { ImeSnapshot } from "../contracts/spec/ime.ts";
+
+function fixture() {
+ let session = 1;
+ const sent: { id: number; method: string; payload: string }[] = [], replies: string[] = [], committed: string[] = [];
+ const io = createOffloadClient({ session: () => session, take: () => replies.shift(),
+ submit: raw => { sent.push(JSON.parse(raw)); return true; } });
+ const ime = createIme({ io, changed() {}, commit: value => committed.push(value) });
+ const tick = () => { io.step(); ime.step(); };
+ const answer = (request: number, fields: Partial = {}) => replies.push(JSON.stringify({ id: request,
+ payload: JSON.stringify({ commit: "", preedit: "ni", candidates: ["你", "呢"], page: 0, last: false, caret: (fields.preedit ?? "ni").length, ...fields }) }));
+ return { ime, io, sent, committed, tick, answer, replies, connect: (n: number) => { session = n; } };
+}
+describe("replayable IME", () => {
+ test("candidate windows are reads and absolute selection is revision fenced", () => {
+ const f = fixture(); f.ime.key(110); f.tick(); f.tick(); f.answer(f.sent[0].id); f.tick();
+ let count = 0;
+ expect(f.ime.browse(15, p => { count = p!.candidates.length; })).toBeGreaterThan(0); f.tick();
+ const read = f.sent.at(-1)!;
+ expect(read.method).toBe("ime.candidates");
+ expect(JSON.parse(read.payload)).toEqual({ keys: [110], offset: 15 });
+ f.replies.push(JSON.stringify({ id: read.id, payload: JSON.stringify({ offset: 15, candidates: ["泥", "拟"], last: true }) })); f.tick();
+ expect(count).toBe(2); expect(f.ime.state().preedit).toBe("ni");
+ expect(f.ime.selectAbsolute(16)).toBe(true); f.tick(); f.tick();
+ expect(JSON.parse(f.sent.at(-1)!.payload)).toEqual([110, IME.selectAbsolute + 16]);
+ expect(f.ime.selectAbsolute(15)).toBe(false);
+ });
+ test("typing cancels candidate windows and rejects their stale selections", () => {
+ const f = fixture(); f.ime.key(110); f.tick(); f.tick(); f.answer(f.sent[0].id); f.tick();
+ let completed = false;
+ f.ime.browse(15, () => { completed = true; }); f.tick(); const old = f.sent.at(-1)!.id;
+ f.ime.key(105);
+ f.replies.push(JSON.stringify({ id: old, payload: JSON.stringify({ offset: 15, candidates: ["wrong"], last: true }) })); f.tick();
+ expect(completed).toBe(false); expect(f.ime.selectAbsolute(15)).toBe(false);
+ expect(f.ime.browse(512, () => {})).toBe(0);
+ });
+ test("stale candidates cannot replace a newer composition or be selected", () => {
+ const f = fixture();
+ f.ime.key(110); f.tick(); f.tick();
+ f.ime.key(105);
+ f.answer(f.sent[0].id, { preedit: "n" }); f.tick(); f.tick();
+ expect(f.ime.select(0)).toBe(false);
+ expect(JSON.parse(f.sent[1].payload)).toEqual([110, 105]);
+ f.answer(f.sent[1].id); f.tick();
+ expect(f.ime.state().preedit).toBe("ni");
+ expect(f.ime.select(0)).toBe(true);
+ });
+ test("delayed caret replies cannot move a continuing left drag back to an older position", () => {
+ const f = fixture();
+ for (const ch of "haha") f.ime.key(ch.charCodeAt(0));
+ f.tick(); f.tick(); f.answer(f.sent.at(-1)!.id, { preedit: "ha ha", caret: 5 }); f.tick();
+ const shown = [f.ime.state().caret];
+ f.ime.key(IME.left); f.tick(); f.tick(); const first = f.sent.at(-1)!.id;
+ f.ime.key(IME.left);
+ f.answer(first, { preedit: "ha ha", caret: 4 }); f.tick(); f.tick();
+ shown.push(f.ime.state().caret);
+ const second = f.sent.at(-1)!.id;
+ f.answer(second, { preedit: "haha", caret: 2 }); f.tick(); shown.push(f.ime.state().caret);
+ f.ime.key(IME.left); f.tick(); f.tick();
+ f.answer(f.sent.at(-1)!.id, { preedit: "haha", caret: 1 }); f.tick(); shown.push(f.ime.state().caret);
+ f.ime.key(IME.left); f.tick(); f.tick();
+ f.answer(f.sent.at(-1)!.id, { preedit: "ha ha", caret: 0 }); f.tick(); shown.push(f.ime.state().caret);
+ f.answer(first, { preedit: "ha ha", caret: 4 }); f.answer(second, { preedit: "haha", caret: 2 }); f.tick();
+ shown.push(f.ime.state().caret);
+ expect(shown).toEqual([5, 5, 2, 1, 0, 0]);
+ expect(JSON.parse(f.sent.at(-1)!.payload)).toEqual([...Array.from("haha", c => c.charCodeAt(0)), ...Array(4).fill(IME.left)]);
+ expect(f.committed).toEqual([]);
+ });
+ test("disconnect retains the transcript and fences replies from its previous transport", () => {
+ const f = fixture();
+ f.ime.key(110); f.tick(); f.tick();
+ const old = f.sent[0].id;
+ f.connect(0); f.tick(); f.ime.key(105); f.tick();
+ expect(f.ime.state().connected).toBe(false);
+ f.connect(2); f.tick(); f.tick();
+ expect(JSON.parse(f.sent[1].payload)).toEqual([110, 105]);
+ f.answer(old, { commit: "wrong", preedit: "" }); f.tick();
+ expect(f.committed).toEqual([]);
+ f.answer(f.sent[1].id); f.tick();
+ expect(f.ime.select(1)).toBe(true);
+ f.tick(); f.tick();
+ f.answer(f.sent[2].id, { commit: "呢", preedit: "", candidates: [] }); f.tick();
+ expect(f.committed).toEqual(["呢"]);
+ expect(f.ime.composing()).toBe(false);
+ });
+ test("closing an editor rejects in-flight commits; transcript budget is bounded", () => {
+ const f = fixture(); f.ime.key(110); f.tick(); f.tick();
+ f.ime.reset(); f.answer(f.sent[0].id, { commit: "你", preedit: "" }); f.tick();
+ expect(f.committed).toEqual([]);
+ for (let i = 0; i < IME.keys; i++) expect(f.ime.key(97)).toBe(true);
+ expect(f.ime.key(97)).toBe(false);
+ f.ime.reset();
+ expect(f.ime.key(-1)).toBe(false);
+ expect(f.ime.key(0x1f600)).toBe(false);
+ expect(f.ime.key(97)).toBe(true);
+ });
+});
diff --git a/tests/ipodtouch4-profile.test.ts b/tests/ipodtouch4-profile.test.ts
index 7761a7443..c7d701a5b 100644
--- a/tests/ipodtouch4-profile.test.ts
+++ b/tests/ipodtouch4-profile.test.ts
@@ -42,7 +42,7 @@ describe("private iPod touch 4 profile", () => {
presentations: ["native"],
rasterDensity: IPODTOUCH4_RASTER_DENSITY,
},
- capabilities: ["input.touch", "text.glyphs.baked"],
+ capabilities: ["input.touch", "text.glyphs.baked", "io.offload"],
});
// Same legacy UIKit runtime, same op table, same guest protocol as the
// iPhone 4S — the ABI is the protocol revision, the target id the device.
diff --git a/tests/moto-g-play-profile.test.ts b/tests/moto-g-play-profile.test.ts
new file mode 100644
index 000000000..fe3292682
--- /dev/null
+++ b/tests/moto-g-play-profile.test.ts
@@ -0,0 +1,12 @@
+import { expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { resolveMotoGPlayBuildPlan } from "../tools/moto-g-play-profile.ts";
+import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts";
+test("Moto Clear resolves full-panel logical geometry and requires the implemented offload capability", () => {
+ const manifest = JSON.parse(readFileSync("apps/clear/pocket.android.json", "utf8"));
+ const plan = resolveMotoGPlayBuildPlan(manifest);
+ const inputs = extractHostBuildInputs(plan, { expectedTarget: "moto-g-play-dev" });
+ expect(inputs.viewport).toEqual({ logical: [360, 800], physical: [720, 1600], rasterDensity: 2, presentation: "native" });
+ manifest.app.viewport.fixed.logical = [320, 480];
+ expect(() => resolveMotoGPlayBuildPlan(manifest)).toThrow();
+});
diff --git a/tests/npm-package.test.ts b/tests/npm-package.test.ts
index 30a428926..be23b79c6 100644
--- a/tests/npm-package.test.ts
+++ b/tests/npm-package.test.ts
@@ -79,6 +79,8 @@ describe("published npm artifacts", () => {
"hosts/blackberry-classic-android",
"hosts/blackberry-classic-qnx",
"hosts/web",
+ "hosts/android",
+ "hosts/shared",
"docs/APPLE.md",
"docs/IPHONE2G.md",
"docs/IPHONE4S.md",
diff --git a/tests/offload-posix.test.ts b/tests/offload-posix.test.ts
new file mode 100644
index 000000000..c7653cc4a
--- /dev/null
+++ b/tests/offload-posix.test.ts
@@ -0,0 +1,64 @@
+import { afterAll, expect, test } from "bun:test";
+import { dlopen, ptr, FFIType } from "bun:ffi";
+import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { connect, type Socket } from "node:net";
+import { encodeOffloadRecord } from "../tools/offload-wire.ts";
+const directory = mkdtempSync(join(tmpdir(), "pocket-posix-"));
+const library = join(directory, process.platform === "darwin" ? "transport.dylib" : "transport.so");
+const compile = Bun.spawnSync(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-shared", "-fPIC", "-pthread",
+ "hosts/shared/offload_posix.c", "-o", library], { stderr: "pipe" });
+if (compile.exitCode) throw new Error(compile.stderr.toString());
+const native = dlopen(library, {
+ pocket_offload_start: { args: [FFIType.cstring, FFIType.u32], returns: FFIType.void },
+ pocket_offload_stop: { args: [], returns: FFIType.void },
+ pocket_offload_session: { args: [], returns: FFIType.u32 },
+ pocket_offload_submit: { args: [FFIType.ptr, FFIType.u64], returns: FFIType.i32 },
+ pocket_offload_take: { args: [FFIType.ptr], returns: FFIType.u64 },
+});
+const key = "a".repeat(64), keyPath = join(directory, "key");
+writeFileSync(keyPath, key, { mode: 0o600 });
+const cpath = Buffer.from(`${keyPath}\0`), buffer = Buffer.alloc(4096);
+const api = native.symbols;
+const port = 28471;
+afterAll(() => { api.pocket_offload_stop(); native.close(); rmSync(directory, { recursive: true, force: true }); });
+async function until(predicate: () => boolean) {
+ for (let i = 0; i < 200; i++) { if (predicate()) return; await Bun.sleep(5); }
+ throw new Error("Timed out waiting for native transport");
+}
+async function socket(): Promise {
+ return new Promise((resolve, reject) => {
+ const s = connect({ port, host: "127.0.0.1" }, () => resolve(s)); s.on("error", reject);
+ });
+}
+test("POSIX worker authenticates USB sessions, reassembles split UTF-8, bounds queues and rejects old generations", async () => {
+ api.pocket_offload_start(ptr(cpath), port); await Bun.sleep(30);
+ const wrong = await socket(); wrong.write("b".repeat(64));
+ await new Promise(resolve => wrong.on("close", () => resolve()));
+ expect(api.pocket_offload_session()).toBe(0);
+ const peer = await socket(); peer.write(key);
+ await until(() => api.pocket_offload_session() > 0);
+ const first = api.pocket_offload_session();
+ const frame = encodeOffloadRecord('{"id":1,"payload":"你好"}');
+ for (const byte of frame) { peer.write(Buffer.from([byte])); await Bun.sleep(1); }
+ let length = 0;
+ await until(() => (length = Number(api.pocket_offload_take(ptr(buffer)))) > 0);
+ expect(buffer.toString("utf8", 0, length)).toBe('{"id":1,"payload":"你好"}');
+ peer.write(frame);
+ await Bun.sleep(30); // Leave one delivery queued across the disconnect.
+ peer.destroy(); await until(() => api.pocket_offload_session() === 0);
+ const reply = Buffer.from("reply");
+ expect(api.pocket_offload_submit(ptr(reply), reply.length)).toBe(0);
+ const second = await socket(); second.write(key);
+ await until(() => api.pocket_offload_session() > first);
+ expect(Number(api.pocket_offload_take(ptr(buffer)))).toBe(0);
+ second.write(Buffer.from([0, 0, 32, 0]));
+ await until(() => api.pocket_offload_session() === 0);
+ second.destroy();
+ const full = await socket(); full.write(key);
+ await until(() => api.pocket_offload_session() > first);
+ full.write(Buffer.concat(Array.from({ length: 9 }, () => frame)));
+ await until(() => api.pocket_offload_session() === 0);
+ full.destroy();
+}, 10000);
diff --git a/tests/text.test.ts b/tests/text.test.ts
new file mode 100644
index 000000000..590dfa57a
--- /dev/null
+++ b/tests/text.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, test } from "bun:test";
+import { existsSync } from "node:fs";
+import { createTextResources } from "../framework/src/text.ts";
+import { createOffloadClient } from "../framework/src/offload.ts";
+import { createTextProvider } from "../tools/text-provider.ts";
+import type { OffloadRequest } from "../contracts/spec/offload.ts";
+const font = existsSync("/System/Library/Fonts/STHeiti Medium.ttc") ? "/System/Library/Fonts/STHeiti Medium.ttc" : "assets/fonts/Inter-Regular.ttf";
+
+function fixture(maxGlyphs = 96) {
+ const provider = createTextProvider(font), sent: OffloadRequest[] = [], replies: string[] = [], held: OffloadRequest[] = [];
+ let session = 1, allow = true, next = 1, frame = 0;
+ const uploaded: number[] = [], freed: number[] = [];
+ function answer(r: OffloadRequest) { replies.push(JSON.stringify({ id: r.id,
+ payload: r.method === "text.font" ? provider["text.font"]() : provider["text.glyph"](r.payload) })); }
+ const io = createOffloadClient({ session: () => session, take: () => replies.shift(), submit: raw => {
+ const request = JSON.parse(raw) as OffloadRequest; sent.push(request);
+ if (request.method === "text.font" || allow) answer(request); else held.push(request);
+ return true;
+ } });
+ const resources = createTextResources({ io, maxGlyphs, measure: s => s.length * 8,
+ upload() { uploaded.push(frame); return next++; }, free: h => freed.push(h) });
+ const layouts: ReturnType[] = [];
+ function label() { const label = resources.createLayout({ width: 300, size: 20, density: 2, bold: true, fontSlot: 11 }); layouts.push(label); return label; }
+ function step(n = 1) { for (let i = 0; i < n; i++) { frame++; io.step(); resources.step(); for (const l of layouts) l.snapshot(); } }
+ return { resources, io, label, sent, uploaded, freed, step, connect: (s: number) => { session = s; },
+ hold() { allow = false; }, resume() { allow = true; for (const request of held.splice(0)) answer(request); } };
+}
+describe("retained text resources", () => {
+ test("a clipped glyph dependency still updates the full measured width", () => {
+ const f = fixture(), visible = f.label();
+ const clipped = f.resources.createLayout({ width: 6, size: 20, density: 2, bold: true, fontSlot: 11 });
+ clipped.set("Aé"); const before = clipped.snapshot();
+ expect(before.width).toBe(28);
+ visible.set("é"); f.step(30);
+ const after = clipped.snapshot();
+ expect(after.width).toBe(8 + visible.snapshot().width);
+ expect(after.width).not.toBe(before.width);
+ expect(after.parts.map(p => p.text)).toEqual(["A"]);
+ clipped.dispose(); f.resources.dispose();
+ });
+ test("loading another label's glyphs does not invalidate a resident layout", () => {
+ const f = fixture(), row = f.label(), candidates = f.label();
+ row.set("Tap to Edit 你好|"); f.step(30);
+ const stable = row.snapshot();
+ candidates.set("们中文候选");
+ for (let frame = 0; frame < 40; frame++) {
+ f.step(); expect(row.snapshot()).toBe(stable);
+ }
+ expect(candidates.snapshot().pending).toBe(false);
+ f.resources.dispose();
+ });
+ test("deleting and reordering resident Han text preserves pixels without I/O, including offline", () => {
+ const f = fixture(), label = f.label(); label.set("Tap to Edit 你好|"); f.step(30);
+ const ready = label.snapshot(); expect(ready.pending).toBe(false);
+ const glyph = ready.parts.find(p => p.text === "你")!;
+ expect(glyph.kind).toBe("glyph");
+ const before = f.sent.length;
+ label.set("Tap to Edit 你|");
+ expect(label.snapshot().pending).toBe(false);
+ expect(label.snapshot().parts.find(p => p.text === "你")).toEqual(glyph);
+ f.connect(0); f.step(); label.set("好你|");
+ expect(label.snapshot().pending).toBe(false);
+ f.step(5); expect(f.sent.length).toBe(before);
+ expect(new Set(f.uploaded).size).toBe(f.uploaded.length);
+ f.resources.dispose();
+ });
+ test("a missing glyph does not hide resident Latin or Han; labels share coverage", () => {
+ const f = fixture(), a = f.label(), b = f.label();
+ a.set("你好"); b.set("你好"); f.step(30);
+ expect(f.sent.filter(r => r.method === "text.glyph")).toHaveLength(2);
+ f.hold(); a.set("A你们B"); f.step(5);
+ const parts = a.snapshot().parts;
+ expect(parts.filter(p => p.kind === "local").map(p => p.text)).toEqual(["A", "B"]);
+ expect(parts.find(p => p.text === "你" && p.kind === "glyph" && p.glyph)).toBeDefined();
+ expect(parts.find(p => p.text === "们" && p.kind === "glyph" && !p.glyph)).toBeDefined();
+ f.resume(); f.step(20); expect(a.snapshot().pending).toBe(false);
+ f.resources.dispose();
+ });
+ test("font handshake on reconnect preserves immutable resident coverage and a bounded cache", () => {
+ const f = fixture(2), a = f.label(), b = f.label(); a.set("你"); b.set("你"); f.step(20);
+ const initial = f.sent.filter(r => r.method === "text.glyph").length;
+ a.dispose(); f.connect(0); f.step(); f.connect(2); f.step(20);
+ expect(b.snapshot().pending).toBe(false);
+ expect(f.sent.filter(r => r.method === "text.glyph")).toHaveLength(initial);
+ b.set("你好"); f.step(20); b.set("你们"); f.step(20);
+ expect(b.snapshot().pending).toBe(false);
+ expect(f.resources.stats().entries).toBeLessThanOrEqual(2);
+ expect(f.freed.length).toBeGreaterThan(0);
+ f.resources.dispose();
+ });
+});
diff --git a/tools/android.ts b/tools/android.ts
new file mode 100644
index 000000000..8b6c12a3c
--- /dev/null
+++ b/tools/android.ts
@@ -0,0 +1,742 @@
+import { createHash } from "node:crypto";
+import {
+ cpSync,
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ renameSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { homedir } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ BLACKBERRY_ANDROID_DEV_TARGET_ID,
+ resolveBlackBerryClassicBuildPlan,
+} from "./blackberry-classic-profile.ts";
+import {
+ buildGuestBundle,
+ ensureQuickJsCheckout,
+ type GuestBundle,
+ type GuestBundleRequest,
+ mustRunCommand,
+ packageIdentity,
+ type PackageIdentity,
+ printCheck,
+ quickJsCheckout,
+ quickJsCheckoutStatus,
+ readGuestBundle,
+ renderTemplate,
+ runCommand,
+ sha256File,
+ xmlEscape,
+} from "./native-host-build.ts";
+
+import { MOTO_G_PLAY_TARGET, resolveMotoGPlayBuildPlan } from "./moto-g-play-profile.ts";
+const modern = Bun.argv.includes("--profile=moto-g-play");
+const profile = modern ? "moto-g-play" : "blackberry-android";
+const LABEL = `PocketJS Android (${profile})`;
+const target = modern ? MOTO_G_PLAY_TARGET : BLACKBERRY_ANDROID_DEV_TARGET_ID;
+const repository = fileURLToPath(new URL("..", import.meta.url));
+const command = Bun.argv.slice(2).find(a => !a.startsWith("--")) ?? "doctor";
+const toolchain = JSON.parse(
+ readFileSync(
+ join(repository, `tools/cli/${profile}-toolchain.json`),
+ "utf8",
+ ),
+) as {
+ readonly toolchainVersion: string;
+ readonly cachePath: string;
+ readonly javaImage: string;
+ readonly quickjs: {
+ readonly version: string;
+ readonly repository: string;
+ readonly revision: string;
+ };
+ readonly rust: {
+ readonly toolchain: string;
+ readonly target: string;
+ };
+ readonly android: {
+ readonly apiLevel: number;
+ readonly platformVersion: string;
+ readonly buildToolsVersion: string;
+ readonly ndkVersion: string;
+ readonly abi: string;
+ readonly clangTarget: string;
+ readonly repository: string;
+ readonly packages: readonly SdkPackage[];
+ };
+ readonly app: {
+ readonly manifest: string;
+ readonly output: string;
+ };
+};
+
+interface SdkArchive {
+ readonly asset: string;
+ /** The checksum Google publishes in repository2-3.xml (what sdkmanager checks). */
+ readonly sha1: string;
+}
+
+/** One SDK component: the archive per host OS and where it unpacks. */
+interface SdkPackage {
+ readonly id: string;
+ readonly path: string;
+ readonly archives: Readonly>>;
+}
+
+/**
+ * The NDK ships one LLVM prebuilt per host operating system. Linux x86-64 is
+ * the verified host; the macOS prebuilt is x86-64 as well and runs under
+ * Rosetta on Apple silicon.
+ */
+function ndkHostTag(): string {
+ switch (process.platform) {
+ case "linux":
+ return "linux-x86_64";
+ case "darwin":
+ return "darwin-x86_64";
+ default:
+ throw new Error(`${LABEL}: no NDK r23c prebuilt for host ${process.platform}`);
+ }
+}
+
+const cache = join(homedir(), ".cache/pocket-stack", toolchain.cachePath);
+const sdk = process.env.POCKETJS_ANDROID_SDK_ROOT ?? (modern && process.platform === "darwin" ? "/opt/homebrew/share/android-commandlinetools" : join(cache, "sdk"));
+const buildTools = join(sdk, "build-tools", toolchain.android.buildToolsVersion);
+const ndk = join(sdk, "ndk", toolchain.android.ndkVersion);
+const llvm = join(ndk, "toolchains/llvm/prebuilt", ndkHostTag(), "bin");
+const clang = join(llvm, `${toolchain.android.clangTarget}-clang`);
+const readelf = join(llvm, "llvm-readelf");
+const androidJar = join(
+ sdk,
+ "platforms",
+ `android-${toolchain.android.apiLevel}`,
+ "android.jar",
+);
+const appHost = join(repository, modern ? "hosts/android/app" : "hosts/blackberry-classic-android/app");
+const sharedHost = join(repository, "hosts/android/app");
+const build = join(repository, `.pocket-build/${profile}`);
+const staging = join(build, "staging");
+const appOutput = join(repository, toolchain.app.output);
+const signing = join(cache, "signing");
+/* One local key signs every APK; Android upgrades an installed package only
+ * when the new APK carries the same signing identity, so a key generated by
+ * an earlier version of this tool keeps being used under its old name. */
+const keystoreName = existsSync(join(signing, "blackberry-android-probe.jks"))
+ ? "blackberry-android-probe.jks"
+ : "blackberry-classic.jks";
+const keystore = join(signing, keystoreName);
+const quickJs = quickJsCheckout(join(cache, "sources/quickjs-rs"));
+const guest: GuestBundleRequest = {
+ label: LABEL,
+ repository,
+ target,
+ resolvePlan: modern ? resolveMotoGPlayBuildPlan : (manifest) =>
+ resolveBlackBerryClassicBuildPlan(manifest, BLACKBERRY_ANDROID_DEV_TARGET_ID),
+ manifestPath: join(repository, toolchain.app.manifest),
+ planPath: join(repository, `.pocket/${profile}/app.plan.json`),
+ outputDirectory: join(repository, `dist/${profile}/guest`),
+};
+
+function run(program: string, args: readonly string[]) {
+ return runCommand(program, args, repository);
+}
+
+function mustRun(
+ program: string,
+ args: readonly string[],
+ cwd = repository,
+ env: NodeJS.ProcessEnv = process.env,
+): string {
+ return mustRunCommand(LABEL, program, args, cwd, env);
+}
+
+function dockerJava(args: readonly string[]): string {
+ if (modern) {
+ const javaHome = process.env.JAVA_HOME ?? "/opt/homebrew/opt/openjdk@17";
+ const mapped = args.map(a => a.replace(/^\/repo(?=\/)/, repository).replace(/^\/android-sdk(?=\/)/, sdk)
+ .replace(/^\/build(?=\/)/, build).replace(/^\/signing(?=\/)/, signing));
+ const program = mapped[0].includes("/") ? mapped[0] : join(javaHome, "bin", mapped[0]);
+ return mustRun(program, mapped.slice(1), repository, { ...process.env, JAVA_HOME: javaHome });
+ }
+ const uid = process.getuid?.() ?? 1000;
+ const gid = process.getgid?.() ?? 1000;
+ return mustRun("docker", [
+ "run",
+ "--rm",
+ "--user",
+ `${uid}:${gid}`,
+ "-e",
+ "HOME=/tmp",
+ "-v",
+ `${repository}:/repo:ro`,
+ "-v",
+ `${sdk}:/android-sdk:ro`,
+ "-v",
+ `${build}:/build`,
+ "-v",
+ `${signing}:/signing`,
+ toolchain.javaImage,
+ ...args,
+ ]);
+}
+
+function javaImagePresent(): boolean {
+ return modern ? existsSync(join(process.env.JAVA_HOME ?? "/opt/homebrew/opt/openjdk@17", "bin/javac")) : run("docker", ["image", "inspect", toolchain.javaImage]).exitCode === 0;
+}
+
+/**
+ * Checks the target's std directory in the pinned toolchain's sysroot. `rustup
+ * target list --toolchain X` would install a missing X on the spot, which a
+ * doctor must not do.
+ */
+function rustTargetInstalled(): boolean {
+ const sysroot = run("rustup", ["run", toolchain.rust.toolchain, "rustc", "--print", "sysroot"]);
+ if (sysroot.exitCode !== 0) return false;
+ return existsSync(
+ join(sysroot.stdout.trim(), "lib/rustlib", toolchain.rust.target, "lib"),
+ );
+}
+
+function checkPath(label: string, path: string): boolean {
+ return printCheck(label, existsSync(path), path);
+}
+
+function doctor(): void {
+ const rust = run("rustup", ["run", toolchain.rust.toolchain, "rustc", "--version"]);
+ const quickjs = quickJsCheckoutStatus(quickJs.root, toolchain.quickjs);
+ const sdkChecks = [
+ checkPath(`Android SDK Platform ${toolchain.android.apiLevel}`, androidJar),
+ checkPath(`NDK ${toolchain.android.ndkVersion} clang`, clang),
+ checkPath("NDK llvm-readelf", readelf),
+ checkPath("aapt2", join(buildTools, "aapt2")),
+ checkPath("aapt", join(buildTools, "aapt")),
+ checkPath("d8", join(buildTools, "d8")),
+ checkPath("zipalign", join(buildTools, "zipalign")),
+ checkPath("apksigner", join(buildTools, "apksigner")),
+ ];
+ const checks = [
+ ...sdkChecks,
+ printCheck(modern ? "Java 17" : "Java image", javaImagePresent(), modern ? (process.env.JAVA_HOME ?? "/opt/homebrew/opt/openjdk@17") : toolchain.javaImage),
+ printCheck(
+ "Rust nightly",
+ rust.exitCode === 0,
+ rust.stdout.trim() || toolchain.rust.toolchain,
+ ),
+ printCheck(
+ "Rust Android target",
+ rustTargetInstalled(),
+ `${toolchain.rust.target} on ${toolchain.rust.toolchain}`,
+ ),
+ printCheck("pinned QuickJS", quickjs.ok, quickjs.detail),
+ ];
+ if (sdkChecks.some((ok) => !ok)) {
+ console.log(
+ `Run \`bun blackberry-android setup\` to unpack the pinned SDK archives into ${sdk}, ` +
+ `or point POCKETJS_ANDROID_SDK_ROOT at an SDK that already holds ` +
+ `${toolchain.android.packages.map((pkg) => pkg.id).join(", ")}.`,
+ );
+ }
+ if (checks.some((ok) => !ok)) process.exitCode = 1;
+ else console.log(`[ok] toolchain: ${toolchain.toolchainVersion}`);
+}
+
+function requireToolchain(): void {
+ doctor();
+ if (process.exitCode) {
+ throw new Error(`${LABEL}: toolchain is incomplete; see the doctor report above`);
+ }
+}
+
+async function sha1File(path: string): Promise {
+ const hash = createHash("sha1");
+ for await (const chunk of Bun.file(path).stream()) hash.update(chunk);
+ return hash.digest("hex");
+}
+
+/**
+ * Unpacks the pinned SDK archives (the same files `sdkmanager` installs) into
+ * the SDK root, one host-OS archive per component; nothing is downloaded for
+ * a component whose directory already exists.
+ */
+async function installSdkPackages(): Promise {
+ const downloads = join(cache, "downloads");
+ for (const pkg of toolchain.android.packages) {
+ const target = join(sdk, pkg.path);
+ if (existsSync(target)) continue;
+ const archive =
+ pkg.archives[process.platform as "linux" | "darwin"] ?? pkg.archives.any;
+ if (!archive) {
+ throw new Error(`${LABEL}: ${pkg.id} has no archive for host ${process.platform}`);
+ }
+ mkdirSync(downloads, { recursive: true });
+ const download = join(downloads, archive.asset);
+ if (!existsSync(download) || (await sha1File(download)) !== archive.sha1) {
+ const url = `${toolchain.android.repository}${archive.asset}`;
+ console.log(`${LABEL}: downloading ${url}`);
+ const response = await fetch(url);
+ if (!response.ok || response.body === null) {
+ throw new Error(`${LABEL}: ${url} failed (${response.status})`);
+ }
+ const partial = `${download}.part`;
+ const sink = Bun.file(partial).writer();
+ let received = 0;
+ for await (const chunk of response.body) {
+ sink.write(chunk);
+ received += chunk.byteLength;
+ }
+ await sink.end();
+ renameSync(partial, download);
+ console.log(`${LABEL}: ${archive.asset} ${received} bytes`);
+ const digest = await sha1File(download);
+ if (digest !== archive.sha1) {
+ rmSync(download, { force: true });
+ throw new Error(
+ `${LABEL}: ${archive.asset} sha1 ${digest} does not match the pinned ${archive.sha1}`,
+ );
+ }
+ }
+ const scratch = mkdtempSync(join(downloads, "unpack-"));
+ try {
+ mustRun("unzip", ["-q", download, "-d", scratch]);
+ const entries = readdirSync(scratch).filter((name) => !name.startsWith("."));
+ if (entries.length !== 1) {
+ throw new Error(
+ `${LABEL}: ${archive.asset} unpacked to ${entries.length} entries, expected one directory`,
+ );
+ }
+ mkdirSync(dirname(target), { recursive: true });
+ renameSync(join(scratch, entries[0]), target);
+ console.log(`${LABEL}: ${pkg.id} -> ${target}`);
+ } finally {
+ rmSync(scratch, { recursive: true, force: true });
+ }
+ }
+}
+
+async function setup(): Promise {
+ if (modern) {
+ const manager = join(sdk, "cmdline-tools/latest/bin/sdkmanager");
+ mustRun(manager, [`--sdk_root=${sdk}`, "platforms;android-34", "build-tools;35.0.0", "ndk;27.1.12297006"], repository,
+ { ...process.env, JAVA_HOME: process.env.JAVA_HOME ?? "/opt/homebrew/opt/openjdk@17" });
+ } else await installSdkPackages();
+ if (!javaImagePresent()) {
+ if (modern) throw new Error("Install Java 17 and set JAVA_HOME");
+ mustRun("docker", ["pull", toolchain.javaImage]);
+ }
+ ensureQuickJsCheckout(LABEL, quickJs.root, toolchain.quickjs);
+ if (!rustTargetInstalled()) {
+ mustRun("rustup", [
+ "target",
+ "add",
+ toolchain.rust.target,
+ "--toolchain",
+ toolchain.rust.toolchain,
+ ]);
+ }
+ doctor();
+}
+
+function ensureKeystore(): void {
+ mkdirSync(signing, { recursive: true });
+ if (existsSync(keystore)) return;
+ dockerJava([
+ "keytool",
+ "-genkeypair",
+ "-noprompt",
+ "-keystore",
+ `/signing/${keystoreName}`,
+ "-storepass",
+ "android",
+ "-alias",
+ "androiddebugkey",
+ "-keypass",
+ "android",
+ "-dname",
+ "CN=PocketJS BlackBerry Classic,O=PocketJS,C=HK",
+ "-keyalg",
+ "RSA",
+ "-keysize",
+ "2048",
+ "-validity",
+ "10000",
+ ]);
+}
+
+/** javac → jar → d8 for PocketActivity, into staging/classes.dex. */
+function compileActivity(): void {
+ mkdirSync(join(build, "classes"), { recursive: true });
+ mkdirSync(join(build, "dex"), { recursive: true });
+ dockerJava([
+ "javac",
+ "-encoding",
+ "UTF-8",
+ "-source",
+ "8",
+ "-target",
+ "8",
+ "-bootclasspath",
+ `/android-sdk/platforms/android-${toolchain.android.apiLevel}/android.jar`,
+ "-d",
+ "/build/classes",
+ "/repo/hosts/android/app/src/dev/pocketstack/android/PocketActivity.java",
+ ...(!modern ? ["/repo/hosts/blackberry-classic-android/app/src/dev/pocketstack/blackberry/PocketActivity.java"] : []),
+ ]);
+ dockerJava(["jar", "cf", "/build/classes.jar", "-C", "/build/classes", "."]);
+ dockerJava([
+ `/android-sdk/build-tools/${toolchain.android.buildToolsVersion}/d8`,
+ "--min-api",
+ String(modern ? 23 : toolchain.android.apiLevel),
+ "--output",
+ "/build/dex",
+ "/build/classes.jar",
+ ]);
+ copyFileSync(join(build, "dex/classes.dex"), join(staging, "classes.dex"));
+}
+
+/**
+ * aapt2 → zipalign → apksigner over staging/ (classes.dex + lib/) plus the
+ * guest assets. Android 4.3 verifies only the JAR (v1) signature scheme, so
+ * every newer scheme is disabled explicitly.
+ */
+function packageApk(identity: PackageIdentity, resources: string, assets: string): {
+ readonly signature: string;
+ readonly badging: string;
+} {
+ const compiled = join(build, "app-res.zip");
+ mustRun(join(buildTools, "aapt2"), ["compile", "--dir", resources, "-o", compiled]);
+ const manifest = join(build, "AndroidManifest.xml");
+ writeFileSync(
+ manifest,
+ renderTemplate(readFileSync(join(appHost, "AndroidManifest.xml"), "utf8"), {
+ PACKAGE: identity.packageId,
+ VERSION_CODE: identity.versionCode,
+ VERSION_NAME: identity.version,
+ }),
+ );
+ const unsigned = join(build, "app-unsigned.apk");
+ mustRun(join(buildTools, "aapt2"), [
+ "link",
+ "-o",
+ unsigned,
+ "--manifest",
+ manifest,
+ "-I",
+ androidJar,
+ "-A",
+ assets,
+ "--min-sdk-version",
+ String(modern ? 23 : toolchain.android.apiLevel),
+ "--target-sdk-version",
+ String(toolchain.android.apiLevel),
+ compiled,
+ ]);
+ mustRun("zip", ["-q", "-r", unsigned, "classes.dex", "lib"], staging);
+ const aligned = join(build, "app-aligned.apk");
+ mustRun(join(buildTools, "zipalign"), ["-f", "-p", "4", unsigned, aligned]);
+ ensureKeystore();
+ dockerJava([
+ `/android-sdk/build-tools/${toolchain.android.buildToolsVersion}/apksigner`,
+ "sign",
+ "--ks",
+ `/signing/${keystoreName}`,
+ "--ks-key-alias",
+ "androiddebugkey",
+ "--ks-pass",
+ "pass:android",
+ "--key-pass",
+ "pass:android",
+ "--min-sdk-version",
+ String(modern ? 23 : toolchain.android.apiLevel),
+ "--v1-signing-enabled",
+ "true",
+ "--v2-signing-enabled",
+ modern ? "true" : "false",
+ "--v3-signing-enabled",
+ "false",
+ "--v4-signing-enabled",
+ "false",
+ "--out",
+ "/build/app-signed.apk",
+ "/build/app-aligned.apk",
+ ]);
+ mkdirSync(dirname(appOutput), { recursive: true });
+ copyFileSync(join(build, "app-signed.apk"), appOutput);
+ const signature = dockerJava([
+ `/android-sdk/build-tools/${toolchain.android.buildToolsVersion}/apksigner`,
+ "verify",
+ "--verbose",
+ "--print-certs",
+ "--min-sdk-version",
+ String(modern ? 23 : toolchain.android.apiLevel),
+ "/build/app-signed.apk",
+ ]);
+ const badging = mustRun(join(buildTools, "aapt"), ["dump", "badging", appOutput]);
+ return { signature, badging };
+}
+
+function resetBuild(): void {
+ rmSync(build, { recursive: true, force: true });
+ mkdirSync(join(staging, "lib", toolchain.android.abi), { recursive: true });
+}
+
+function buildRustCore(): string {
+ const rustTarget = join(build, "rust");
+ mustRun(
+ "rustup",
+ [
+ "run",
+ toolchain.rust.toolchain,
+ "cargo",
+ "build",
+ "--release",
+ "--locked",
+ "--target",
+ toolchain.rust.target,
+ "--features",
+ "bare-platform",
+ "--target-dir",
+ rustTarget,
+ ],
+ join(repository, "engine/ui-cabi"),
+ {
+ ...process.env,
+ CARGO_PROFILE_RELEASE_LTO: "false",
+ CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER: clang,
+ CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER: clang,
+ },
+ );
+ const library = join(rustTarget, toolchain.rust.target, "release/libpocketjs_symbian_core.a");
+ if (!existsSync(library)) {
+ throw new Error(`${LABEL}: Rust core archive is absent: ${library}`);
+ }
+ return library;
+}
+
+function buildQuickJs(): string {
+ const objects = join(build, "objects/quickjs");
+ mkdirSync(objects, { recursive: true });
+ const flags = [
+ "-std=gnu11",
+ "-O2",
+ "-fPIC",
+ "-funsigned-char",
+ "-fno-strict-aliasing",
+ "-ffunction-sections",
+ "-fdata-sections",
+ "-D_GNU_SOURCE",
+ `-DCONFIG_VERSION="${toolchain.quickjs.version}"`,
+ `-I${quickJs.source}`,
+ ];
+ const objectPaths: string[] = [];
+ for (const source of ["cutils.c", "dtoa.c", "libregexp.c", "libunicode.c", "quickjs.c"]) {
+ const object = join(objects, source.replace(/\.c$/, ".o"));
+ mustRun(clang, [...flags, "-c", join(quickJs.source, source), "-o", object]);
+ objectPaths.push(object);
+ }
+ const staticFunctions = join(objects, "static-functions.o");
+ mustRun(clang, [...flags, "-c", quickJs.staticFunctions, "-o", staticFunctions]);
+ objectPaths.push(staticFunctions);
+ const library = join(build, "libquickjs.a");
+ mustRun(join(llvm, "llvm-ar"), ["rcs", library, ...objectPaths]);
+ return library;
+}
+
+function buildNativeLibrary(bundle: GuestBundle, quickJsLibrary: string, coreLibrary: string): string {
+ const objects = join(build, "objects");
+ const cFlags = [
+ "-std=gnu11",
+ "-Os",
+ "-fPIC",
+ "-fno-strict-aliasing",
+ "-ffunction-sections",
+ "-fdata-sections",
+ "-fvisibility=hidden",
+ "-Wall",
+ "-Wextra",
+ "-Werror",
+ "-Wno-unused-parameter",
+ ...(modern ? ["-DPOCKET_OFFLOAD_POSIX", `-I${join(repository, "hosts/shared")}`] : []),
+ ];
+ const portableRuntime = join(objects, "pocket_runtime.o");
+ mustRun(clang, [
+ ...cFlags,
+ `-DPOCKETJS_TARGET_ID="${bundle.inputs.target}"`,
+ `-DPOCKETJS_HOST_ABI=${bundle.inputs.hostAbi}`,
+ `-DPOCKET_RASTER_DENSITY=${bundle.inputs.viewport.rasterDensity}`,
+ `-I${join(repository, "engine/quickjs-c")}`,
+ `-I${join(repository, "engine/ui-cabi/include")}`,
+ `-I${join(repository, "contracts/generated")}`,
+ `-I${quickJs.source}`,
+ "-c",
+ join(repository, "engine/quickjs-c/pocket_runtime.c"),
+ "-o",
+ portableRuntime,
+ ]);
+ const androidRuntime = join(objects, "android_runtime.o");
+ mustRun(clang, [
+ ...cFlags,
+ `-DPOCKET_LOGICAL_WIDTH=${bundle.inputs.viewport.logical[0]}`,
+ `-DPOCKET_LOGICAL_HEIGHT=${bundle.inputs.viewport.logical[1]}`,
+ `-I${join(repository, "engine/quickjs-c")}`,
+ `-I${join(repository, "hosts/blackberry-classic")}`,
+ `-I${join(repository, "contracts/generated")}`,
+ "-c",
+ join(sharedHost, "jni/runtime.c"),
+ "-o",
+ androidRuntime,
+ ]);
+ const sharedSources = [
+ {
+ name: "pocket_input",
+ source: join(repository, "hosts/blackberry-classic/pocket_input.c"),
+ includes: [
+ `-I${join(repository, "hosts/blackberry-classic")}`,
+ `-I${join(repository, "contracts/generated")}`,
+ ],
+ },
+ {
+ name: "rust_eh_personality",
+ source: join(repository, "engine/quickjs-c/rust_eh_personality.c"),
+ includes: [],
+ },
+ ] satisfies Array<{ name: string; source: string; includes: string[] }>;
+ if (modern) sharedSources.push({ name: "offload_posix", source: join(repository, "hosts/shared/offload_posix.c"), includes: [] });
+ const sharedObjects = sharedSources.map(({ name, source, includes }) => {
+ const object = join(objects, `${name}.o`);
+ mustRun(clang, [
+ ...cFlags,
+ ...includes,
+ "-c",
+ source,
+ "-o",
+ object,
+ ]);
+ return object;
+ });
+ const nativeLibrary = join(staging, "lib", toolchain.android.abi, "libpocketjs.so");
+ /* No -landroid: the library needs nothing beyond GLESv2/log/dl/m/c, and
+ * --no-undefined turns any missing native symbol into a link failure. */
+ mustRun(clang, [
+ "-shared",
+ "-Wl,--build-id=none",
+ "-Wl,--gc-sections",
+ "-Wl,--exclude-libs,ALL",
+ "-Wl,--no-undefined",
+ androidRuntime,
+ portableRuntime,
+ ...sharedObjects,
+ quickJsLibrary,
+ coreLibrary,
+ "-o",
+ nativeLibrary,
+ "-lGLESv2",
+ "-llog",
+ "-ldl",
+ "-lm",
+ ]);
+ return nativeLibrary;
+}
+
+function buildApp(): void {
+ requireToolchain();
+ const bundle = readGuestBundle(guest);
+ resetBuild();
+ const coreLibrary = buildRustCore();
+ const quickJsLibrary = buildQuickJs();
+ const nativeLibrary = buildNativeLibrary(bundle, quickJsLibrary, coreLibrary);
+ compileActivity();
+
+ const assets = join(build, "assets");
+ mkdirSync(assets, { recursive: true });
+ copyFileSync(bundle.javaScript, join(assets, "app.js"));
+ copyFileSync(bundle.pack, join(assets, "app.pak"));
+ const identity = packageIdentity(bundle.inputs.app);
+ const resources = join(build, "resources");
+ cpSync(join(appHost, "res"), resources, { recursive: true });
+ writeFileSync(
+ join(resources, "values/strings.xml"),
+ renderTemplate(readFileSync(join(appHost, "res/values/strings.xml"), "utf8"), {
+ /* Android string resources also need apostrophes escaped. */
+ TITLE: xmlEscape(identity.title).replace(/'/g, "\\'"),
+ }),
+ );
+ mkdirSync(join(resources, "drawable"), { recursive: true });
+ copyFileSync(
+ join(repository, "assets/images/logo.png"),
+ join(resources, "drawable/icon.png"),
+ );
+ const { signature, badging } = packageApk(identity, resources, assets);
+ for (const marker of [
+ `package: name='${identity.packageId}' versionCode='${identity.versionCode}' versionName='${identity.version}'`,
+ `sdkVersion:'${modern ? 23 : toolchain.android.apiLevel}'`,
+ ]) {
+ if (!badging.includes(marker)) {
+ throw new Error(`${LABEL}: APK badging is missing ${marker}`);
+ }
+ }
+ const receipt = {
+ schema: 1,
+ toolchain: toolchain.toolchainVersion,
+ planHash: bundle.plan.planHash,
+ package: identity,
+ target: bundle.inputs.target,
+ hostAbi: bundle.inputs.hostAbi,
+ viewport: bundle.inputs.viewport,
+ apk: {
+ path: toolchain.app.output,
+ bytes: readFileSync(appOutput).byteLength,
+ sha256: sha256File(appOutput),
+ },
+ guest: {
+ javaScript: sha256File(bundle.javaScript),
+ pack: sha256File(bundle.pack),
+ },
+ nativeLibrary: {
+ bytes: readFileSync(nativeLibrary).byteLength,
+ sha256: sha256File(nativeLibrary),
+ elf: mustRun(readelf, ["-h", "-A", "-d", nativeLibrary]),
+ },
+ quickjs: {
+ version: toolchain.quickjs.version,
+ revision: toolchain.quickjs.revision,
+ },
+ rust: toolchain.rust,
+ signature,
+ badging,
+ };
+ const receiptPath = join(dirname(appOutput), `${profile}.receipt.json`);
+ writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
+ console.log(`${LABEL}: APK -> ${appOutput}`);
+ console.log(`SHA-256: ${receipt.apk.sha256}`);
+ console.log(`Receipt: ${receiptPath}`);
+}
+
+switch (command) {
+ case "doctor":
+ doctor();
+ break;
+ case "setup":
+ await setup();
+ break;
+ case "build-demo":
+ buildGuestBundle(guest);
+ break;
+ case "build-app":
+ buildApp();
+ break;
+ case "build":
+ buildGuestBundle(guest);
+ buildApp();
+ break;
+ default:
+ throw new Error(
+ `usage: bun tools/android.ts ${modern ? "--profile=moto-g-play " : ""}`,
+ );
+}
diff --git a/tools/blackberry-android.ts b/tools/blackberry-android.ts
index 9c535ebf5..2604896bd 100644
--- a/tools/blackberry-android.ts
+++ b/tools/blackberry-android.ts
@@ -1,719 +1,2 @@
-import { createHash } from "node:crypto";
-import {
- cpSync,
- copyFileSync,
- existsSync,
- mkdirSync,
- mkdtempSync,
- readFileSync,
- readdirSync,
- renameSync,
- rmSync,
- writeFileSync,
-} from "node:fs";
-import { homedir } from "node:os";
-import { dirname, join } from "node:path";
-import { fileURLToPath } from "node:url";
-import {
- BLACKBERRY_ANDROID_DEV_TARGET_ID,
- resolveBlackBerryClassicBuildPlan,
-} from "./blackberry-classic-profile.ts";
-import {
- buildGuestBundle,
- ensureQuickJsCheckout,
- type GuestBundle,
- type GuestBundleRequest,
- mustRunCommand,
- packageIdentity,
- type PackageIdentity,
- printCheck,
- quickJsCheckout,
- quickJsCheckoutStatus,
- readGuestBundle,
- renderTemplate,
- runCommand,
- sha256File,
- xmlEscape,
-} from "./native-host-build.ts";
-
-const LABEL = "PocketJS BlackBerry Android";
-const repository = fileURLToPath(new URL("..", import.meta.url));
-const command = Bun.argv[2] ?? "doctor";
-const toolchain = JSON.parse(
- readFileSync(
- join(repository, "tools/cli/blackberry-android-toolchain.json"),
- "utf8",
- ),
-) as {
- readonly toolchainVersion: string;
- readonly cachePath: string;
- readonly javaImage: string;
- readonly quickjs: {
- readonly version: string;
- readonly repository: string;
- readonly revision: string;
- };
- readonly rust: {
- readonly toolchain: string;
- readonly target: string;
- };
- readonly android: {
- readonly apiLevel: number;
- readonly platformVersion: string;
- readonly buildToolsVersion: string;
- readonly ndkVersion: string;
- readonly abi: string;
- readonly clangTarget: string;
- readonly repository: string;
- readonly packages: readonly SdkPackage[];
- };
- readonly app: {
- readonly manifest: string;
- readonly output: string;
- };
-};
-
-interface SdkArchive {
- readonly asset: string;
- /** The checksum Google publishes in repository2-3.xml (what sdkmanager checks). */
- readonly sha1: string;
-}
-
-/** One SDK component: the archive per host OS and where it unpacks. */
-interface SdkPackage {
- readonly id: string;
- readonly path: string;
- readonly archives: Readonly>>;
-}
-
-/**
- * The NDK ships one LLVM prebuilt per host operating system. Linux x86-64 is
- * the verified host; the macOS prebuilt is x86-64 as well and runs under
- * Rosetta on Apple silicon.
- */
-function ndkHostTag(): string {
- switch (process.platform) {
- case "linux":
- return "linux-x86_64";
- case "darwin":
- return "darwin-x86_64";
- default:
- throw new Error(`${LABEL}: no NDK r23c prebuilt for host ${process.platform}`);
- }
-}
-
-const cache = join(homedir(), ".cache/pocket-stack", toolchain.cachePath);
-const sdk = process.env.POCKETJS_ANDROID_SDK_ROOT ?? join(cache, "sdk");
-const buildTools = join(sdk, "build-tools", toolchain.android.buildToolsVersion);
-const ndk = join(sdk, "ndk", toolchain.android.ndkVersion);
-const llvm = join(ndk, "toolchains/llvm/prebuilt", ndkHostTag(), "bin");
-const clang = join(llvm, `${toolchain.android.clangTarget}-clang`);
-const readelf = join(llvm, "llvm-readelf");
-const androidJar = join(
- sdk,
- "platforms",
- `android-${toolchain.android.apiLevel}`,
- "android.jar",
-);
-const appHost = join(repository, "hosts/blackberry-classic-android/app");
-const build = join(repository, ".pocket-build/blackberry-android");
-const staging = join(build, "staging");
-const appOutput = join(repository, toolchain.app.output);
-const signing = join(cache, "signing");
-/* One local key signs every APK; Android upgrades an installed package only
- * when the new APK carries the same signing identity, so a key generated by
- * an earlier version of this tool keeps being used under its old name. */
-const keystoreName = existsSync(join(signing, "blackberry-android-probe.jks"))
- ? "blackberry-android-probe.jks"
- : "blackberry-classic.jks";
-const keystore = join(signing, keystoreName);
-const quickJs = quickJsCheckout(join(cache, "sources/quickjs-rs"));
-const guest: GuestBundleRequest = {
- label: LABEL,
- repository,
- target: BLACKBERRY_ANDROID_DEV_TARGET_ID,
- resolvePlan: (manifest) =>
- resolveBlackBerryClassicBuildPlan(manifest, BLACKBERRY_ANDROID_DEV_TARGET_ID),
- manifestPath: join(repository, toolchain.app.manifest),
- planPath: join(repository, ".pocket/blackberry-android/app.plan.json"),
- outputDirectory: join(repository, "dist/blackberry-android/guest"),
-};
-
-function run(program: string, args: readonly string[]) {
- return runCommand(program, args, repository);
-}
-
-function mustRun(
- program: string,
- args: readonly string[],
- cwd = repository,
- env: NodeJS.ProcessEnv = process.env,
-): string {
- return mustRunCommand(LABEL, program, args, cwd, env);
-}
-
-function dockerJava(args: readonly string[]): string {
- const uid = process.getuid?.() ?? 1000;
- const gid = process.getgid?.() ?? 1000;
- return mustRun("docker", [
- "run",
- "--rm",
- "--user",
- `${uid}:${gid}`,
- "-e",
- "HOME=/tmp",
- "-v",
- `${repository}:/repo:ro`,
- "-v",
- `${sdk}:/android-sdk:ro`,
- "-v",
- `${build}:/build`,
- "-v",
- `${signing}:/signing`,
- toolchain.javaImage,
- ...args,
- ]);
-}
-
-function javaImagePresent(): boolean {
- return run("docker", ["image", "inspect", toolchain.javaImage]).exitCode === 0;
-}
-
-/**
- * Checks the target's std directory in the pinned toolchain's sysroot. `rustup
- * target list --toolchain X` would install a missing X on the spot, which a
- * doctor must not do.
- */
-function rustTargetInstalled(): boolean {
- const sysroot = run("rustup", ["run", toolchain.rust.toolchain, "rustc", "--print", "sysroot"]);
- if (sysroot.exitCode !== 0) return false;
- return existsSync(
- join(sysroot.stdout.trim(), "lib/rustlib", toolchain.rust.target, "lib"),
- );
-}
-
-function checkPath(label: string, path: string): boolean {
- return printCheck(label, existsSync(path), path);
-}
-
-function doctor(): void {
- const rust = run("rustup", ["run", toolchain.rust.toolchain, "rustc", "--version"]);
- const quickjs = quickJsCheckoutStatus(quickJs.root, toolchain.quickjs);
- const sdkChecks = [
- checkPath(`Android SDK Platform ${toolchain.android.apiLevel}`, androidJar),
- checkPath(`NDK ${toolchain.android.ndkVersion} clang`, clang),
- checkPath("NDK llvm-readelf", readelf),
- checkPath("aapt2", join(buildTools, "aapt2")),
- checkPath("aapt", join(buildTools, "aapt")),
- checkPath("d8", join(buildTools, "d8")),
- checkPath("zipalign", join(buildTools, "zipalign")),
- checkPath("apksigner", join(buildTools, "apksigner")),
- ];
- const checks = [
- ...sdkChecks,
- printCheck("Java image", javaImagePresent(), toolchain.javaImage),
- printCheck(
- "Rust nightly",
- rust.exitCode === 0,
- rust.stdout.trim() || toolchain.rust.toolchain,
- ),
- printCheck(
- "Rust Android target",
- rustTargetInstalled(),
- `${toolchain.rust.target} on ${toolchain.rust.toolchain}`,
- ),
- printCheck("pinned QuickJS", quickjs.ok, quickjs.detail),
- ];
- if (sdkChecks.some((ok) => !ok)) {
- console.log(
- `Run \`bun blackberry-android setup\` to unpack the pinned SDK archives into ${sdk}, ` +
- `or point POCKETJS_ANDROID_SDK_ROOT at an SDK that already holds ` +
- `${toolchain.android.packages.map((pkg) => pkg.id).join(", ")}.`,
- );
- }
- if (checks.some((ok) => !ok)) process.exitCode = 1;
- else console.log(`[ok] toolchain: ${toolchain.toolchainVersion}`);
-}
-
-function requireToolchain(): void {
- doctor();
- if (process.exitCode) {
- throw new Error(`${LABEL}: toolchain is incomplete; see the doctor report above`);
- }
-}
-
-async function sha1File(path: string): Promise {
- const hash = createHash("sha1");
- for await (const chunk of Bun.file(path).stream()) hash.update(chunk);
- return hash.digest("hex");
-}
-
-/**
- * Unpacks the pinned SDK archives (the same files `sdkmanager` installs) into
- * the SDK root, one host-OS archive per component; nothing is downloaded for
- * a component whose directory already exists.
- */
-async function installSdkPackages(): Promise {
- const downloads = join(cache, "downloads");
- for (const pkg of toolchain.android.packages) {
- const target = join(sdk, pkg.path);
- if (existsSync(target)) continue;
- const archive =
- pkg.archives[process.platform as "linux" | "darwin"] ?? pkg.archives.any;
- if (!archive) {
- throw new Error(`${LABEL}: ${pkg.id} has no archive for host ${process.platform}`);
- }
- mkdirSync(downloads, { recursive: true });
- const download = join(downloads, archive.asset);
- if (!existsSync(download) || (await sha1File(download)) !== archive.sha1) {
- const url = `${toolchain.android.repository}${archive.asset}`;
- console.log(`${LABEL}: downloading ${url}`);
- const response = await fetch(url);
- if (!response.ok || response.body === null) {
- throw new Error(`${LABEL}: ${url} failed (${response.status})`);
- }
- const partial = `${download}.part`;
- const sink = Bun.file(partial).writer();
- let received = 0;
- for await (const chunk of response.body) {
- sink.write(chunk);
- received += chunk.byteLength;
- }
- await sink.end();
- renameSync(partial, download);
- console.log(`${LABEL}: ${archive.asset} ${received} bytes`);
- const digest = await sha1File(download);
- if (digest !== archive.sha1) {
- rmSync(download, { force: true });
- throw new Error(
- `${LABEL}: ${archive.asset} sha1 ${digest} does not match the pinned ${archive.sha1}`,
- );
- }
- }
- const scratch = mkdtempSync(join(downloads, "unpack-"));
- try {
- mustRun("unzip", ["-q", download, "-d", scratch]);
- const entries = readdirSync(scratch).filter((name) => !name.startsWith("."));
- if (entries.length !== 1) {
- throw new Error(
- `${LABEL}: ${archive.asset} unpacked to ${entries.length} entries, expected one directory`,
- );
- }
- mkdirSync(dirname(target), { recursive: true });
- renameSync(join(scratch, entries[0]), target);
- console.log(`${LABEL}: ${pkg.id} -> ${target}`);
- } finally {
- rmSync(scratch, { recursive: true, force: true });
- }
- }
-}
-
-async function setup(): Promise {
- await installSdkPackages();
- if (!javaImagePresent()) mustRun("docker", ["pull", toolchain.javaImage]);
- ensureQuickJsCheckout(LABEL, quickJs.root, toolchain.quickjs);
- if (!rustTargetInstalled()) {
- mustRun("rustup", [
- "target",
- "add",
- toolchain.rust.target,
- "--toolchain",
- toolchain.rust.toolchain,
- ]);
- }
- doctor();
-}
-
-function ensureKeystore(): void {
- mkdirSync(signing, { recursive: true });
- if (existsSync(keystore)) return;
- dockerJava([
- "keytool",
- "-genkeypair",
- "-noprompt",
- "-keystore",
- `/signing/${keystoreName}`,
- "-storepass",
- "android",
- "-alias",
- "androiddebugkey",
- "-keypass",
- "android",
- "-dname",
- "CN=PocketJS BlackBerry Classic,O=PocketJS,C=HK",
- "-keyalg",
- "RSA",
- "-keysize",
- "2048",
- "-validity",
- "10000",
- ]);
-}
-
-/** javac → jar → d8 for PocketActivity, into staging/classes.dex. */
-function compileActivity(): void {
- mkdirSync(join(build, "classes"), { recursive: true });
- mkdirSync(join(build, "dex"), { recursive: true });
- dockerJava([
- "javac",
- "-encoding",
- "UTF-8",
- "-source",
- "7",
- "-target",
- "7",
- "-bootclasspath",
- `/android-sdk/platforms/android-${toolchain.android.apiLevel}/android.jar`,
- "-d",
- "/build/classes",
- "/repo/hosts/blackberry-classic-android/app/src/dev/pocketstack/blackberry/PocketActivity.java",
- ]);
- dockerJava(["jar", "cf", "/build/classes.jar", "-C", "/build/classes", "."]);
- dockerJava([
- `/android-sdk/build-tools/${toolchain.android.buildToolsVersion}/d8`,
- "--min-api",
- String(toolchain.android.apiLevel),
- "--output",
- "/build/dex",
- "/build/classes.jar",
- ]);
- copyFileSync(join(build, "dex/classes.dex"), join(staging, "classes.dex"));
-}
-
-/**
- * aapt2 → zipalign → apksigner over staging/ (classes.dex + lib/) plus the
- * guest assets. Android 4.3 verifies only the JAR (v1) signature scheme, so
- * every newer scheme is disabled explicitly.
- */
-function packageApk(identity: PackageIdentity, resources: string, assets: string): {
- readonly signature: string;
- readonly badging: string;
-} {
- const compiled = join(build, "app-res.zip");
- mustRun(join(buildTools, "aapt2"), ["compile", "--dir", resources, "-o", compiled]);
- const manifest = join(build, "AndroidManifest.xml");
- writeFileSync(
- manifest,
- renderTemplate(readFileSync(join(appHost, "AndroidManifest.xml"), "utf8"), {
- PACKAGE: identity.packageId,
- VERSION_CODE: identity.versionCode,
- VERSION_NAME: identity.version,
- }),
- );
- const unsigned = join(build, "app-unsigned.apk");
- mustRun(join(buildTools, "aapt2"), [
- "link",
- "-o",
- unsigned,
- "--manifest",
- manifest,
- "-I",
- androidJar,
- "-A",
- assets,
- "--min-sdk-version",
- String(toolchain.android.apiLevel),
- "--target-sdk-version",
- String(toolchain.android.apiLevel),
- compiled,
- ]);
- mustRun("zip", ["-q", "-r", unsigned, "classes.dex", "lib"], staging);
- const aligned = join(build, "app-aligned.apk");
- mustRun(join(buildTools, "zipalign"), ["-f", "-p", "4", unsigned, aligned]);
- ensureKeystore();
- dockerJava([
- `/android-sdk/build-tools/${toolchain.android.buildToolsVersion}/apksigner`,
- "sign",
- "--ks",
- `/signing/${keystoreName}`,
- "--ks-key-alias",
- "androiddebugkey",
- "--ks-pass",
- "pass:android",
- "--key-pass",
- "pass:android",
- "--min-sdk-version",
- String(toolchain.android.apiLevel),
- "--v1-signing-enabled",
- "true",
- "--v2-signing-enabled",
- "false",
- "--v3-signing-enabled",
- "false",
- "--v4-signing-enabled",
- "false",
- "--out",
- "/build/app-signed.apk",
- "/build/app-aligned.apk",
- ]);
- mkdirSync(dirname(appOutput), { recursive: true });
- copyFileSync(join(build, "app-signed.apk"), appOutput);
- const signature = dockerJava([
- `/android-sdk/build-tools/${toolchain.android.buildToolsVersion}/apksigner`,
- "verify",
- "--verbose",
- "--print-certs",
- "--min-sdk-version",
- String(toolchain.android.apiLevel),
- "/build/app-signed.apk",
- ]);
- const badging = mustRun(join(buildTools, "aapt"), ["dump", "badging", appOutput]);
- return { signature, badging };
-}
-
-function resetBuild(): void {
- rmSync(build, { recursive: true, force: true });
- mkdirSync(join(staging, "lib", toolchain.android.abi), { recursive: true });
-}
-
-function buildRustCore(): string {
- const rustTarget = join(build, "rust");
- mustRun(
- "rustup",
- [
- "run",
- toolchain.rust.toolchain,
- "cargo",
- "build",
- "--release",
- "--locked",
- "--target",
- toolchain.rust.target,
- "--features",
- "bare-platform",
- "--target-dir",
- rustTarget,
- ],
- join(repository, "engine/ui-cabi"),
- {
- ...process.env,
- CARGO_PROFILE_RELEASE_LTO: "false",
- CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER: clang,
- },
- );
- const library = join(rustTarget, toolchain.rust.target, "release/libpocketjs_symbian_core.a");
- if (!existsSync(library)) {
- throw new Error(`${LABEL}: Rust core archive is absent: ${library}`);
- }
- return library;
-}
-
-function buildQuickJs(): string {
- const objects = join(build, "objects/quickjs");
- mkdirSync(objects, { recursive: true });
- const flags = [
- "-std=gnu11",
- "-O2",
- "-fPIC",
- "-funsigned-char",
- "-fno-strict-aliasing",
- "-ffunction-sections",
- "-fdata-sections",
- "-D_GNU_SOURCE",
- `-DCONFIG_VERSION="${toolchain.quickjs.version}"`,
- `-I${quickJs.source}`,
- ];
- const objectPaths: string[] = [];
- for (const source of ["cutils.c", "dtoa.c", "libregexp.c", "libunicode.c", "quickjs.c"]) {
- const object = join(objects, source.replace(/\.c$/, ".o"));
- mustRun(clang, [...flags, "-c", join(quickJs.source, source), "-o", object]);
- objectPaths.push(object);
- }
- const staticFunctions = join(objects, "static-functions.o");
- mustRun(clang, [...flags, "-c", quickJs.staticFunctions, "-o", staticFunctions]);
- objectPaths.push(staticFunctions);
- const library = join(build, "libquickjs.a");
- mustRun(join(llvm, "llvm-ar"), ["rcs", library, ...objectPaths]);
- return library;
-}
-
-function buildNativeLibrary(bundle: GuestBundle, quickJsLibrary: string, coreLibrary: string): string {
- const objects = join(build, "objects");
- const cFlags = [
- "-std=gnu11",
- "-Os",
- "-fPIC",
- "-fno-strict-aliasing",
- "-ffunction-sections",
- "-fdata-sections",
- "-fvisibility=hidden",
- "-Wall",
- "-Wextra",
- "-Werror",
- "-Wno-unused-parameter",
- ];
- const portableRuntime = join(objects, "pocket_runtime.o");
- mustRun(clang, [
- ...cFlags,
- `-DPOCKETJS_TARGET_ID="${bundle.inputs.target}"`,
- `-DPOCKETJS_HOST_ABI=${bundle.inputs.hostAbi}`,
- `-DPOCKET_RASTER_DENSITY=${bundle.inputs.viewport.rasterDensity}`,
- `-I${join(repository, "engine/quickjs-c")}`,
- `-I${join(repository, "engine/ui-cabi/include")}`,
- `-I${join(repository, "contracts/generated")}`,
- `-I${quickJs.source}`,
- "-c",
- join(repository, "engine/quickjs-c/pocket_runtime.c"),
- "-o",
- portableRuntime,
- ]);
- const androidRuntime = join(objects, "android_runtime.o");
- mustRun(clang, [
- ...cFlags,
- `-DPOCKET_LOGICAL_WIDTH=${bundle.inputs.viewport.logical[0]}`,
- `-DPOCKET_LOGICAL_HEIGHT=${bundle.inputs.viewport.logical[1]}`,
- `-I${join(repository, "engine/quickjs-c")}`,
- `-I${join(repository, "hosts/blackberry-classic")}`,
- `-I${join(repository, "contracts/generated")}`,
- "-c",
- join(appHost, "jni/runtime.c"),
- "-o",
- androidRuntime,
- ]);
- const sharedSources = [
- {
- name: "pocket_input",
- source: join(repository, "hosts/blackberry-classic/pocket_input.c"),
- includes: [
- `-I${join(repository, "hosts/blackberry-classic")}`,
- `-I${join(repository, "contracts/generated")}`,
- ],
- },
- {
- name: "rust_eh_personality",
- source: join(repository, "engine/quickjs-c/rust_eh_personality.c"),
- includes: [],
- },
- ] satisfies Array<{ name: string; source: string; includes: string[] }>;
- const sharedObjects = sharedSources.map(({ name, source, includes }) => {
- const object = join(objects, `${name}.o`);
- mustRun(clang, [
- ...cFlags,
- ...includes,
- "-c",
- source,
- "-o",
- object,
- ]);
- return object;
- });
- const nativeLibrary = join(staging, "lib", toolchain.android.abi, "libpocketjs.so");
- /* No -landroid: the library needs nothing beyond GLESv2/log/dl/m/c, and
- * --no-undefined turns any missing native symbol into a link failure. */
- mustRun(clang, [
- "-shared",
- "-Wl,--build-id=none",
- "-Wl,--gc-sections",
- "-Wl,--exclude-libs,ALL",
- "-Wl,--no-undefined",
- androidRuntime,
- portableRuntime,
- ...sharedObjects,
- quickJsLibrary,
- coreLibrary,
- "-o",
- nativeLibrary,
- "-lGLESv2",
- "-llog",
- "-ldl",
- "-lm",
- ]);
- return nativeLibrary;
-}
-
-function buildApp(): void {
- requireToolchain();
- const bundle = readGuestBundle(guest);
- resetBuild();
- const coreLibrary = buildRustCore();
- const quickJsLibrary = buildQuickJs();
- const nativeLibrary = buildNativeLibrary(bundle, quickJsLibrary, coreLibrary);
- compileActivity();
-
- const assets = join(build, "assets");
- mkdirSync(assets, { recursive: true });
- copyFileSync(bundle.javaScript, join(assets, "app.js"));
- copyFileSync(bundle.pack, join(assets, "app.pak"));
- const identity = packageIdentity(bundle.inputs.app);
- const resources = join(build, "resources");
- cpSync(join(appHost, "res"), resources, { recursive: true });
- writeFileSync(
- join(resources, "values/strings.xml"),
- renderTemplate(readFileSync(join(appHost, "res/values/strings.xml"), "utf8"), {
- /* Android string resources also need apostrophes escaped. */
- TITLE: xmlEscape(identity.title).replace(/'/g, "\\'"),
- }),
- );
- mkdirSync(join(resources, "drawable"), { recursive: true });
- copyFileSync(
- join(repository, "assets/images/logo.png"),
- join(resources, "drawable/icon.png"),
- );
- const { signature, badging } = packageApk(identity, resources, assets);
- for (const marker of [
- `package: name='${identity.packageId}' versionCode='${identity.versionCode}' versionName='${identity.version}'`,
- `sdkVersion:'${toolchain.android.apiLevel}'`,
- ]) {
- if (!badging.includes(marker)) {
- throw new Error(`${LABEL}: APK badging is missing ${marker}`);
- }
- }
- const receipt = {
- schema: 1,
- toolchain: toolchain.toolchainVersion,
- planHash: bundle.plan.planHash,
- package: identity,
- target: bundle.inputs.target,
- hostAbi: bundle.inputs.hostAbi,
- viewport: bundle.inputs.viewport,
- apk: {
- path: toolchain.app.output,
- bytes: readFileSync(appOutput).byteLength,
- sha256: sha256File(appOutput),
- },
- guest: {
- javaScript: sha256File(bundle.javaScript),
- pack: sha256File(bundle.pack),
- },
- nativeLibrary: {
- bytes: readFileSync(nativeLibrary).byteLength,
- sha256: sha256File(nativeLibrary),
- elf: mustRun(readelf, ["-h", "-A", "-d", nativeLibrary]),
- },
- quickjs: {
- version: toolchain.quickjs.version,
- revision: toolchain.quickjs.revision,
- },
- rust: toolchain.rust,
- signature,
- badging,
- };
- const receiptPath = join(dirname(appOutput), "pocketjs-blackberry-classic.receipt.json");
- writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
- console.log(`${LABEL}: Hero APK -> ${appOutput}`);
- console.log(`SHA-256: ${receipt.apk.sha256}`);
- console.log(`Receipt: ${receiptPath}`);
-}
-
-switch (command) {
- case "doctor":
- doctor();
- break;
- case "setup":
- await setup();
- break;
- case "build-demo":
- buildGuestBundle(guest);
- break;
- case "build-app":
- buildApp();
- break;
- case "build":
- buildGuestBundle(guest);
- buildApp();
- break;
- default:
- throw new Error(
- "usage: bun tools/blackberry-android.ts ",
- );
-}
+// Compatibility CLI for the BlackBerry Android Runtime profile.
+import "./android.ts";
diff --git a/tools/cli/moto-g-play-toolchain.json b/tools/cli/moto-g-play-toolchain.json
new file mode 100644
index 000000000..4a4cb5cf1
--- /dev/null
+++ b/tools/cli/moto-g-play-toolchain.json
@@ -0,0 +1,29 @@
+{
+ "schemaVersion": 1,
+ "toolchainVersion": "moto-g-play-api34-v1",
+ "cachePath": "android",
+ "android": {
+ "apiLevel": 34,
+ "platformVersion": "14",
+ "buildToolsVersion": "35.0.0",
+ "ndkVersion": "27.1.12297006",
+ "abi": "arm64-v8a",
+ "clangTarget": "aarch64-linux-android23",
+ "repository": "https://dl.google.com/android/repository/",
+ "packages": []
+ },
+ "javaImage": "eclipse-temurin:17-jdk-jammy@sha256:29467857e8bde40ab1f7befecbda0ea764b95afec1cc7f89aa90f7a766577e19",
+ "quickjs": {
+ "version": "2026-06-04",
+ "repository": "https://github.com/pocket-stack/quickjs-rs.git",
+ "revision": "ba5bdd0dc013518768e76cd9e05cd30ed53dd35b"
+ },
+ "rust": {
+ "toolchain": "nightly-2026-07-02",
+ "target": "aarch64-linux-android"
+ },
+ "app": {
+ "manifest": "apps/clear/pocket.android.json",
+ "output": "dist/moto-g-play/pocket-clear.apk"
+ }
+}
diff --git a/tools/ime/build-ipod-tap.ts b/tools/ime/build-ipod-tap.ts
new file mode 100644
index 000000000..7f6a903cc
--- /dev/null
+++ b/tools/ime/build-ipod-tap.ts
@@ -0,0 +1,20 @@
+/** Build the test-only event sender after `bun ipodtouch4 build`. */
+import { resolve, join } from "node:path";
+import { ipodtouch4SysrootPath } from "../ipodtouch4-toolchain.ts";
+const root = resolve(import.meta.dir, "../..");
+const directory = join(root, ".pocket-build/ipodtouch4/clear/runtime");
+const output = join(root, ".pocket-build/pocket-ime-gstap");
+function run(args: string[]) {
+ const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe" });
+ if (result.exitCode) throw new Error(result.stderr.toString());
+ return result.stdout.toString().trim();
+}
+const sdk = run(["xcrun", "--sdk", "macosx", "--show-sdk-path"]);
+run(["xcrun", "clang", "-target", "armv7-apple-ios6.0", "-miphoneos-version-min=6.0", "-Os", "-fno-stack-protector",
+ "-Wno-incompatible-sysroot", "-isysroot", sdk, "-c", join(import.meta.dir, "ipod-tap.c"), "-o", `${output}.o`]);
+run(["xcrun", "ld-classic", "-arch", "armv7", "-syslibroot", ipodtouch4SysrootPath(), "-L/usr/lib",
+ "-iphoneos_version_min", "6.0", "-no_pie", "-no_uuid", "-no_function_starts", "-no_data_in_code_info", "-no_source_version",
+ "-no_compact_unwind", "-no_adhoc_codesign", "-no_encryption", "-e", "start", "-o", output,
+ join(directory, "csu-start.o"), join(directory, "csu-dyld-glue.o"), join(directory, "crt_globals.o"), `${output}.o`, "-lSystem", "-lgcc_s.1"]);
+run(["ldid", "-S", output]);
+console.log(output);
diff --git a/tools/ime/device.ts b/tools/ime/device.ts
new file mode 100644
index 000000000..fabc373ff
--- /dev/null
+++ b/tools/ime/device.ts
@@ -0,0 +1,54 @@
+/** Pair and run one USB companion. Keys remain in private device storage and
+ * ignored local files, outside APKs, IPAs, guest bundles, and build receipts. */
+import { randomBytes, createHash } from "node:crypto";
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { homedir } from "node:os";
+import { shellQuote } from "../ipodtouch4-installation.ts";
+const target = Bun.argv[2];
+if (!["ipodtouch4", "moto-g-play"].includes(target)) throw new Error("Usage: bun tools/ime/device.ts --id=");
+const option = (key: string) => Bun.argv.find(a => a.startsWith(`--${key}=`))?.slice(key.length + 3);
+const id = option("id");
+if (!id || !/^[A-Za-z0-9-]+$/.test(id)) throw new Error("Select the exact device with --id");
+const keyPath = resolve(option("key") ?? `.pocket/clear-${target}.key`);
+mkdirSync(dirname(keyPath), { recursive: true });
+if (!existsSync(keyPath)) writeFileSync(keyPath, randomBytes(32).toString("hex"), { mode: 0o600, flag: "wx" });
+const key = readFileSync(keyPath, "utf8").trim();
+if (!/^[0-9a-f]{64}$/.test(key)) throw new Error("Invalid pairing key");
+function run(args: string[], stdin?: string) {
+ const result = Bun.spawnSync(args, { stdin: stdin === undefined ? "ignore" : Buffer.from(stdin), stdout: "pipe", stderr: "pipe" });
+ if (result.exitCode) throw new Error(`${args[0]} failed: ${result.stderr.toString()}`);
+ return result.stdout.toString().trim();
+}
+let tunnel: ReturnType | undefined;
+process.on("exit", () => tunnel?.kill());
+const port = target === "ipodtouch4" ? 18741 : 28741;
+if (target === "ipodtouch4") {
+ if (run(["ideviceinfo", "-u", id, "-k", "ProductType"]) !== "iPod4,1") throw new Error("Expected iPod touch 4");
+ tunnel = Bun.spawn(["iproxy", "-u", id, "19224:22", `${port}:8741`], { stdout: "ignore", stderr: "inherit" });
+ await Bun.sleep(500);
+ const cache = resolve(homedir(), ".cache/pocket-stack/ipodtouch4/ssh");
+ const ssh = ["ssh", "-p", "19224", "-i", resolve(cache, "id_rsa"), "-o", `UserKnownHostsFile=${resolve(cache, "known_hosts")}`,
+ "-o", "HostKeyAlias=[127.0.0.1]:2224", "-o", "StrictHostKeyChecking=yes",
+ "-o", "HostKeyAlgorithms=+ssh-rsa", "-o", "PubkeyAcceptedAlgorithms=+ssh-rsa", "-o", "BatchMode=yes", "root@127.0.0.1"];
+ const bundle = run([...ssh, "/var/root/Library/PocketJS/ipodtouch4-installer user-path dev.pocket-stack.clear"]);
+ if (!/^\/private\/var\/mobile\/Applications\/[A-Fa-f0-9-]+\/PocketJSiPodTouch4.app$/.test(bundle)) throw new Error("Unexpected Clear installation path");
+ const path = shellQuote(`${dirname(bundle)}/Documents/offload.key`);
+ run([...ssh, `umask 077; cat > ${path}; chown mobile:mobile ${path}`], key);
+ const readback = run([...ssh, `cat ${path}`]);
+ if (readback !== key) throw new Error("Pairing readback mismatch");
+} else {
+ const adb = ["adb", "-s", id];
+ if (run([...adb, "shell", "getprop", "ro.product.device"]) !== "fogona") throw new Error("Expected Moto G Play 2024 (fogona)");
+ run([...adb, "shell", "run-as", "dev.pocket_stack.clear", "mkdir", "-p", "files"]);
+ run([...adb, "shell", "run-as", "dev.pocket_stack.clear", "sh", "-c", "'umask 077; cat > files/offload.key'"], key);
+ if (run([...adb, "shell", "run-as", "dev.pocket_stack.clear", "cat", "files/offload.key"]) !== key) throw new Error("Pairing readback mismatch");
+ run([...adb, "forward", `tcp:${port}`, "tcp:8741"]);
+}
+writeFileSync(resolve(dirname(keyPath), `clear-${target}-pairing.json`), JSON.stringify({ target, id, port,
+ keyFingerprint: createHash("sha256").update(key).digest("hex"), readback: true }, null, 2));
+const companion = Bun.spawn([process.execPath, resolve(import.meta.dir, "serve.ts"), `--port=${port}`, `--key=${keyPath}`], { stdout: "inherit", stderr: "inherit" });
+const stop = () => { companion.kill(); tunnel?.kill(); };
+process.on("SIGINT", stop); process.on("SIGTERM", stop);
+await companion.exited;
+tunnel?.kill();
diff --git a/tools/ime/ipod-tap.c b/tools/ime/ipod-tap.c
new file mode 100644
index 000000000..352c8d079
--- /dev/null
+++ b/tools/ime/ipod-tap.c
@@ -0,0 +1,42 @@
+/* Test-only iOS 6 ARMv7 UIKit event sender. Not linked into PocketJS apps.
+ * ABI reference: mringwal/hid-support, 3rdParty/GraphicsServices/GSEvent.h.
+ * Target is restricted to the Clear test bundle; coordinates are logical points. */
+#include
+#include
+#include
+#include
+#include
+#include
+typedef struct { float x,y; } Point;
+typedef struct { int type,subtype; Point location,windowLocation; int context; uint64_t time; void *window; unsigned flags,pid; int size; } Record;
+typedef struct { int type; short dx,dy; float a,b,width,c,height,d; unsigned char e,count; unsigned short x52; } Hand;
+typedef struct { unsigned char index,identity,proximity; float pressure,radius; Point location; void *window; } Path;
+int main(int argc,char **argv) {
+ // x y [hold-ms [end-x end-y drag-ms]]; existing taps retain their timing.
+ if(argc!=3 && argc!=4 && argc!=7)return 2;
+ int hold=argc>=4?atoi(argv[3]):150, drag=argc==7?atoi(argv[6]):0;
+ if(hold<0||hold>30000||drag<0||drag>30000)return 2;
+ Point start={atof(argv[1]),atof(argv[2])};
+ Point end=argc==7?(Point){atof(argv[4]),atof(argv[5])}:start;
+ int moves=drag>0?(drag+15)/16:0;
+ void *lib=dlopen("/System/Library/PrivateFrameworks/GraphicsServices.framework/GraphicsServices",RTLD_NOW);
+ unsigned (*port)(const char*)=dlsym(lib,"GSCopyPurpleNamedPort");
+ uint64_t (*now)(void)=dlsym(lib,"GSCurrentEventTimestamp");
+ void (*send)(void*,unsigned)=dlsym(lib,"GSSendEvent");
+ if(!port||!now||!send)return 3;
+ unsigned target=port("dev.pocket-stack.clear");
+ printf("port=%u record=%lu hand=%lu path=%lu\n",target,(unsigned long)sizeof(Record),(unsigned long)sizeof(Hand),(unsigned long)sizeof(Path));
+ if(!target)return 4;
+ for(int step=0;step<=moves+1;step++) {
+ int down=step<=moves;
+ float t=step==0?0: moves>0?(float)step/moves:1; if(t>1)t=1;
+ struct {Record record;Hand hand;Path path;} event;
+ memset(&event,0,sizeof event);
+ event.record.type=3001;event.record.location=(Point){start.x+(end.x-start.x)*t,start.y+(end.y-start.y)*t};event.record.windowLocation=event.record.location;
+ event.record.time=now();event.record.size=sizeof(Hand)+sizeof(Path);
+ event.hand.type=step==0?1:down?2:6;event.hand.x52=1;
+ event.path.index=1;event.path.identity=2;event.path.proximity=down?3:0;event.path.pressure=1;event.path.radius=1;event.path.location=event.record.location;
+ send(&event,target);usleep(step==0?hold*1000:down?drag*1000/moves:150000);
+ }
+ return 0;
+}
diff --git a/tools/ime/pocket_pinyin.schema.yaml b/tools/ime/pocket_pinyin.schema.yaml
new file mode 100644
index 000000000..ce6353382
--- /dev/null
+++ b/tools/ime/pocket_pinyin.schema.yaml
@@ -0,0 +1,31 @@
+# A replayable full-pinyin schema using the upstream Luna Pinyin dictionary.
+schema:
+ schema_id: pocket_pinyin
+ name: Pocket Pinyin
+ version: '1'
+switches:
+ - { name: ascii_mode, reset: 0 }
+ - { name: simplification, reset: 1 }
+engine:
+ processors: [speller, punctuator, selector, navigator, express_editor]
+ segmentors: [abc_segmentor, punct_segmentor, fallback_segmentor]
+ translators: [punct_translator, script_translator]
+ filters: [simplifier, uniquifier]
+menu:
+ page_size: 5
+speller:
+ alphabet: abcdefghijklmnopqrstuvwxyz
+ delimiter: " '"
+ algebra:
+ - abbrev/^([a-z]).+$/$1/
+ - abbrev/^([zcs]h).+$/$1/
+translator:
+ dictionary: luna_pinyin
+ enable_user_dict: false
+ enable_sentence: true
+ enable_completion: true
+simplifier:
+ option_name: simplification
+ opencc_config: t2s.json
+punctuator:
+ import_preset: default
diff --git a/tools/ime/rime.c b/tools/ime/rime.c
new file mode 100644
index 000000000..53b504dae
--- /dev/null
+++ b/tools/ime/rime.c
@@ -0,0 +1,111 @@
+/* Private, line-oriented worker helper. Each bounded transcript is evaluated
+ * in a fresh session against a schema with user learning disabled. */
+#include
+#include
+#include
+#include
+#define SELECT 0x1000000
+#define SELECT_ABSOLUTE 0x2000000
+#define BROWSE_SIZE 15
+/* Trackpad steps are bounded raw-input character moves. The schema navigator
+ * handles syllable navigation and can wrap Left from the start of a segment. */
+static void move_caret(RimeApi *api, RimeSessionId session, int direction) {
+ const char *input = api->get_input(session);
+ size_t length = input ? strlen(input) : 0;
+ size_t caret = api->get_caret_pos(session), next = caret > length ? length : caret;
+ if (direction < 0 && next) {
+ do { next--; } while (next && ((unsigned char)input[next] & 0xc0) == 0x80);
+ } else if (direction > 0 && next < length) {
+ do { next++; } while (next < length && ((unsigned char)input[next] & 0xc0) == 0x80);
+ }
+ if (next != caret) api->set_caret_pos(session, next);
+}
+/* Rime reports a UTF-8 byte offset; the guest slices UTF-16 strings. */
+static int caret_utf16(const char *s, int bytes) {
+ int units = 0;
+ if (s) for (int i = 0; i < bytes && s[i]; i++) {
+ unsigned char c = (unsigned char)s[i];
+ if ((c & 0xc0) != 0x80) units += c >= 0xf0 ? 2 : 1;
+ }
+ return units;
+}
+static void string(const char *s) {
+ putchar('"');
+ if (s) for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
+ if (*p == '"' || *p == '\\') { putchar('\\'); putchar(*p); }
+ else if (*p < 32) printf("\\u%04x", *p);
+ else putchar(*p);
+ }
+ putchar('"');
+}
+static size_t json_bytes(const char *s) {
+ size_t bytes = 2;
+ for (const unsigned char *p = (const unsigned char *)s; *p; p++)
+ bytes += *p < 32 ? 6 : (*p == '"' || *p == '\\') ? 2 : 1;
+ return bytes;
+}
+int main(int argc, char **argv) {
+ if (argc != 2) return 2;
+ RimeApi *api = rime_get_api();
+ RIME_STRUCT(RimeTraits, traits);
+ traits.shared_data_dir = argv[1]; traits.user_data_dir = argv[1];
+ traits.app_name = "rime.pocketjs"; traits.min_log_level = 2;
+ api->setup(&traits); api->initialize(&traits);
+ char line[2048];
+ while (fgets(line, sizeof line, stdin)) {
+ RimeSessionId session = api->create_session();
+ if (!session || !api->select_schema(session, "pocket_pinyin")) {
+ if (session) api->destroy_session(session);
+ puts("{\"error\":\"Rime schema unavailable\"}"); fflush(stdout); continue;
+ }
+ api->set_option(session, "ascii_mode", False);
+ char committed[2048] = {0};
+ char *p = line;
+ unsigned count = 0;
+ while (*p && *p != '\n' && count++ < 128) {
+ char *end; long key = strtol(p, &end, 10);
+ if (end == p) break;
+ p = *end == ',' ? end + 1 : end;
+ if (key >= SELECT && key < SELECT + 5) api->select_candidate_on_current_page(session, (size_t)(key - SELECT));
+ else if (key >= SELECT_ABSOLUTE && key < SELECT_ABSOLUTE + 512) api->select_candidate(session, (size_t)(key - SELECT_ABSOLUTE));
+ else if (key == 0xff51 || key == 0xff53) move_caret(api, session, key == 0xff51 ? -1 : 1);
+ else api->process_key(session, (int)key, 0);
+ RIME_STRUCT(RimeCommit, commit);
+ if (api->get_commit(session, &commit)) {
+ if (commit.text && strlen(committed) + strlen(commit.text) < sizeof committed) strcat(committed, commit.text);
+ api->free_commit(&commit);
+ }
+ }
+ /* A read-only candidate window follows the transcript after ';'. It never
+ * becomes a key action, changes composition, or consumes replay capacity. */
+ if (*p == ';') {
+ int offset = atoi(p + 1), n = 0, more = 0; size_t bytes = 128;
+ RimeCandidateListIterator it = {0};
+ printf("{\"offset\":%d,\"candidates\":[", offset);
+ if (offset >= 0 && offset < 512 && api->candidate_list_from_index(session, &it, offset)) {
+ while (api->candidate_list_next(&it)) {
+ size_t item_bytes = json_bytes(it.candidate.text);
+ if (n == BROWSE_SIZE || offset + n >= 512 || (n && bytes + item_bytes > 2500)) { more = offset + n < 512; break; }
+ bytes += item_bytes + 1;
+ if (n++) putchar(','); string(it.candidate.text);
+ }
+ api->candidate_list_end(&it);
+ }
+ printf("],\"last\":%s}\n", more ? "false" : "true"); fflush(stdout);
+ api->destroy_session(session); continue;
+ }
+ RIME_STRUCT(RimeContext, ctx);
+ int has = api->get_context(session, &ctx);
+ printf("{\"commit\":"); string(committed);
+ printf(",\"preedit\":"); string(has ? ctx.composition.preedit : "");
+ printf(",\"caret\":%d,\"page\":%d,\"last\":%s,\"candidates\":[",
+ has ? caret_utf16(ctx.composition.preedit, ctx.composition.cursor_pos) : 0, has ? ctx.menu.page_no : 0, !has || ctx.menu.is_last_page ? "true" : "false");
+ if (has) for (int i = 0; i < ctx.menu.num_candidates && i < 5; i++) {
+ if (i) putchar(','); string(ctx.menu.candidates[i].text);
+ }
+ puts("]}"); fflush(stdout);
+ if (has) api->free_context(&ctx);
+ api->destroy_session(session);
+ }
+ api->finalize(); return 0;
+}
diff --git a/tools/ime/rime.ts b/tools/ime/rime.ts
new file mode 100644
index 000000000..facb37ed9
--- /dev/null
+++ b/tools/ime/rime.ts
@@ -0,0 +1,50 @@
+import { join } from "node:path";
+import { validImeKeys, validImeBrowse } from "../../contracts/spec/ime.ts";
+
+/** Supervisor-owned native engine. Socket workers may restart without owning
+ * or orphaning this process. Each query still replays an independent session. */
+export class RimeEngine {
+ private child?: ReturnType;
+ private reader?: ReadableStreamDefaultReader;
+ private buffered = "";
+ private serial = Promise.resolve();
+ private pending = 0;
+ private stopped = false;
+ constructor(readonly directory: string) {}
+ compose(payload: string, browse = false): Promise {
+ const input = JSON.parse(payload);
+ if (browse ? !validImeBrowse(input) : !validImeKeys(input)) return Promise.reject(new Error("Invalid IME transcript"));
+ const keys: number[] = browse ? input.keys : input;
+ if (this.stopped || this.pending >= 16) return Promise.reject(new Error("IME engine busy"));
+ this.pending++;
+ const result = this.serial.then(async () => {
+ const timeout = setTimeout(() => this.reset(), 5000);
+ try {
+ if (this.stopped) throw new Error("IME engine closed");
+ if (!this.child) {
+ this.child = Bun.spawn([join(this.directory, "pocket-rime"), this.directory], { stdin: "pipe", stdout: "pipe", stderr: "inherit" });
+ this.reader = (this.child.stdout as ReadableStream).getReader();
+ }
+ const decoder = new TextDecoder();
+ (this.child.stdin as import("bun").FileSink).write(`${keys.join(",")}${browse ? `;${input.offset}` : ""}\n`);
+ (this.child.stdin as import("bun").FileSink).flush();
+ while (!this.buffered.includes("\n")) {
+ const chunk = await this.reader!.read();
+ if (chunk.done) throw new Error("Rime engine exited");
+ this.buffered += decoder.decode(chunk.value, { stream: true });
+ if (this.buffered.length > 8192) throw new Error("Rime output exceeds budget");
+ }
+ const end = this.buffered.indexOf("\n"), result = this.buffered.slice(0, end);
+ this.buffered = this.buffered.slice(end + 1);
+ const snapshot = JSON.parse(result);
+ if (snapshot.error) throw new Error(snapshot.error);
+ return result;
+ } catch (error) { this.reset(); throw error; }
+ finally { clearTimeout(timeout); this.pending--; }
+ });
+ this.serial = result.then(() => {}, () => {});
+ return result;
+ }
+ private reset() { this.child?.kill(); this.child = undefined; this.reader = undefined; this.buffered = ""; }
+ close() { this.stopped = true; this.reset(); }
+}
diff --git a/tools/ime/serve.ts b/tools/ime/serve.ts
new file mode 100644
index 000000000..af712b875
--- /dev/null
+++ b/tools/ime/serve.ts
@@ -0,0 +1,26 @@
+import { RimeEngine } from "./rime.ts";
+import { randomBytes } from "node:crypto";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { connectOffloadProvider } from "../offload-provider.ts";
+const args = Bun.argv.slice(2);
+const option = (name: string, fallback: string) => args.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
+const key = readFileSync(resolve(option("key", ".pocket/clear-offload.key")), "utf8").trim();
+const port = Number(option("port", "18741"));
+const engine = new RimeEngine(resolve(option("data", ".pocket/ime")));
+const token = randomBytes(32).toString("hex");
+const server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 2048,
+ async fetch(request) {
+ const path = new URL(request.url).pathname;
+ if (request.method !== "POST" || !["/compose", "/candidates"].includes(path) || request.headers.get("authorization") !== token)
+ return new Response("Forbidden", { status: 403 });
+ try { return new Response(await engine.compose(await request.text(), path === "/candidates")); }
+ catch { return new Response("IME engine unavailable", { status: 503 }); }
+ },
+});
+const provider = connectOffloadProvider({ address: "127.0.0.1", port, key,
+ worker: new URL("./worker.ts", import.meta.url),
+ data: { enginePort: server.port, engineToken: token, font: option("font", "/System/Library/Fonts/STHeiti Medium.ttc") }, log: console.log });
+process.on("SIGINT", () => { provider.close(); engine.close(); server.stop(true); process.exit(0); });
+process.on("SIGTERM", () => { provider.close(); engine.close(); server.stop(true); process.exit(0); });
+console.log(`Pocket IME companion: USB localhost:${port}`);
diff --git a/tools/ime/setup.ts b/tools/ime/setup.ts
new file mode 100644
index 000000000..50a5b8517
--- /dev/null
+++ b/tools/ime/setup.ts
@@ -0,0 +1,30 @@
+import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { resolve, join } from "node:path";
+const root = resolve(import.meta.dir, "../..");
+const data = resolve(Bun.argv[2] ?? join(root, ".pocket/ime"));
+mkdirSync(data, { recursive: true });
+function run(args: string[]) {
+ const result = Bun.spawnSync(args, { cwd: root, stdout: "inherit", stderr: "inherit" });
+ if (result.exitCode) throw new Error(`Failed: ${args[0]}`);
+}
+const dependencies = {
+ "luna-pinyin": "56b934b099dfbeab842320f13aa8b461a6ab3e42",
+ prelude: "082425ea0684bca36474415d4a0e8db9b016487e",
+ essay: "e9b1a374a6ea015fca5bdd04318924b4483ac35a",
+};
+for (const [name, revision] of Object.entries(dependencies)) {
+ const checkout = join(root, ".pocket-build", `rime-${name}`);
+ if (!existsSync(join(checkout, ".git"))) run(["git", "clone", `https://github.com/rime/rime-${name}.git`, checkout]);
+ run(["git", "-C", checkout, "fetch", "origin", revision]);
+ run(["git", "-C", checkout, "checkout", "--detach", revision]);
+ for (const entry of new Bun.Glob("*.{yaml,txt}").scanSync(checkout)) cpSync(join(checkout, entry), join(data, entry));
+}
+const prefix = process.env.POCKETJS_RIME_PREFIX ?? "/opt/homebrew";
+cpSync(join(prefix, "share/opencc"), join(data, "opencc"), { recursive: true });
+cpSync(join(root, "tools/ime/pocket_pinyin.schema.yaml"), join(data, "pocket_pinyin.schema.yaml"));
+writeFileSync(join(data, "default.custom.yaml"), 'patch:\n schema_list:\n - schema: pocket_pinyin\n');
+run([join(prefix, "bin/rime_deployer"), "--build", data, data, join(data, "build")]);
+run(["cc", "-O2", "-Wall", "-Wextra", "-Werror", `-I${prefix}/include`, `-L${prefix}/lib`,
+ "-lrime", join(root, "tools/ime/rime.c"), "-o", join(data, "pocket-rime")]);
+writeFileSync(join(data, "sources.json"), JSON.stringify({ dependencies, schema: readFileSync(join(data, "pocket_pinyin.schema.yaml"), "utf8") }, null, 2));
+console.log(`IME engine and dictionaries: ${data}`);
diff --git a/tools/ime/text-tile.ts b/tools/ime/text-tile.ts
new file mode 100644
index 000000000..cc2bcb440
--- /dev/null
+++ b/tools/ime/text-tile.ts
@@ -0,0 +1,25 @@
+import { createCanvas } from "@napi-rs/canvas";
+
+/** One bounded canvas per connection worker; every tile uses the same baseline. */
+export function createTextTileRenderer(font = "Pocket CJK") {
+ const canvas = createCanvas(320, 64), context = canvas.getContext("2d");
+ return (payload: string) => {
+ const { text, width, size, row, bold, column = 0 } = JSON.parse(payload);
+ if (typeof text !== "string" || text.length > 256 || !Number.isInteger(width) || width < 4 || width > 320 || width % 4 ||
+ ![16, 20, 32, 40].includes(size) || ![0, 1, 2, 3].includes(row) || ![0, 1].includes(column) || typeof bold !== "boolean") throw new Error("Invalid text tile");
+ context.clearRect(0, 0, 320, 64);
+ context.font = `${bold ? "bold " : ""}${size}px "${font}"`;
+ context.fillStyle = "white";
+ // Canvas's `top` baseline can put CJK ink above y=0. Use font ascent
+ // with leading in the guest's complete line box.
+ context.textBaseline = "alphabetic";
+ const metrics = context.measureText(text);
+ const leading = Math.max(2, (size + 16 - metrics.fontBoundingBoxAscent - metrics.fontBoundingBoxDescent) / 2);
+ const baseline = Math.ceil(Math.max(metrics.fontBoundingBoxAscent, metrics.actualBoundingBoxAscent) + leading);
+ context.fillText(text, -column * 320, baseline);
+ const pixels = context.getImageData(0, row * 16, width, 16).data;
+ const mask = Buffer.alloc(width * 4);
+ for (let i = 0; i < width * 16; i++) mask[i >> 2] |= Math.round(pixels[i * 4 + 3] / 85) << ((i & 3) * 2);
+ return mask.toString("base64");
+ };
+}
diff --git a/tools/ime/verify.ts b/tools/ime/verify.ts
new file mode 100644
index 000000000..57ceae4ea
--- /dev/null
+++ b/tools/ime/verify.ts
@@ -0,0 +1,65 @@
+/** Run after ime:setup; this gate uses the deployed dictionary and native engine. */
+import { strict as assert } from "node:assert";
+import { resolve } from "node:path";
+import { RimeEngine } from "./rime.ts";
+import { IME } from "../../contracts/spec/ime.ts";
+const engine = new RimeEngine(resolve(Bun.argv[2] ?? ".pocket/ime"));
+const keys = (s: string) => Array.from(s, c => c.charCodeAt(0));
+const compose = async (input: number[]) => JSON.parse(await engine.compose(JSON.stringify(input)));
+try {
+ for (const [pinyin, expected] of [["nihao", "你好"], ["zhongwen", "中文"], ["beijing", "北京"]]) {
+ const input = keys(pinyin);
+ const snapshot = await compose(input);
+ assert.equal(snapshot.candidates[0], expected);
+ const committed = await compose([...input, IME.select]);
+ assert.equal(committed.commit, expected);
+ assert.equal(committed.preedit, "");
+ assert.deepEqual(await compose([...input, IME.select]), committed);
+ }
+ const first = await compose(keys("ni"));
+ const browse = async (offset: number) => JSON.parse(await engine.compose(JSON.stringify({ keys: keys("ni"), offset }), true));
+ const window = await browse(0), later = await browse(15);
+ assert.deepEqual(window.candidates.slice(0, 5), first.candidates);
+ assert.equal(window.candidates.length, 15); assert.equal(window.last, false);
+ assert.equal(later.offset, 15); assert.ok(later.candidates.length > 0);
+ assert.equal((await compose([...keys("ni"), IME.selectAbsolute + 15])).commit, later.candidates[0]);
+ assert.deepEqual(await compose(keys("ni")), first);
+ const second = await compose([...keys("ni"), IME.pageDown]);
+ assert.equal(second.page, 1);
+ assert.notDeepEqual(second.candidates, first.candidates);
+ assert.deepEqual(await compose([...keys("ni"), IME.pageDown, IME.pageUp]), first);
+ assert.deepEqual(await compose([...keys("nix"), IME.backspace]), first);
+ const moved = await compose([...keys("nihao"), IME.left]);
+ assert.ok(moved.caret < moved.preedit.length);
+ for (const word of ["haha", "nihao", "xi'an"]) {
+ const transcript = keys(word), original = await compose(transcript);
+ let previous = original;
+ for (let step = 0; step < word.length + 4; step++) {
+ transcript.push(IME.left);
+ const next = await compose(transcript);
+ assert.ok(next.caret <= previous.caret, `${word}: Left moved right at step ${step}`);
+ if (step < word.length) assert.ok(next.caret < previous.caret, `${word}: Left must move one input character`);
+ assert.equal(next.commit, "");
+ previous = next;
+ }
+ assert.equal(previous.caret, 0);
+ for (let step = 0; step < word.length + 4; step++) {
+ transcript.push(IME.right);
+ const next = await compose(transcript);
+ assert.ok(next.caret >= previous.caret, `${word}: Right moved left at step ${step}`);
+ if (step < word.length) assert.ok(next.caret > previous.caret, `${word}: Right must move one input character`);
+ assert.equal(next.commit, "");
+ previous = next;
+ }
+ assert.deepEqual(previous, original);
+ assert.deepEqual(await compose(transcript), previous);
+ assert.equal((await compose([...transcript, IME.enter])).commit, word);
+ }
+ const toEnd = Array(8).fill(IME.right);
+ assert.equal((await compose([...keys("haha"), IME.left, IME.left, 120, ...toEnd, IME.enter])).commit, "haxha");
+ assert.equal((await compose([...keys("haha"), IME.left, IME.backspace, ...toEnd, IME.enter])).commit, "haa");
+ assert.deepEqual(await compose([IME.left, IME.right]), await compose([]));
+ assert.equal((await compose([...keys("nihao"), IME.enter])).commit, "nihao");
+ assert.equal((await compose([...keys("nihao"), 32])).commit, "你好");
+ console.log("Rime acceptance passed: phrases, selection, replay, paging, read-only windows, absolute selection, deletion, bounded character caret, raw commit, space");
+} finally { engine.close(); }
diff --git a/tools/ime/worker.ts b/tools/ime/worker.ts
new file mode 100644
index 000000000..a7d30c16f
--- /dev/null
+++ b/tools/ime/worker.ts
@@ -0,0 +1,24 @@
+import { dispatchOffload } from "../offload-provider.ts";
+import { validImeKeys, validImeBrowse } from "../../contracts/spec/ime.ts";
+import { GlobalFonts } from "@napi-rs/canvas";
+import { createTextTileRenderer } from "./text-tile.ts";
+import { createTextProvider } from "../text-provider.ts";
+declare const self: Worker;
+let enginePort = 0, engineToken = "";
+async function compose(payload: string, browse = false) {
+ if (!(browse ? validImeBrowse : validImeKeys)(JSON.parse(payload))) throw new Error("Invalid IME transcript");
+ const response = await fetch(`http://127.0.0.1:${enginePort}/${browse ? "candidates" : "compose"}`, {
+ method: "POST", headers: { authorization: engineToken }, body: payload, signal: AbortSignal.timeout(6000),
+ });
+ if (!response.ok) throw new Error("IME engine unavailable");
+ return response.text();
+}
+const textTile = createTextTileRenderer();
+let textProvider: ReturnType;
+// One serial capability queue owns this worker's canvas. The supervisor owns
+// Rime; per-connection workers hold its authenticated loopback address.
+let pending = Promise.resolve();
+self.onmessage = event => {
+ if (event.data.init) { if (!GlobalFonts.registerFromPath(event.data.init.font, "Pocket CJK")) throw new Error("CJK font unavailable"); textProvider = createTextProvider(event.data.init.font); enginePort = event.data.init.enginePort; engineToken = event.data.init.engineToken; return; }
+ pending = pending.then(async () => self.postMessage(await dispatchOffload({ "ime.compose": compose, "ime.candidates": p => compose(p, true), "text.tile": textTile, ...textProvider }, event.data)));
+};
diff --git a/tools/ipodtouch4-profile.ts b/tools/ipodtouch4-profile.ts
index a6402fae8..6db281973 100644
--- a/tools/ipodtouch4-profile.ts
+++ b/tools/ipodtouch4-profile.ts
@@ -36,7 +36,7 @@ export const IPODTOUCH4_DEV_CONTRACTS = definePlatformContractRegistry(
presentations: ["native"],
rasterDensity: IPODTOUCH4_RASTER_DENSITY,
},
- capabilities: ["input.touch", "text.glyphs.baked"],
+ capabilities: ["input.touch", "text.glyphs.baked", "io.offload"],
},
}),
);
diff --git a/tools/ipodtouch4.ts b/tools/ipodtouch4.ts
index 747ac2950..135d75105 100644
--- a/tools/ipodtouch4.ts
+++ b/tools/ipodtouch4.ts
@@ -116,7 +116,7 @@ export const IPODTOUCH4_APPS: Readonly> = {
receiptSlug: "pocketjs-ipodtouch4",
actionName: ACTION_NAME,
svcWire: false,
- keepAwake: false,
+ keepAwake: true,
},
};
@@ -700,6 +700,9 @@ async function build(): Promise {
// so it needs the same switch as the guest runtime.
...(APP.svcWire ? ["-DPOCKET_SVC_WIRE"] : []),
];
+ const offloadDefines = ["-DPOCKET_OFFLOAD_POSIX", "-I", join(REPOSITORY, "hosts/shared")];
+ const offloadObject = join(nativeBuild, "offload_posix.o");
+ compile(join(REPOSITORY, "hosts/shared/offload_posix.c"), offloadObject, [...warnings, ...offloadDefines]);
const svcWireDefines = APP.svcWire ? ["-DPOCKET_SVC_WIRE", "-I", join(REPOSITORY, "hosts/ios-legacy")] : [];
const crtGlobalsObject = join(nativeBuild, "crt_globals.o");
const runtimeIdentityObject = join(nativeBuild, "runtime.build-id-input.o");
@@ -716,6 +719,7 @@ async function build(): Promise {
compile(join(REPOSITORY, "engine/quickjs-c/pocket_runtime.c"), pocketRuntimeObject, [
...warnings,
...svcWireDefines,
+ ...offloadDefines,
`-DPOCKETJS_TARGET_ID=\"${inputs.target}\"`,
`-DPOCKETJS_HOST_ABI=${inputs.hostAbi}`,
`-DPOCKET_RASTER_DENSITY=${inputs.viewport.rasterDensity}`,
@@ -755,6 +759,7 @@ async function build(): Promise {
{ label: "native/crt_globals.o", path: crtGlobalsObject },
{ label: "native/runtime.build-id-input.o", path: runtimeIdentityObject },
{ label: "native/pocket_runtime.o", path: pocketRuntimeObject },
+ { label: "native/offload_posix.o", path: offloadObject },
...(APP.svcWire ? [{ label: "native/svcwire.o", path: svcWireObject }] : []),
{ label: "native/compat.o", path: compatObject },
...quickJsObjects.map((path) => ({ label: `native/${path.slice(nativeBuild.length + 1)}`, path })),
@@ -782,7 +787,7 @@ async function build(): Promise {
"-no_source_version", "-no_compact_unwind", "-no_adhoc_codesign", "-no_encryption",
"-e", "start", "-o", executable, join(nativeBuild, "csu-start.o"),
join(nativeBuild, "csu-dyld-glue.o"), crtGlobalsObject,
- runtimeObject, pocketRuntimeObject, ...(APP.svcWire ? [svcWireObject] : []), compatObject,
+ runtimeObject, pocketRuntimeObject, offloadObject, ...(APP.svcWire ? [svcWireObject] : []), compatObject,
"-force_load", rustLibrary, ...quickJsObjects,
"-sectcreate", "__DATA", "__pocket_js", embeddedJavaScript,
"-sectcreate", "__DATA", "__pocket_pak", guestPak,
diff --git a/tools/moto-g-play-profile.ts b/tools/moto-g-play-profile.ts
new file mode 100644
index 000000000..8ad4fa598
--- /dev/null
+++ b/tools/moto-g-play-profile.ts
@@ -0,0 +1,15 @@
+import { POCKET_CAPABILITIES, definePlatformContractRegistry, defineTargetRegistry } from "../contracts/spec/platforms.ts";
+import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts";
+export const MOTO_G_PLAY_TARGET = "moto-g-play-dev";
+export const MOTO_G_PLAY_CONTRACTS = definePlatformContractRegistry(POCKET_CAPABILITIES, defineTargetRegistry({
+ [MOTO_G_PLAY_TARGET]: {
+ platform: "android", form: "takeover", hostAbi: 9,
+ display: { physicalViewport: [720, 1600], logicalViewports: [[360, 800]], presentations: ["native"], rasterDensity: 2 },
+ capabilities: ["input.buttons", "input.touch", "text.glyphs.baked", "io.offload"],
+ },
+}));
+export function resolveMotoGPlayBuildPlan(input: unknown) {
+ const result = validateAndResolveBuildPlan(input, { target: MOTO_G_PLAY_TARGET }, MOTO_G_PLAY_CONTRACTS);
+ if (!result.ok) throw new Error(result.diagnostics.map(d => `${d.path}: ${d.message}`).join("; "));
+ return result.plan;
+}
diff --git a/tools/moto-g-play.ts b/tools/moto-g-play.ts
new file mode 100644
index 000000000..afb344215
--- /dev/null
+++ b/tools/moto-g-play.ts
@@ -0,0 +1,47 @@
+/** Exact-device operations for the arm64 Clear development host. */
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { createHash } from "node:crypto";
+const root = resolve(import.meta.dir, "..");
+const args = Bun.argv.slice(2), command = args.find(a => !a.startsWith("--")) ?? "doctor";
+const id = args.find(a => a.startsWith("--id="))?.slice(5) ?? process.env.POCKETJS_MOTO_G_PLAY_SERIAL;
+const packageId = "dev.pocket_stack.clear", activity = `${packageId}/dev.pocketstack.android.PocketActivity`;
+const output = resolve(root, "dist/moto-g-play");
+function adb(args: string[]) {
+ const result = Bun.spawnSync(["adb", "-s", id!, ...args], { stdout: "pipe", stderr: "pipe" });
+ if (result.exitCode) throw new Error(result.stderr.toString());
+ return result.stdout;
+}
+if (["setup", "doctor", "build", "build-demo", "build-app"].includes(command)) {
+ const child = Bun.spawn([process.execPath, resolve(import.meta.dir, "android.ts"), "--profile=moto-g-play", command],
+ { cwd: root, stdout: "inherit", stderr: "inherit" });
+ process.exit(await child.exited);
+}
+if (!id || !/^[A-Za-z0-9-]+$/.test(id)) throw new Error("Use --id= to select the Moto G Play");
+if (adb(["shell", "getprop", "ro.product.device"]).toString().trim() !== "fogona") throw new Error("Expected Moto G Play 2024 (fogona)");
+mkdirSync(output, { recursive: true });
+switch (command) {
+ case "deploy": {
+ const apk = resolve(output, "pocket-clear.apk");
+ const hash = createHash("sha256").update(readFileSync(apk)).digest("hex");
+ console.log(adb(["install", "-r", apk]).toString().trim());
+ const installed = adb(["shell", "pm", "path", packageId]).toString().trim().replace(/^package:/, "");
+ if (!/^\/data\/app\/[A-Za-z0-9_./=+~-]+\.apk$/.test(installed)) throw new Error("Unexpected installed APK path");
+ const readback = adb(["shell", "sha256sum", installed]).toString().split(/\s/)[0];
+ if (readback !== hash) throw new Error("Installed APK hash differs from build");
+ writeFileSync(resolve(output, "device-install.json"), JSON.stringify({ device: id, packageId, apkSha256: hash, readback: true }, null, 2));
+ console.log(`Installed APK SHA-256 verified: ${hash}`);
+ break;
+ }
+ case "launch":
+ console.log(adb(["shell", "am", "start", "-n", activity]).toString().trim());
+ break;
+ case "status":
+ console.log(adb(["shell", "run-as", packageId, "cat", "files/runtime.txt"]).toString().trim());
+ break;
+ case "capture":
+ writeFileSync(resolve(output, "device-frame.png"), adb(["exec-out", "screencap", "-p"]));
+ console.log(resolve(output, "device-frame.png"));
+ break;
+ default: throw new Error("Usage: bun moto-g-play [--id=]");
+}
diff --git a/tools/test.ts b/tools/test.ts
index 15896393a..cfa626eec 100644
--- a/tools/test.ts
+++ b/tools/test.ts
@@ -81,11 +81,18 @@ const SUITE: readonly Stage[] = [
"tests/gesture.test.ts",
"tests/kinetics.test.ts",
"tests/osk-controller.test.ts",
+ "tests/clear-keyboard-touch.test.ts",
"tests/audio.test.ts",
"tests/db.test.ts",
"tests/fs.test.ts",
"tests/net.test.ts",
"tests/offload.test.ts",
+ "tests/offload-posix.test.ts",
+ "tests/ime.test.ts",
+ "tests/ime-text-tile.test.ts",
+ "tests/text.test.ts",
+ "tests/clear-candidate-panel.test.ts",
+ "tests/moto-g-play-profile.test.ts",
"tests/companion-session.test.ts",
"tests/resource-cache.test.ts",
"tests/net-web.test.js",
@@ -137,7 +144,7 @@ const SUITE: readonly Stage[] = [
name: "clear journeys",
prep: [["bun", "tools/build.ts", "clear-main", "--framework=vue-vapor"]],
browser: true,
- tests: ["tests/clear.test.ts"],
+ tests: ["tests/clear.test.ts", "tests/clear-ime-loading.test.ts"],
},
{
name: "octane smoke",
diff --git a/tools/text-provider.ts b/tools/text-provider.ts
new file mode 100644
index 000000000..cfdf606ef
--- /dev/null
+++ b/tools/text-provider.ts
@@ -0,0 +1,36 @@
+import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
+import { createHash } from "node:crypto";
+import { readFileSync } from "node:fs";
+import { TEXT, validTextGlyph, type TextGlyph } from "../contracts/spec/text.ts";
+
+/** The worker owns font I/O and rasterization. No IME or app dependency. */
+export function createTextProvider(path: string) {
+ const id = createHash("sha256").update(`pocket-scalar-coverage-${TEXT.rasterizerRevision}\0`).update(readFileSync(path)).digest("hex");
+ const family = `Pocket-${id}`;
+ if (!GlobalFonts.registerFromPath(path, family)) throw new Error("Text font unavailable");
+ const context = createCanvas(TEXT.maxWidth, TEXT.maxHeight).getContext("2d");
+ return {
+ "text.font": () => JSON.stringify({ id, mapping: "scalar" }),
+ "text.glyph": (payload: string) => {
+ const v = JSON.parse(payload);
+ if (!validTextGlyph(v) || v.face !== id) throw new Error("Invalid text glyph");
+ context.clearRect(0, 0, TEXT.maxWidth, TEXT.maxHeight);
+ const pixels = v.size * v.density;
+ context.font = `${v.bold ? "bold " : ""}${pixels}px "${family}"`;
+ context.textBaseline = "alphabetic"; context.fillStyle = "white";
+ const m = context.measureText(v.text), lineHeight = (v.size + 8) * v.density;
+ const leading = Math.max(2, (lineHeight - m.fontBoundingBoxAscent - m.fontBoundingBoxDescent) / 2);
+ const baseline = Math.ceil(Math.max(m.fontBoundingBoxAscent, m.actualBoundingBoxAscent) + leading);
+ const xoff = Math.ceil(Math.max(0, m.actualBoundingBoxLeft)) + 2;
+ const width = Math.ceil((Math.max(m.width, m.actualBoundingBoxRight) + xoff + 2) / 4) * 4;
+ const height = Math.max(16, 2 ** Math.ceil(Math.log2(lineHeight)));
+ if (width > TEXT.maxWidth || height > TEXT.maxHeight || baseline + m.actualBoundingBoxDescent > lineHeight)
+ throw new Error("Glyph exceeds coverage bounds");
+ context.fillText(v.text, xoff, baseline);
+ const rgba = context.getImageData(0, 0, width, height).data, mask = Buffer.alloc(width * height / 4);
+ for (let i = 0; i < width * height; i++) mask[i >> 2] |= Math.round(rgba[i * 4 + 3] / 85) << ((i & 3) * 2);
+ const result: TextGlyph = { face: id, advance: m.width / v.density, xoff: xoff / v.density, width, height, mask: mask.toString("base64") };
+ return JSON.stringify(result);
+ },
+ };
+}
From c3a8a7a16fdd9613568efed69bbd52ba6c3a8ef0 Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang"
Date: Fri, 11 Sep 2026 01:54:57 -0700
Subject: [PATCH 2/7] docs(workflow): keep validation artifacts out of Git
(#406)
(cherry picked from commit b827e68b2efe331ffd9a23b9da8ff0395fabcd5a)
---
CLAUDE.md | 4 ++++
skills/pocketjs-devtools/SKILL.md | 17 ++++++++++++-----
skills/pocketjs-review-pr/SKILL.md | 29 ++++++++++++++++++++++++-----
3 files changed, 40 insertions(+), 10 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 916dc4814..045a5cdc0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3,6 +3,10 @@
- After completing and validating a code or documentation change, publish it as a draft pull request before treating the work as ready for review or merge.
- If the user also asks to merge the change, open the draft pull request first, then mark it ready and merge it after the relevant checks pass.
- Name pull requests (and the branch's primary commit) using the Conventional Commits format — `type(scope): summary`, e.g. `feat(gallery): …`, `fix: …`, `docs: …`, `refactor: …`.
+- Keep per-run validation screenshots, videos, raw logs, traces, benchmark dumps, and build/install receipts in ignored `.pocket-build/validation///` output or an artifact store. Device validation does not require committing these files to Git.
+- Put reproducible commands, results, build identities, and acceptance limits in the PR description. Attach a small selection of relevant images to the PR; do not add a new source directory for each validation run.
+- Commit an image or recording when a test consumes it as a maintained fixture, or when it is an intentional product/documentation asset with an identified consumer. Temporary debugging output and historical screenshots are not test fixtures merely because they are called evidence.
+- Before staging, inspect the file list and remove unintended validation artifacts. Preserve needed originals outside Git, and remove stale documentation links when cleaning up generated records. Do not infer a requirement to commit artifacts from a previous session's actions or a memory summary; apply the user's current instructions and these repository rules.
- Keep PocketJS examples explicit about API ownership: import PocketJS runtime, host components, lifecycle, input, and animation APIs from `@pocketjs/framework/*`; import Solid primitives and control flow directly from `solid-js`.
- Pocket Vapor apps take incremental input only through the hardware-neutral `RelativeAxis`/`onAxisDelta` contract in `vapor/host/input.ts` — never device SDK concepts in app code, never crank motion encoded as fake buttons (details: `vapor/DESIGN.md` §5).
- Documentation prose (`site/content/docs/`, `docs/`) states the mechanism directly and bolds concrete engineering facts, never slogans. No meta-framing of the concept system ("ontology", "philosophy", "N nouns and one relation"), no imported architecture jargon ("vertical slice", "algebra" for an API, "first-class citizen"), no personification or dramatic one-liners, no empty intensifiers ("simply", "elegant", "magic"). No adverbs modifying a verb or adjective ("simply", "just", "actually", "typically", "carefully", "silently", "properly") — delete the adverb, or replace it with the fact it was standing in for; prepositional phrases that carry a mechanism ("once per frame", "at the down edge") are facts, not adverbs, and stay. Register reference: `site/content/docs/architecture.md` and `site/content/docs/native-contract.md`. The blog keeps its own separate voice (first-person essays); this rule is for reference documentation.
diff --git a/skills/pocketjs-devtools/SKILL.md b/skills/pocketjs-devtools/SKILL.md
index 04a431036..5dab19cd0 100644
--- a/skills/pocketjs-devtools/SKILL.md
+++ b/skills/pocketjs-devtools/SKILL.md
@@ -19,12 +19,15 @@ answerable from the terminal. Use the panel when a human is co-driving.
## Headless workflow (no screen needed)
+Keep per-run tapes, hashes, captures, and logs in ignored output. For example:
+
```bash
-bun run tape record hero-main --frames 180 --input "5:64,40:8192" --out t.json
-bun run tape replay hero-main t.json --hashes h.json # per-frame FNV hashes
-bun run tape replay hero-main t.json --assert h.json # exit 1 + FIRST DIVERGENT FRAME
-bun run tape replay hero-main t.json --png 60,120 # render frames to PNG (read them!)
-bun run tape tree hero-main t.json --at 60 # component tree JSON at frame 60
+mkdir -p .pocket-build/validation/devtools
+bun run tape record hero-main --frames 180 --input "5:64,40:8192" --out .pocket-build/validation/devtools/t.json
+bun run tape replay hero-main .pocket-build/validation/devtools/t.json --hashes .pocket-build/validation/devtools/h.json
+bun run tape replay hero-main .pocket-build/validation/devtools/t.json --assert .pocket-build/validation/devtools/h.json --outdir .pocket-build/validation/devtools
+bun run tape replay hero-main .pocket-build/validation/devtools/t.json --png 60,120 --outdir .pocket-build/validation/devtools
+bun run tape tree hero-main .pocket-build/validation/devtools/t.json --at 60
bun run tape:check # committed session golden
```
@@ -37,6 +40,10 @@ bun run tape:check # committed session gol
replay is then an approximation (warned automatically).
- Committed session goldens live in `tests/tapes/`; regenerate hashes only when
a visual change is intended.
+- A replay capture or exported session is temporary validation output. Promote
+ it to a committed fixture only when a named regression test consumes it.
+ Put selected screenshots in PR attachments and keep raw captures, logs and
+ receipts outside the tracked tree, following `AGENTS.md`/`CLAUDE.md`.
## Panel workflow (one command)
diff --git a/skills/pocketjs-review-pr/SKILL.md b/skills/pocketjs-review-pr/SKILL.md
index d5dc1ccf3..95f4a345e 100644
--- a/skills/pocketjs-review-pr/SKILL.md
+++ b/skills/pocketjs-review-pr/SKILL.md
@@ -7,10 +7,12 @@ description: Review a community pull request on this repo to a merge verdict —
## Overview
-There is no test CI on this repo — `.github/workflows/` is deploy, esp32p4, and
-release only, so `gh pr checks` reports "no checks reported" for every PR. That
-is not a green light: **the local run is the gate**, and the verdict is yours to
-produce. Two habits carry the review:
+Read the current `.github/workflows/` triggers and the PR's check results.
+Path-filtered workflows may leave a documentation-only PR with no reported
+checks; that is neither a passed check nor a failed check. Run the local checks
+appropriate to the changed files and require applicable remote checks before
+merge. For code changes, the curated local suite is the gate. Two habits carry
+the review:
1. **Reproduce, don't read.** Run the bug on `main` and the fix on the branch
over an input matrix wider than the PR's own test. A PR description is a
@@ -28,7 +30,7 @@ produce. Two habits carry the review:
gh pr view --json number,title,body,author,isDraft,baseRefName,headRefName,\
additions,deletions,changedFiles,mergeable,mergeStateStatus,isCrossRepository,maintainerCanModify
gh pr diff
-gh pr checks # "no checks reported" is normal here
+gh pr checks # compare missing checks with current workflow filters
```
2. **Get the branch locally without touching `main`.** Superset worktrees can't
@@ -104,6 +106,8 @@ derives `preserveComments` from it, so the two settings must agree).
7. **Run the gate and prove any mechanism empirically.**
+For code changes:
+
```bash
rm -f dist/*.js dist/*.pak # dist bundles are target-flavored
bun run test # the curated gate; report pass/fail counts
@@ -117,6 +121,21 @@ need re-running, not repeating. For a cache-key or build-key change, prove it:
cover, rebuild, confirm every entry re-keyed, revert, confirm the original keys
come back.
+For documentation and instruction changes, check links, referenced commands,
+symlink targets, whitespace, and the final file list. Confirm that code and
+test fixtures are unchanged; do not rerun device deployments or broad runtime
+tests merely to validate prose.
+
+Keep per-run screenshots, logs, traces, benchmark dumps, and receipts in
+ignored `.pocket-build/validation///` output or an artifact store.
+The PR should contain a concise validation summary and selected attachments.
+Before committing, inspect `git diff --cached --name-status`: a new image or
+recording needs an identified test consumer or maintained product/documentation
+purpose. A local capture is not a fixture merely because it records a test.
+Remove stale links when moving temporary artifacts out of the tracked tree.
+The repository's `AGENTS.md`/`CLAUDE.md` rules govern artifact retention;
+historical session behavior does not create a new requirement to commit files.
+
8. **Push the fixes onto the contributor's branch.**
```bash
From 51fabb471934b4538e80cb9db3295d63b7f8950d Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Fri, 11 Sep 2026 01:56:31 -0700
Subject: [PATCH 3/7] docs(clear): align validation output with repository
policy
---
docs/CLEAR_IME.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/CLEAR_IME.md b/docs/CLEAR_IME.md
index 23fdd1817..4f82a454e 100644
--- a/docs/CLEAR_IME.md
+++ b/docs/CLEAR_IME.md
@@ -143,4 +143,4 @@ To repeat device acceptance after building, installing and starting the companio
**Static panels and sustained scrolling require separate timing runs.** On iPod, start from a fresh launch, compose `ni`, expand the panel, wait four seconds, then drag 140 logical points over eight seconds in each direction. Sample device status before taking captures. Use distinct 60-frame heartbeat windows with `touch_down=1`; compute delivered FPS as `window_frames × 1,000,000 / window_us`. `frame_us` measures the guest/core frame before presentation, and `submit_us` measures GL submission. Keep first-pass and reverse-pass results separate. Window means do not establish frame-time percentiles or physical-finger response times.
-Capture commands write to ignored `dist/` output. Keep per-run screenshots, logs and device receipts under `.pocket-build/clear-validation/`; attach selected images and a validation summary to the PR. Versioned image fixtures belong with the tests that consume them. iPod capture reads the app's rendered frame; Android capture reads the device display. GraphicsServices and ADB input exercise native input routes; physical-finger testing remains a separate check.
+Capture commands write to ignored `dist/` output. Keep per-run screenshots, logs and device receipts under `.pocket-build/validation/clear//`; attach selected images and a validation summary to the PR. Versioned image fixtures belong with the tests that consume them. iPod capture reads the app's rendered frame; Android capture reads the device display. GraphicsServices and ADB input exercise native input routes; physical-finger testing remains a separate check.
From 07b9fa55946ffe0760a7cb47532eeeccf17644b6 Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Sun, 13 Sep 2026 17:14:06 -0700
Subject: [PATCH 4/7] fix(ime): preserve input order and recover contact and
glyph lifetimes
---
.github/workflows/native-c-harness.yml | 10 ++
.github/workflows/resources.yml | 16 +-
apps/clear/editor.ts | 12 +-
docs/CLEAR_IME.md | 4 +-
docs/TEXT_RESOURCES.md | 2 +-
engine/quickjs-c/pocket_runtime.h | 2 +-
framework/src/ime.ts | 193 +++++++++++++++----------
framework/src/resource-cache.ts | 22 ++-
framework/src/text.ts | 1 +
hosts/android/app/jni/runtime.c | 30 +---
hosts/shared/contact_latch.h | 84 +++++++++++
tests/contact-latch.test.ts | 16 ++
tests/fixtures/contact-latch.c | 71 +++++++++
tests/ime.test.ts | 66 ++++++++-
tests/resource-cache.test.ts | 19 +++
tests/text.test.ts | 24 ++-
tools/ime/verify.ts | 28 +++-
tools/test.ts | 1 +
18 files changed, 481 insertions(+), 120 deletions(-)
create mode 100644 hosts/shared/contact_latch.h
create mode 100644 tests/contact-latch.test.ts
create mode 100644 tests/fixtures/contact-latch.c
diff --git a/.github/workflows/native-c-harness.yml b/.github/workflows/native-c-harness.yml
index 61ac92795..21dabd23e 100644
--- a/.github/workflows/native-c-harness.yml
+++ b/.github/workflows/native-c-harness.yml
@@ -5,6 +5,10 @@ on:
paths:
- ".github/workflows/native-c-harness.yml"
- "engine/quickjs-c/**"
+ - "hosts/shared/contact_latch.h"
+ - "hosts/android/**"
+ - "tests/contact-latch.test.ts"
+ - "tests/fixtures/contact-latch.c"
- "engine/ui-cabi/**"
- "engine/core/**"
- "framework/src/**"
@@ -25,6 +29,10 @@ on:
paths:
- ".github/workflows/native-c-harness.yml"
- "engine/quickjs-c/**"
+ - "hosts/shared/contact_latch.h"
+ - "hosts/android/**"
+ - "tests/contact-latch.test.ts"
+ - "tests/fixtures/contact-latch.c"
- "engine/ui-cabi/**"
- "engine/core/**"
- "framework/src/**"
@@ -70,6 +78,8 @@ jobs:
run: bun tools/build.ts hero
- name: Renderer and runtime stage/dispatcher contracts
run: bun test --conditions=browser tests/quickjs-c-harness.test.ts tests/renderer.test.ts tests/virtual-list.test.ts tests/vue-vapor-dom.test.ts
+ - name: Native contact lifetimes and cancellation
+ run: bun test tests/contact-latch.test.ts
- name: UI singleton access and alignment policy
run: cargo test --locked --manifest-path engine/ui-cabi/Cargo.toml --features harness-access
- name: Link and execute the real C allocator
diff --git a/.github/workflows/resources.yml b/.github/workflows/resources.yml
index bf6bd316d..9c6f16aea 100644
--- a/.github/workflows/resources.yml
+++ b/.github/workflows/resources.yml
@@ -4,6 +4,13 @@ on:
paths:
- 'framework/src/resource*.ts'
- 'framework/src/offload.ts'
+ - 'framework/src/ime.ts'
+ - 'framework/src/text.ts'
+ - 'contracts/spec/ime.ts'
+ - 'contracts/spec/text.ts'
+ - 'tools/text-glyph-provider.ts'
+ - 'tests/ime.test.ts'
+ - 'tests/text.test.ts'
- 'framework/src/keyboard-touch.ts'
- 'framework/src/indexed-image.ts'
- 'framework/src/frame.ts'
@@ -21,6 +28,13 @@ on:
paths:
- 'framework/src/resource*.ts'
- 'framework/src/offload.ts'
+ - 'framework/src/ime.ts'
+ - 'framework/src/text.ts'
+ - 'contracts/spec/ime.ts'
+ - 'contracts/spec/text.ts'
+ - 'tools/text-glyph-provider.ts'
+ - 'tests/ime.test.ts'
+ - 'tests/text.test.ts'
- 'framework/src/keyboard-touch.ts'
- 'framework/src/indexed-image.ts'
- 'framework/src/frame.ts'
@@ -45,4 +59,4 @@ jobs:
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
- - run: bun test --conditions=browser tests/resource-cache.test.ts tests/resource-view.test.ts tests/resource.test.ts tests/offload.test.ts tests/indexed-image.test.ts tests/keyboard-touch.test.ts
+ - run: bun test --conditions=browser tests/resource-cache.test.ts tests/resource-view.test.ts tests/resource.test.ts tests/offload.test.ts tests/indexed-image.test.ts tests/keyboard-touch.test.ts tests/ime.test.ts tests/text.test.ts
diff --git a/apps/clear/editor.ts b/apps/clear/editor.ts
index 1f51a8971..2a9a54acb 100644
--- a/apps/clear/editor.ts
+++ b/apps/clear/editor.ts
@@ -129,9 +129,16 @@ export function makeEditor(host: EditorHost): Editor {
editCaret -= count;
paintEditRow();
}
+ function moveCaret(direction: number): void {
+ if (!editing) return;
+ if (direction < 0) editCaret -= Array.from(editing.text.slice(0, editCaret)).pop()?.length ?? 0;
+ else editCaret += Array.from(editing.text.slice(editCaret))[0]?.length ?? 0;
+ paintEditRow();
+ }
const ime = hasCompanion() ? createIme({
changed: state => kb.setIme(state, chinese),
commit: insert,
+ edit: action => { if (action === "backspace") backspace(); else moveCaret(action === "left" ? -1 : 1); },
}) : undefined;
const kb = makeKeyboard({
onInsert(ch) {
@@ -155,10 +162,7 @@ export function makeEditor(host: EditorHost): Editor {
onCancelComposition() { ime?.reset(); closeAfterComposition = false; },
onCaret(direction) {
if (ime?.composing()) { ime.key(direction < 0 ? IME.left : IME.right); return; }
- if (!editing) return;
- if (direction < 0) editCaret -= Array.from(editing.text.slice(0, editCaret)).pop()?.length ?? 0;
- else editCaret += Array.from(editing.text.slice(editCaret))[0]?.length ?? 0;
- paintEditRow();
+ moveCaret(direction);
},
});
function step() {
diff --git a/docs/CLEAR_IME.md b/docs/CLEAR_IME.md
index 076d86397..5a50b4edf 100644
--- a/docs/CLEAR_IME.md
+++ b/docs/CLEAR_IME.md
@@ -23,7 +23,7 @@ The v1 offload contract bounds records to **4,096 bytes**, pending requests to *
- `ime.candidates`: read a window of at most 15 candidates without changing the composition transcript. The guest retains at most 512 candidates and fences windows by revision.
- `text.font` and `text.glyph`: identify the font rendition and return reusable scalar glyph metrics and coverage. The framework owns the cache and uploads. `text.tile` remains available for older guests.
-**Rime evaluates each transcript in a fresh session with user learning disabled.** Replaying the same input therefore avoids repeated dictionary mutations. The guest fences replies by editor revision, retains its transcript during disconnection and applies the new suffix of the cumulative commit once. **Raw input remains editable on the device.** A validated reply refreshes raw input after conversion or a partial commit; generated preedit spacing never becomes source text. Closing or cancelling the editor rejects pending commits. This replay policy belongs to IME; it does not change the transport's no-replay policy for sent mutations such as terminal input.
+**Rime evaluates each transcript in a fresh session with user learning disabled.** Replaying the same input therefore avoids repeated dictionary mutations. The guest fences replies by composition revision, retains its transcript during disconnection and applies the new suffix of the cumulative commit once. **Confirmation seals an input segment.** Later typing enters a new segment; a single compose request serves the queue head. Replies can finish a sealed prefix while its suffix changes. Every confirmed segment retains its own 400 ms deadline, and the queue shares a 128-action budget. Reconnection replays the head before advancing to the next segment. Committed-text deletion and caret actions at a sealed boundary wait in the same queue and reach the editor through the `edit` callback; they cannot overtake conversion. **Raw input remains editable on the device.** A validated reply refreshes raw input after conversion or a partial commit; generated preedit spacing never becomes source text. Closing or cancelling the editor rejects pending commits. This replay policy belongs to IME; it does not change the transport's no-replay policy for sent mutations such as terminal input.
The native API comes from [librime](https://github.com/rime/librime/blob/master/src/rime_api.h). `tools/ime/setup.ts` pins the Luna Pinyin, Prelude and Essay dictionary revisions. Schema compilation, dictionary storage, OpenCC and system CJK font access stay on the Mac. System fonts and dictionary artifacts are not packaged into the applications.
@@ -79,7 +79,7 @@ bun ipodtouch4 capture
## Moto G Play 2024
-The tested device is `fogona`, Android 14, with a **720×1600 physical** display. The host uses arm64, GLES2, QuickJS-C, the shared Rust renderer, native multi-touch and a **360×800 logical** viewport. Clear's keyboard follows the viewport width; gestures continue through the shared input contracts.
+The tested device is `fogona`, Android 14, with a **720×1600 physical** display. The host uses arm64, GLES2, QuickJS-C, the shared Rust renderer, native multi-touch and a **360×800 logical** viewport. **Platform pointer IDs and guest contact IDs have separate lifetimes.** The native latch holds at most eight contacts, assigns a guest ID at each down edge, and reserves IDs still present in the previous snapshot. An up followed by a reused pointer ID creates a new contact. Ordinary taps between frames latch one down sample; cancellation removes an unsampled contact without a synthetic press. Pause cancellation clears all contacts. Clear's keyboard follows the viewport width; gestures continue through the shared input contracts.
Install Android command-line tools, Java 17 and Rust. Enable USB debugging and authorize this Mac on the device. The default SDK is `/opt/homebrew/share/android-commandlinetools`; `POCKETJS_ANDROID_SDK_ROOT` selects another location. `JAVA_HOME` selects Java 17. The setup command installs platform 34, build tools 35.0.0, NDK 27.1.12297006, the pinned QuickJS revision and the Rust target. Install the pinned Rust toolchain before setup:
diff --git a/docs/TEXT_RESOURCES.md b/docs/TEXT_RESOURCES.md
index cdf222640..0397af515 100644
--- a/docs/TEXT_RESOURCES.md
+++ b/docs/TEXT_RESOURCES.md
@@ -17,7 +17,7 @@
**A text change updates positions before requesting resources.** Latin spans use the core's baked font metrics and text nodes. Other scalar values use cached advances and coverage. Deleting a resident Han character, moving the caret, or reordering resident Han characters requires no new raster request. A cache miss affects that cell; existing Latin and Han content stays visible. Color and container alignment are presentation state.
-The companion exposes `text.font` and `text.glyph` through `tools/text-glyph-provider.ts`. `text.font` returns an identity derived from the font contents and rasterizer revision, alongside the declared `scalar` mapping. Glyph identity includes that face, scalar value, logical size, weight and raster density. The first valid face response admits requests. Reconnection verifies the face identity while resident coverage remains usable offline. A changed face creates different cache keys.
+The companion exposes `text.font` and `text.glyph` through `tools/text-glyph-provider.ts`. `text.font` returns an identity derived from the font contents and rasterizer revision, alongside the declared `scalar` mapping. Glyph identity includes that face, scalar value, logical size, weight and raster density. The first valid face response admits requests. Reconnection verifies the face identity while resident coverage remains usable offline. After the font handshake, `retryFailed()` grants failed glyph reads a new retry budget for that face, including failures during texture upload. Healthy resident entries keep their handles and layout identity. Retries remain bounded within each connection session. A changed face creates different cache keys.
**One `createResourceScheduler` owns glyph work per realm.** It allows two concurrent reads, one new read per frame and one materialization per frame. The cache admits at most 96 glyphs and reserves at most 32 KiB per glyph before allocation. Visible layouts pin their demands; the editor and candidate viewport have priority over surrounding rows. Clear supplies its row viewport with one row of overscan. Completed entries remain cached after a label releases them, subject to eviction.
diff --git a/engine/quickjs-c/pocket_runtime.h b/engine/quickjs-c/pocket_runtime.h
index d4cd4068a..a9f7fbae5 100644
--- a/engine/quickjs-c/pocket_runtime.h
+++ b/engine/quickjs-c/pocket_runtime.h
@@ -49,7 +49,7 @@ typedef struct {
int pocket_runtime_tick(const PocketRuntimeInput *input);
/*
- * Multi-contact frame entry. `id` is the host's contact slot (0-255, stable
+ * Multi-contact frame entry. `id` is a host-owned contact identity (0-255, stable
* while the finger stays down, reusable after release), `x`/`y` are logical
* pixels, and `hit` is the bounds hit resolved once at the contact's down
* edge (pocket_runtime_hit_test_bounds) or zero. Contacts pack into the
diff --git a/framework/src/ime.ts b/framework/src/ime.ts
index 2145bd7a4..ab1392a5f 100644
--- a/framework/src/ime.ts
+++ b/framework/src/ime.ts
@@ -6,81 +6,128 @@ export type { ImeSnapshot, ImeCandidatePage };
type Channel = Pick, "request" | "cancel" | "session">;
export type ImeState = ImeSnapshot & { pending: boolean; connected: boolean; error: string; revision: number; composing: boolean };
const empty = (): ImeSnapshot => ({ preedit: "", commit: "", candidates: [], page: 0, last: true, caret: 0, raw: "", rawCaret: 0 });
+export type ImeEdit = "backspace" | "left" | "right";
+type Composition = {
+ edit?: ImeEdit;
+ keys: number[]; raw: string; caret: number; snapshot: ImeSnapshot; applied: string;
+ version: number; dirty: boolean; error: string; retry: number; confirmAt?: number;
+};
+const composition = (): Composition => ({ keys: [], raw: "", caret: 0, snapshot: empty(), applied: "",
+ version: 0, dirty: false, error: "", retry: 0 });
-/** One editor owns one bounded transcript. A reconnect recomputes the current
- * transcript, and revision checks fence callbacks from a closed or edited field. */
+/** Confirmed segments form an ordered queue; only its head talks to the
+ * converter. Later typing edits the tail without changing a sealed prefix.
+ * The entire queue shares one action budget and one in-flight compose read. */
export function createIme(options: {
io?: Channel;
now?: () => number;
changed(state: ImeState): void;
commit(text: string): void;
+ /** Committed-text edits ordered after any preceding confirmation. */
+ edit?(action: ImeEdit): void;
}) {
- const io = options.io ?? offload();
- let keys: number[] = [], revision = 0, request = 0, requestRevision = -1;
- let session = 0, applied = "", dirty = false, snapshot = empty(), error = "";
- let retry = 0, raw = "", rawCaret = 0, accepting = false, acceptAt = Infinity;
- const now = options.now ?? virtualNow;
+ const io = options.io ?? offload(), now = options.now ?? virtualNow;
+ const queue: Composition[] = [];
+ let revision = 0, request = 0, requestSerial = 0, session = 0, budgetError = "";
const browsing = new Set(), knownCandidates = new Map();
+ const tail = () => queue.at(-1);
+ const keyCount = () => queue.reduce((count, item) => count + item.keys.length, 0);
function clearBrowsing() { for (const id of browsing) io.cancel(id); browsing.clear(); knownCandidates.clear(); }
+ function cancelRequest() { if (request) io.cancel(request); request = 0; requestSerial++; }
+ function changed() { revision++; clearBrowsing(); budgetError = ""; }
function state(): ImeState {
- const connected = io.session() > 0, fallback = !connected || !!error;
- return { ...snapshot, ...(fallback || !snapshot.preedit ? { preedit: raw, caret: rawCaret } : {}),
- raw, rawCaret, candidates: fallback ? [] : snapshot.candidates,
- pending: connected && !error && (dirty || request > 0), connected, error, revision, composing: keys.length > 0 };
+ const item = tail(), connected = io.session() > 0, error = budgetError || item?.error || "";
+ const snapshot = item?.snapshot ?? empty(), raw = item?.raw ?? "", rawCaret = item?.caret ?? 0;
+ const fallback = !connected || !!error;
+ return { ...snapshot, ...(fallback || !snapshot.preedit ? { preedit: raw, caret: rawCaret } : {}), raw, rawCaret,
+ candidates: fallback || item?.confirmAt !== undefined ? [] : snapshot.candidates,
+ pending: connected && !error && (queue.length > 1 || !!item?.dirty || request > 0),
+ connected, error, revision, composing: queue.length > 0 };
}
const notify = () => options.changed(state());
+ function removeHead() { cancelRequest(); queue.shift(); changed(); }
+ function commitHeadRaw() {
+ const text = queue[0].raw;
+ removeHead();
+ if (text) options.commit(text);
+ notify();
+ }
+ function append(item: Composition, key: number) {
+ item.keys.push(key); item.version++; item.dirty = true; item.error = "";
+ item.snapshot = { ...item.snapshot, candidates: [] }; changed();
+ }
+ function confirm(item: Composition) { item.confirmAt ??= now() + 0.4; changed(); }
function finish() {
- if (!accepting) return;
- if (!io.session() || error || now() >= acceptAt) { api.commitRaw(); return; }
- if (dirty || request) return;
- if (!keys.length) { accepting = false; return; }
- if (snapshot.candidates.length) api.select(0);
- else api.commitRaw();
+ // The loop is bounded by the shared 128-action queue. Removing one expired
+ // prefix must never commit or discard an unconfirmed suffix.
+ while (queue.length) {
+ const head = queue[0];
+ if (head.edit) {
+ removeHead(); options.edit?.(head.edit); notify(); continue;
+ }
+ if (head.confirmAt === undefined) return;
+ if (!io.session() || head.error || now() >= head.confirmAt) { commitHeadRaw(); continue; }
+ if (head.dirty || request) return;
+ if (!head.snapshot.candidates.length || keyCount() >= IME.keys) { commitHeadRaw(); continue; }
+ append(head, IME.select); notify(); return;
+ }
+ }
+ function selectable() {
+ return queue.length === 1 && !queue[0].edit && queue[0].confirmAt === undefined && !queue[0].dirty && !request && io.session() > 0;
}
const api = {
- composing: () => keys.length > 0,
+ composing: () => queue.length > 0,
state,
- /** Confirm within 400 ms of virtual time; an unavailable converter cannot
- * own the editor's lifetime. A second confirmation does not extend it. */
+ /** Seal this input boundary. Further typing and repeated confirmation do
+ * not cancel or extend its 400 ms virtual-time fallback deadline. */
accept() {
- if (!keys.length) return;
- if (!accepting) { accepting = true; acceptAt = now() + 0.4; }
- finish();
+ const item = tail();
+ if (!item || item.edit || item.confirmAt !== undefined) return;
+ confirm(item); finish(); notify();
},
- /** End the transaction before delivering local text, fencing late commits. */
+ /** Mode changes drain every segment in order and fence remote commits. */
commitRaw() {
- const text = raw;
+ const pending = queue.slice();
api.reset();
- if (text) options.commit(text);
+ for (const item of pending) {
+ if (item.edit) options.edit?.(item.edit);
+ else if (item.raw) options.commit(item.raw);
+ }
},
key(key: number) {
if (!validImeKeys([key])) return false;
if (key === IME.enter) { api.commitRaw(); return true; }
- if (keys.length >= IME.keys) { error = "Composition limit reached"; notify(); return false; }
- if (key < IME.select) { accepting = false; acceptAt = Infinity; }
+ if (keyCount() >= IME.keys) { budgetError = "Composition limit reached"; notify(); return false; }
+ let item = tail();
+ if (!item || item.edit || item.confirmAt !== undefined) {
+ const edit = key === IME.backspace ? "backspace" : key === IME.left ? "left" : key === IME.right ? "right" : undefined;
+ if (edit && !options.edit) return false;
+ item = composition(); item.edit = edit; queue.push(item);
+ if (edit) { append(item, key); finish(); notify(); return true; }
+ }
if (key >= 32 && key <= 126) {
- raw = raw.slice(0, rawCaret) + String.fromCharCode(key) + raw.slice(rawCaret); rawCaret++;
- } else if (key === IME.left) rawCaret = Math.max(0, rawCaret - 1);
- else if (key === IME.right) rawCaret = Math.min(raw.length, rawCaret + 1);
- else if (key === IME.backspace && rawCaret) {
- raw = raw.slice(0, rawCaret - 1) + raw.slice(rawCaret); rawCaret--;
+ item.raw = item.raw.slice(0, item.caret) + String.fromCharCode(key) + item.raw.slice(item.caret); item.caret++;
+ } else if (key === IME.left) item.caret = Math.max(0, item.caret - 1);
+ else if (key === IME.right) item.caret = Math.min(item.raw.length, item.caret + 1);
+ else if (key === IME.backspace && item.caret) {
+ item.raw = item.raw.slice(0, item.caret - 1) + item.raw.slice(item.caret); item.caret--;
+ }
+ if (!item.raw && key === IME.backspace) {
+ if (queue[0] === item) cancelRequest();
+ queue.pop(); changed(); notify(); return true;
}
- if (!raw && key === IME.backspace) { api.reset(); return true; }
- clearBrowsing(); keys.push(key); revision++; dirty = true; error = "";
- // Old candidate labels are never selectable against a newer transcript.
- snapshot = { ...snapshot, candidates: [] };
- notify(); return true;
+ append(item, key); notify(); return true;
},
select(index: number) {
- if (!io.session() || dirty || request || index < 0 || index >= snapshot.candidates.length) return false;
- return api.key(IME.select + index);
+ if (!selectable() || !Number.isInteger(index) || index < 0 || index >= queue[0].snapshot.candidates.length) return false;
+ if (!api.key(IME.select + index)) return false;
+ confirm(queue[0]); notify(); return true;
},
/** A read-only window. Browsing does not change preedit or consume keys. */
browse(offset: number, complete: (page: ImeCandidatePage | null) => void): number {
- if (dirty || request || !keys.length || io.session() <= 0 || browsing.size >= 2 ||
- !Number.isSafeInteger(offset) || offset < 0 || offset >= IME.browseLimit) return 0;
+ if (!selectable() || browsing.size >= 2 || !Number.isSafeInteger(offset) || offset < 0 || offset >= IME.browseLimit) return 0;
const version = revision;
- const id = io.request("ime.candidates", JSON.stringify({ keys, offset }), result => {
+ const id = io.request("ime.candidates", JSON.stringify({ keys: queue[0].keys, offset }), result => {
browsing.delete(id);
if (version !== revision) return;
if (result.ok) try {
@@ -97,50 +144,48 @@ export function createIme(options: {
return id;
},
selectAbsolute(index: number) {
- if (!io.session() || dirty || request || !knownCandidates.has(index)) return false;
- return api.key(IME.selectAbsolute + index);
- },
- reset() {
- clearBrowsing();
- if (request) io.cancel(request);
- request = 0; requestRevision = -1; raw = ""; rawCaret = 0; accepting = false; acceptAt = Infinity; retry = 0; revision++; keys = []; applied = ""; dirty = false; snapshot = empty(); error = ""; notify();
+ if (!selectable() || !knownCandidates.has(index)) return false;
+ if (!api.key(IME.selectAbsolute + index)) return false;
+ confirm(queue[0]); notify(); return true;
},
+ reset() { cancelRequest(); queue.length = 0; changed(); notify(); },
/** Called by the editor once per frame, after the realm offload pump. */
step() {
const current = io.session();
if (current !== session) {
- clearBrowsing(); revision++;
- session = current;
- if (request) io.cancel(request);
- request = 0; dirty = keys.length > 0; retry = 0; notify();
+ session = current; cancelRequest(); changed();
+ for (const item of queue) { item.dirty = true; item.retry = 0; }
+ notify();
}
finish();
- if (retry > 0) { retry--; return; }
- if (!dirty || request || current <= 0) return;
- const version = revision; requestRevision = version;
- request = io.request("ime.compose", JSON.stringify(keys), result => {
- if (requestRevision !== version) return;
+ const head = queue[0];
+ if (!head) return;
+ if (head.retry > 0) { head.retry--; return; }
+ if (!head.dirty || request || current <= 0) return;
+ const version = head.version, serial = ++requestSerial;
+ request = io.request("ime.compose", JSON.stringify(head.keys), result => {
+ if (serial !== requestSerial) return;
request = 0;
- if (revision !== version) return;
- if (!result.ok) { error = result.error; retry = 60; notify(); return; }
+ if (queue[0] !== head || head.version !== version || io.session() !== current) return;
+ if (!result.ok) { head.error = result.error; head.retry = 60; notify(); return; }
+ let next: ImeSnapshot;
try {
- const next = JSON.parse(result.value) as ImeSnapshot;
+ next = JSON.parse(result.value) as ImeSnapshot;
if (typeof next.preedit !== "string" || next.preedit.length > 256 ||
typeof next.commit !== "string" || next.commit.length > 512 ||
- !next.commit.startsWith(applied) || !Array.isArray(next.candidates) ||
+ !next.commit.startsWith(head.applied) || !Array.isArray(next.candidates) ||
next.candidates.length > IME.candidates || next.candidates.some(c => typeof c !== "string" || c.length > 128) ||
!Number.isSafeInteger(next.page) || next.page < 0 || !Number.isInteger(next.caret) ||
- next.caret < 0 || next.caret > next.preedit.length || typeof next.last !== "boolean") throw new Error("Invalid IME snapshot");
- if (typeof next.raw !== "string" || next.raw.length > IME.keys || !/^[\x20-\x7e]*$/.test(next.raw) ||
- !Number.isInteger(next.rawCaret) || next.rawCaret < 0 || next.rawCaret > next.raw.length)
- throw new Error("Invalid raw IME input");
- const suffix = next.commit.slice(applied.length);
- raw = next.raw; rawCaret = next.rawCaret;
- applied = next.commit; snapshot = next; dirty = false; error = "";
- next.candidates.forEach((value, i) => knownCandidates.set(next.page * IME.candidates + i, value));
- if (suffix) options.commit(suffix);
- if (!next.preedit) { keys = []; applied = ""; raw = ""; rawCaret = 0; accepting = false; snapshot = empty(); }
- } catch { error = "Invalid IME reply"; retry = 60; }
+ next.caret < 0 || next.caret > next.preedit.length || typeof next.last !== "boolean" ||
+ typeof next.raw !== "string" || next.raw.length > IME.keys || !/^[\x20-\x7e]*$/.test(next.raw) ||
+ !Number.isInteger(next.rawCaret) || next.rawCaret < 0 || next.rawCaret > next.raw.length) throw new Error();
+ } catch { head.error = "Invalid IME reply"; head.retry = 60; notify(); return; }
+ const suffix = next.commit.slice(head.applied.length);
+ head.raw = next.raw; head.caret = next.rawCaret; head.applied = next.commit;
+ head.snapshot = next; head.dirty = false; head.error = "";
+ if (!next.preedit) removeHead();
+ else if (selectable()) next.candidates.forEach((value, i) => knownCandidates.set(next.page * IME.candidates + i, value));
+ if (suffix) options.commit(suffix);
notify();
});
},
diff --git a/framework/src/resource-cache.ts b/framework/src/resource-cache.ts
index f8fb00008..efab6124c 100644
--- a/framework/src/resource-cache.ts
+++ b/framework/src/resource-cache.ts
@@ -100,6 +100,13 @@ export function createResourceScheduler(options: ResourceSchedulerOptions) {
entry.attempts++; entry.charged = true; return true;
}
}
+ function invalidateEntry(entry: Entry, dropValue: boolean) {
+ stop(entry); entry.stale = true; entry.attempts = 0; entry.retryAt = 0; entry.error = undefined;
+ const previous = entry.state;
+ if (dropValue || previous.status === "error") entry.state = pending();
+ notify(entry);
+ if (dropValue && previous.status === "ready") config.dispose?.(previous.value);
+ }
const collection: Collection = {
candidate() {
let chosen: Entry | undefined;
@@ -189,15 +196,16 @@ export function createResourceScheduler(options: ResourceSchedulerOptions) {
return entry ? { state: entry.state, stale: entry.stale, refreshing: entry.busy, error: entry.error }
: { state: pending(), stale: true, refreshing: false };
},
+ /** A new provider session or explicit recovery grants failed reads a
+ * fresh retry budget. Healthy resident entries keep their identity. */
+ retryFailed(matches: (input: I) => boolean = () => true) {
+ for (const entry of entries.values()) if (matches(entry.input) &&
+ (entry.state.status === "error" || entry.error !== undefined ||
+ (!entry.busy && entry.stale && entry.attempts > 0))) invalidateEntry(entry, false);
+ },
/** Retain stale content by default; drop when the identity is unsafe to display. */
invalidate(matches: (input: I) => boolean = () => true, dropValue = false) {
- for (const entry of entries.values()) if (matches(entry.input)) {
- stop(entry); entry.stale = true; entry.attempts = 0; entry.retryAt = 0; entry.error = undefined;
- const previous = entry.state;
- if (dropValue || previous.status === "error") entry.state = pending();
- notify(entry);
- if (dropValue && previous.status === "ready") config.dispose?.(previous.value);
- }
+ for (const entry of entries.values()) if (matches(entry.input)) invalidateEntry(entry, dropValue);
},
cancel, clear, dispose: collection.dispose,
stats: () => ({ entries: entries.size, cost, ready: [...entries.values()].reduce((n, e) => n + (e.state.status === "ready" ? 1 : 0), 0) }),
diff --git a/framework/src/text.ts b/framework/src/text.ts
index 5e27ccc31..54b36a717 100644
--- a/framework/src/text.ts
+++ b/framework/src/text.ts
@@ -117,6 +117,7 @@ export function createTextResources(options: {
face = next.id;
for (const owner of owners) if (owner.glyphs.size) owner.stale = true;
}
+ cache.retryFailed(request => request.face === face);
facePending = false; return;
} catch { /* Keep immutable resident glyphs until a valid face arrives. */ }
retry = 60;
diff --git a/hosts/android/app/jni/runtime.c b/hosts/android/app/jni/runtime.c
index e1751495a..1be8c1356 100644
--- a/hosts/android/app/jni/runtime.c
+++ b/hosts/android/app/jni/runtime.c
@@ -7,6 +7,7 @@
#include
#include "pocket_input.h"
+#include "../../../shared/contact_latch.h"
#include "pocket_runtime.h"
#include "pocket_spec.h"
@@ -60,8 +61,7 @@ static int gl_initialized;
static char android_error[512];
static char receipt_path[1024];
static unsigned long frames, touch_sequences;
-typedef struct { int used, platform_id, ending, sampled; float x, y; int hit; } Contact;
-static Contact contacts[POCKET_RUNTIME_MAX_CONTACTS];
+static PocketContactLatch contacts;
JNIEXPORT jint JNICALL Java_dev_pocketstack_android_PocketActivity_nativeLogicalWidth(JNIEnv *env, jclass owner) {
(void)env; (void)owner; return POCKET_LOGICAL_WIDTH;
}
@@ -82,7 +82,7 @@ JNIEXPORT void JNICALL Java_dev_pocketstack_android_PocketActivity_nativeConfigu
JNIEXPORT void JNICALL Java_dev_pocketstack_android_PocketActivity_nativeCancelTouches(JNIEnv *env, jclass owner) {
(void)env; (void)owner;
pthread_mutex_lock(&input_mutex);
- for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++) if (contacts[i].used) contacts[i].ending = 1;
+ pocket_contacts_cancel(&contacts);
pthread_mutex_unlock(&input_mutex);
}
@@ -240,17 +240,8 @@ Java_dev_pocketstack_android_PocketActivity_nativeFrame(
height = surface_height;
sequences = touch_sequences;
frame.buttons = sample.buttons;
- for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++) {
- Contact *contact = &contacts[i];
- if (!contact->used) continue;
- if (contact->ending && contact->sampled) { memset(contact, 0, sizeof *contact); continue; }
- int x = (int)(contact->x * POCKET_LOGICAL_WIDTH / width);
- int y = (int)(contact->y * POCKET_LOGICAL_HEIGHT / height);
- if (!contact->sampled) contact->hit = pocket_runtime_hit_test_bounds((float)x, (float)y);
- PocketRuntimeContact *out = &frame.contacts[frame.contact_count++];
- out->id = (int)i; out->x = x; out->y = y; out->hit = contact->hit;
- contact->sampled = 1;
- }
+ pocket_contacts_sample(&contacts, &frame, width, height, POCKET_LOGICAL_WIDTH, POCKET_LOGICAL_HEIGHT,
+ pocket_runtime_hit_test_bounds);
pthread_mutex_unlock(&input_mutex);
if (!pocket_runtime_tick_contacts(&frame)) {
set_android_error(pocket_runtime_error());
@@ -326,16 +317,7 @@ Java_dev_pocketstack_android_PocketActivity_nativeTouch(
else phase = POCKET_TOUCH_MOVE;
pthread_mutex_lock(&input_mutex);
ensure_input();
- Contact *contact = NULL;
- for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++)
- if (contacts[i].used && contacts[i].platform_id == pointer_id) { contact = &contacts[i]; break; }
- if (phase == POCKET_TOUCH_DOWN && !contact && x >= 0 && y >= 0 && x < surface_width && y < surface_height) {
- for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++) if (!contacts[i].used) {
- contact = &contacts[i]; memset(contact, 0, sizeof *contact); contact->used = 1; contact->platform_id = pointer_id;
- touch_sequences++; break;
- }
- }
- if (contact) { contact->x = x; contact->y = y; if (phase == POCKET_TOUCH_UP || phase == POCKET_TOUCH_CANCEL) contact->ending = 1; }
+ touch_sequences += pocket_contact_event(&contacts, phase, pointer_id, x, y, surface_width, surface_height);
pthread_mutex_unlock(&input_mutex);
}
diff --git a/hosts/shared/contact_latch.h b/hosts/shared/contact_latch.h
new file mode 100644
index 000000000..6b3cc20a7
--- /dev/null
+++ b/hosts/shared/contact_latch.h
@@ -0,0 +1,84 @@
+#ifndef POCKET_CONTACT_LATCH_H
+#define POCKET_CONTACT_LATCH_H
+#include
+#include
+#include "../../engine/quickjs-c/pocket_runtime.h"
+#include "../blackberry-classic/pocket_input.h"
+
+/* Platform pointer IDs identify event streams, not guest contact lifetimes.
+ * A released stream can coexist with a new stream using the same platform ID.
+ * The caller serializes events/sampling and supplies the native bounds hit. */
+typedef struct {
+ int used, platform_id, id, ending, sampled;
+ float x, y;
+ int hit;
+} PocketLatchedContact;
+typedef struct {
+ PocketLatchedContact contacts[POCKET_RUNTIME_MAX_CONTACTS];
+ unsigned next_id, previous_count;
+ int previous[POCKET_RUNTIME_MAX_CONTACTS];
+} PocketContactLatch;
+
+static inline int pocket_contact_id_used(const PocketContactLatch *state, int id) {
+ for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++)
+ if (state->contacts[i].used && state->contacts[i].id == id) return 1;
+ for (unsigned i = 0; i < state->previous_count; i++) if (state->previous[i] == id) return 1;
+ return 0;
+}
+/* Return 1 only when a new contact is admitted. At capacity, reclaim a sampled
+ * release; never evict a held finger or an unsampled ordinary tap. */
+static inline int pocket_contact_event(PocketContactLatch *state, PocketTouchPhase phase, int platform_id,
+ float x, float y, int width, int height) {
+ PocketLatchedContact *contact = NULL;
+ for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++) {
+ PocketLatchedContact *c = &state->contacts[i];
+ if (c->used && !c->ending && c->platform_id == platform_id) { contact = c; break; }
+ }
+ if (phase == POCKET_TOUCH_CANCEL) {
+ if (contact) memset(contact, 0, sizeof *contact);
+ return 0;
+ }
+ if (!isfinite(x) || !isfinite(y)) return 0;
+ if (phase == POCKET_TOUCH_DOWN) {
+ if (contact || x < 0 || y < 0 || x >= width || y >= height) return 0;
+ for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++) {
+ PocketLatchedContact *c = &state->contacts[i];
+ if (!c->used || (c->ending && c->sampled)) { contact = c; break; }
+ }
+ if (!contact) return 0;
+ memset(contact, 0, sizeof *contact);
+ // At most 16 IDs are reserved by the resident and previous-frame sets.
+ int id;
+ do { id = (int)(state->next_id++ & 255); } while (pocket_contact_id_used(state, id));
+ contact->used = 1; contact->platform_id = platform_id; contact->id = id;
+ contact->x = x; contact->y = y;
+ return 1;
+ }
+ if (contact) {
+ contact->x = x; contact->y = y;
+ if (phase == POCKET_TOUCH_UP) contact->ending = 1;
+ }
+ return 0;
+}
+/* Cancellation has no tap latch. Previously delivered contacts disappear in
+ * the next snapshot; a contact cancelled before sampling is never delivered. */
+static inline void pocket_contacts_cancel(PocketContactLatch *state) {
+ memset(state->contacts, 0, sizeof state->contacts);
+}
+static inline void pocket_contacts_sample(PocketContactLatch *state, PocketRuntimeContactsInput *frame,
+ int width, int height, int logical_width, int logical_height, int (*hit_test)(float, float)) {
+ frame->contact_count = 0;
+ for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++) {
+ PocketLatchedContact *contact = &state->contacts[i];
+ if (!contact->used) continue;
+ if (contact->ending && contact->sampled) { memset(contact, 0, sizeof *contact); continue; }
+ int x = (int)(contact->x * logical_width / width), y = (int)(contact->y * logical_height / height);
+ if (!contact->sampled) contact->hit = hit_test((float)x, (float)y);
+ PocketRuntimeContact *out = &frame->contacts[frame->contact_count++];
+ out->id = contact->id; out->x = x; out->y = y; out->hit = contact->hit;
+ contact->sampled = 1;
+ }
+ state->previous_count = frame->contact_count;
+ for (unsigned i = 0; i < frame->contact_count; i++) state->previous[i] = frame->contacts[i].id;
+}
+#endif
diff --git a/tests/contact-latch.test.ts b/tests/contact-latch.test.ts
new file mode 100644
index 000000000..7d61732a0
--- /dev/null
+++ b/tests/contact-latch.test.ts
@@ -0,0 +1,16 @@
+import { expect, test } from "bun:test";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+test("native contact lifetimes survive pointer reuse and suppress cancelled tap latches", () => {
+ const directory = mkdtempSync(join(tmpdir(), "pocket-contact-latch-"));
+ try {
+ const binary = join(directory, "test");
+ const build = Bun.spawnSync(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
+ "tests/fixtures/contact-latch.c", "-o", binary], { stderr: "pipe" });
+ expect(build.exitCode, build.stderr.toString()).toBe(0);
+ const run = Bun.spawnSync([binary], { stderr: "pipe" });
+ expect(run.exitCode, run.stderr.toString()).toBe(0);
+ } finally { rmSync(directory, { recursive: true, force: true }); }
+});
diff --git a/tests/fixtures/contact-latch.c b/tests/fixtures/contact-latch.c
new file mode 100644
index 000000000..b0dbbe85b
--- /dev/null
+++ b/tests/fixtures/contact-latch.c
@@ -0,0 +1,71 @@
+#include "../../hosts/shared/contact_latch.h"
+#include
+static unsigned hits;
+static int hit(float x, float y) { hits++; return (int)(x + y); }
+static PocketRuntimeContactsInput sample(PocketContactLatch *state) {
+ PocketRuntimeContactsInput frame = {0};
+ pocket_contacts_sample(state, &frame, 720, 1600, 360, 800, hit);
+ assert(frame.contact_count <= 8);
+ for (unsigned i = 0; i < frame.contact_count; i++) for (unsigned j = i + 1; j < frame.contact_count; j++)
+ assert(frame.contacts[i].id != frame.contacts[j].id);
+ return frame;
+}
+static int event(PocketContactLatch *s, PocketTouchPhase phase, int id, float x) {
+ return pocket_contact_event(s, phase, id, x, 200, 720, 1600);
+}
+int main(void) {
+ PocketContactLatch s = {0};
+ assert(event(&s, POCKET_TOUCH_DOWN, 19, 100));
+ PocketRuntimeContactsInput first = sample(&s);
+ assert(first.contact_count == 1 && first.contacts[0].x == 50 && first.contacts[0].y == 100);
+ const int old_id = first.contacts[0].id;
+ event(&s, POCKET_TOUCH_UP, 19, 100);
+ assert(event(&s, POCKET_TOUCH_DOWN, 19, 300)); // same platform ID, before another frame
+ PocketRuntimeContactsInput next = sample(&s);
+ assert(next.contact_count == 1 && next.contacts[0].id != old_id && next.contacts[0].x == 150);
+ const int new_id = next.contacts[0].id, captured_hit = next.contacts[0].hit;
+ const unsigned previous_hits = hits;
+ event(&s, POCKET_TOUCH_MOVE, 19, 400); next = sample(&s);
+ assert(next.contact_count == 1 && next.contacts[0].id == new_id && next.contacts[0].x == 200);
+ assert(next.contacts[0].hit == captured_hit && hits == previous_hits);
+ event(&s, POCKET_TOUCH_UP, 19, 400); assert(sample(&s).contact_count == 0);
+
+ // Ordinary sub-frame taps latch once; cancellation never latches a press.
+ assert(event(&s, POCKET_TOUCH_DOWN, 0, 100)); event(&s, POCKET_TOUCH_CANCEL, 0, 100);
+ assert(sample(&s).contact_count == 0); assert(sample(&s).contact_count == 0);
+ assert(event(&s, POCKET_TOUCH_DOWN, 0, 100)); event(&s, POCKET_TOUCH_UP, 0, 100);
+ assert(sample(&s).contact_count == 1); assert(sample(&s).contact_count == 0);
+ assert(event(&s, POCKET_TOUCH_DOWN, 0, 100)); assert(sample(&s).contact_count == 1);
+ event(&s, POCKET_TOUCH_CANCEL, 0, 100); assert(sample(&s).contact_count == 0);
+
+ // Two lifetimes of one platform ID can coexist until the old tap is sampled.
+ assert(event(&s, POCKET_TOUCH_DOWN, 0, 100)); event(&s, POCKET_TOUCH_UP, 0, 100);
+ assert(event(&s, POCKET_TOUCH_DOWN, 0, 200)); next = sample(&s);
+ assert(next.contact_count == 2);
+ event(&s, POCKET_TOUCH_MOVE, 0, 300); next = sample(&s);
+ assert(next.contact_count == 1 && next.contacts[0].x == 150);
+ pocket_contacts_cancel(&s); assert(sample(&s).contact_count == 0);
+
+ // A sampled release makes room even when all eight slots were occupied.
+ for (int id = 0; id < 8; id++) assert(event(&s, POCKET_TOUCH_DOWN, id, 100));
+ first = sample(&s); assert(first.contact_count == 8);
+ assert(!event(&s, POCKET_TOUCH_DOWN, 99, 100));
+ event(&s, POCKET_TOUCH_UP, 0, 100); assert(event(&s, POCKET_TOUCH_DOWN, 0, 200));
+ next = sample(&s); assert(next.contact_count == 8 && next.contacts[0].id != first.contacts[0].id);
+ for (unsigned i = 1; i < 8; i++) assert(next.contacts[i].id == first.contacts[i].id);
+ pocket_contacts_cancel(&s); assert(sample(&s).contact_count == 0);
+
+ // Cancellation on pause also drops contacts that have never been sampled.
+ assert(event(&s, POCKET_TOUCH_DOWN, 0, 100)); assert(event(&s, POCKET_TOUCH_DOWN, 1, 100));
+ pocket_contacts_cancel(&s); assert(sample(&s).contact_count == 0);
+ assert(!event(&s, POCKET_TOUCH_DOWN, 0, -1)); assert(!event(&s, POCKET_TOUCH_DOWN, 0, NAN));
+ event(&s, POCKET_TOUCH_MOVE, 0, 100); assert(sample(&s).contact_count == 0);
+
+ // ID wrap and many cancelled events cannot alias a still-published ID.
+ assert(event(&s, POCKET_TOUCH_DOWN, 0, 100)); first = sample(&s);
+ pocket_contacts_cancel(&s);
+ for (int n = 0; n < 600; n++) { assert(event(&s, POCKET_TOUCH_DOWN, 0, 100)); event(&s, POCKET_TOUCH_CANCEL, 0, 100); }
+ assert(event(&s, POCKET_TOUCH_DOWN, 0, 100)); next = sample(&s);
+ assert(next.contact_count == 1 && next.contacts[0].id != first.contacts[0].id);
+ return 0;
+}
diff --git a/tests/ime.test.ts b/tests/ime.test.ts
index e4fb7a9f5..3d3dd0de9 100644
--- a/tests/ime.test.ts
+++ b/tests/ime.test.ts
@@ -8,11 +8,13 @@ function fixture() {
const sent: { id: number; method: string; payload: string }[] = [], replies: string[] = [], committed: string[] = [];
const io = createOffloadClient({ session: () => session, take: () => replies.shift(),
submit: raw => { sent.push(JSON.parse(raw)); return true; } });
- const ime = createIme({ io, now: () => time, changed() {}, commit: value => committed.push(value) });
+ const events: string[] = [];
+ const ime = createIme({ io, now: () => time, changed() {}, commit: value => { committed.push(value); events.push(`commit:${value}`); },
+ edit: action => events.push(`edit:${action}`) });
const tick = () => { time += 1 / 60; io.step(); ime.step(); };
const answer = (request: number, fields: Partial = {}) => replies.push(JSON.stringify({ id: request,
payload: JSON.stringify({ raw: (fields.preedit ?? "ni").replaceAll(" ", ""), rawCaret: (fields.preedit ?? "ni").replaceAll(" ", "").length, commit: "", preedit: "ni", candidates: ["你", "呢"], page: 0, last: false, caret: (fields.preedit ?? "ni").length, ...fields }) }));
- return { ime, io, sent, committed, tick, answer, replies, connect: (n: number) => { session = n; } };
+ return { ime, io, sent, committed, events, tick, answer, replies, connect: (n: number) => { session = n; } };
}
describe("replayable IME", () => {
test("candidate windows are reads and absolute selection is revision fenced", () => {
@@ -161,3 +163,63 @@ test("provider failure and invalid raw data cannot block local confirmation", ()
expect(f.committed).toEqual(["n"]);
}
});
+
+test("confirmation freezes its input boundary while following keys wait in order", () => {
+ const f = fixture(); for (const ch of "ni") f.ime.key(ch.charCodeAt(0));
+ f.tick(); f.tick(); const first = f.sent.at(-1)!.id;
+ f.ime.accept(); for (const ch of "hao") f.ime.key(ch.charCodeAt(0));
+ f.answer(first); f.tick(); f.tick();
+ expect(JSON.parse(f.sent.at(-1)!.payload)).toEqual([110, 105, IME.select]);
+ f.answer(f.sent.at(-1)!.id, { commit: "你", preedit: "", candidates: [] }); f.tick(); f.tick();
+ expect(f.committed).toEqual(["你"]);
+ expect(JSON.parse(f.sent.at(-1)!.payload)).toEqual([104, 97, 111]);
+ f.answer(f.sent.at(-1)!.id, { preedit: "hao", candidates: ["好"] }); f.tick();
+ expect(f.ime.state().raw).toBe("hao"); expect(f.ime.composing()).toBe(true);
+});
+
+test("typing after confirmation cannot move or cancel its fallback deadline", () => {
+ const f = fixture(); for (const ch of "ni") f.ime.key(ch.charCodeAt(0));
+ f.tick(); f.tick(); const old = f.sent.at(-1)!.id; f.ime.accept();
+ for (let i = 0; i < 12; i++) f.tick();
+ for (const ch of "hao") f.ime.key(ch.charCodeAt(0));
+ for (let i = 0; i < 14; i++) f.tick();
+ expect(f.committed).toEqual(["ni"]); expect(f.ime.state().raw).toBe("hao");
+ f.answer(old, { commit: "wrong", preedit: "" }); f.tick(); expect(f.committed).toEqual(["ni"]);
+ f.connect(0); f.tick(); f.ime.accept(); expect(f.committed).toEqual(["ni", "hao"]);
+});
+
+test("queued confirmations keep their order and deadlines across disconnect and reset", () => {
+ const f = fixture(); for (const ch of "ni") f.ime.key(ch.charCodeAt(0)); f.ime.accept();
+ for (const ch of "hao") f.ime.key(ch.charCodeAt(0)); f.ime.accept();
+ f.ime.key(97); f.tick(); f.tick(); const old = f.sent.at(-1)!.id;
+ f.connect(0); f.tick();
+ expect(f.committed).toEqual(["ni", "hao"]); expect(f.ime.state().raw).toBe("a");
+ f.ime.reset(); f.connect(2); f.answer(old, { commit: "wrong", preedit: "" }); f.tick();
+ expect(f.committed).toEqual(["ni", "hao"]); expect(f.ime.composing()).toBe(false);
+});
+
+test("mode-switch raw commit drains confirmed and current input in source order", () => {
+ const f = fixture(); for (const ch of "ni") f.ime.key(ch.charCodeAt(0)); f.ime.accept();
+ for (const ch of "hao") f.ime.key(ch.charCodeAt(0)); f.ime.key(IME.left); f.ime.key(IME.backspace);
+ f.ime.commitRaw(); expect(f.committed.join("")).toBe("niho"); expect(f.ime.composing()).toBe(false);
+});
+
+
+test("committed-text deletion and caret edits wait behind a confirmed prefix", () => {
+ const f = fixture(); for (const ch of "ni") f.ime.key(ch.charCodeAt(0));
+ f.tick(); f.tick(); f.answer(f.sent.at(-1)!.id); f.tick(); f.ime.accept();
+ f.ime.key(IME.backspace); f.ime.key(IME.left); for (const ch of "hao") f.ime.key(ch.charCodeAt(0));
+ f.tick(); f.tick(); f.answer(f.sent.at(-1)!.id, { preedit: "", commit: "你", candidates: [] }); f.tick(); f.tick();
+ expect(f.events).toEqual(["commit:你", "edit:backspace", "edit:left"]);
+ expect(JSON.parse(f.sent.at(-1)!.payload)).toEqual([104, 97, 111]);
+ expect(f.ime.state().raw).toBe("hao");
+});
+
+test("the action budget covers every queued segment and committed-text edit", () => {
+ const f = fixture();
+ for (let i = 0; i < IME.keys / 2; i++) { expect(f.ime.key(97)).toBe(true); f.ime.accept(); expect(f.ime.key(IME.left)).toBe(true); }
+ expect(f.ime.key(98)).toBe(false);
+ f.ime.commitRaw(); expect(f.events).toHaveLength(IME.keys);
+ expect(f.events.slice(0, 4)).toEqual(["commit:a", "edit:left", "commit:a", "edit:left"]);
+ expect(f.ime.composing()).toBe(false);
+});
diff --git a/tests/resource-cache.test.ts b/tests/resource-cache.test.ts
index 32206deeb..0b3f4da29 100644
--- a/tests/resource-cache.test.ts
+++ b/tests/resource-cache.test.ts
@@ -176,3 +176,22 @@ test("temporary refusals preserve the finite failure budget", () => {
expect(cache.state("x").status).toBe("error"); expect(scheduler.stats().active).toBe(0);
scheduler.dispose();
});
+
+test("retryFailed retains healthy residents and recovers a failed refresh whose error is undefined", () => {
+ const x = setup(); x.want("healthy", "refresh"); x.scheduler.step();
+ x.requests[0].done({ ok: true, value: "H" }); x.requests[1].done({ ok: true, value: "R" });
+ x.scheduler.step(); x.scheduler.step();
+ const healthy = x.cache.state("healthy"), refresh = x.cache.state("refresh");
+ x.cache.invalidate(key => key === "refresh"); x.scheduler.step();
+ x.requests.at(-1)!.done({ ok: false, error: undefined }); x.scheduler.step();
+ x.scheduler.step(); x.scheduler.step(); x.requests.at(-1)!.done({ ok: false, error: undefined }); x.scheduler.step();
+ for (let i = 0; i < 20; i++) x.scheduler.step();
+ expect(x.requests).toHaveLength(4); expect(x.cache.state("refresh")).toBe(refresh);
+ x.cache.retryFailed();
+ expect(x.cache.state("healthy")).toBe(healthy); expect(x.cache.state("refresh")).toBe(refresh);
+ expect(x.freed).toEqual([]); x.scheduler.step();
+ expect(x.requests).toHaveLength(5); expect(x.requests.at(-1)!.key).toBe("refresh");
+ x.requests.at(-1)!.done({ ok: true, value: "R2" }); x.scheduler.step();
+ expect(x.cache.state("refresh")).toEqual({ status: "ready", value: "R2" });
+ expect(x.freed).toEqual(["R"]); x.scheduler.dispose();
+});
diff --git a/tests/text.test.ts b/tests/text.test.ts
index d5878acb8..212a132c7 100644
--- a/tests/text.test.ts
+++ b/tests/text.test.ts
@@ -9,8 +9,11 @@ const font = existsSync("/System/Library/Fonts/STHeiti Medium.ttc") ? "/System/L
function fixture(maxGlyphs = 96) {
const provider = createTextProvider(font), sent: OffloadRequest[] = [], replies: string[] = [], held: OffloadRequest[] = [];
let session = 1, allow = true, next = 1, frame = 0;
+ let failure: "load" | "upload" | undefined;
const uploaded: number[] = [], freed: number[] = [];
- function answer(r: OffloadRequest) { replies.push(JSON.stringify({ id: r.id,
+ function answer(r: OffloadRequest) {
+ if (r.method === "text.glyph" && failure === "load") { replies.push(JSON.stringify({ id: r.id, error: "unavailable" })); return; }
+ replies.push(JSON.stringify({ id: r.id,
payload: r.method === "text.font" ? provider["text.font"]() : provider["text.glyph"](r.payload) })); }
const io = createOffloadClient({ session: () => session, take: () => replies.shift(), submit: raw => {
const request = JSON.parse(raw) as OffloadRequest; sent.push(request);
@@ -18,12 +21,12 @@ function fixture(maxGlyphs = 96) {
return true;
} });
const resources = createTextResources({ io, maxGlyphs, measure: s => s.length * 8,
- upload() { uploaded.push(frame); return next++; }, free: h => freed.push(h) });
+ upload() { uploaded.push(frame); return failure === "upload" ? undefined : next++; }, free: h => freed.push(h) });
const layouts: ReturnType[] = [];
function label() { const label = resources.createLayout({ width: 300, size: 20, density: 2, bold: true, fontSlot: 11 }); layouts.push(label); return label; }
function step(n = 1) { for (let i = 0; i < n; i++) { frame++; io.step(); resources.step(); for (const l of layouts) l.snapshot(); } }
return { resources, io, label, sent, uploaded, freed, step, connect: (s: number) => { session = s; },
- hold() { allow = false; }, resume() { allow = true; for (const request of held.splice(0)) answer(request); } };
+ fail(mode?: "load" | "upload") { failure = mode; }, hold() { allow = false; }, resume() { allow = true; for (const request of held.splice(0)) answer(request); } };
}
describe("retained text resources", () => {
test("a clipped glyph dependency still updates the full measured width", () => {
@@ -89,3 +92,18 @@ describe("retained text resources", () => {
f.resources.dispose();
});
});
+
+for (const mode of ["load", "upload"] as const) for (const offline of [true, false])
+ test(`same-font reconnect restores exhausted ${mode} retries (offline edge ${offline}) without reloading residents`, () => {
+ const f = fixture(), resident = f.label(), missing = f.label(); resident.set("你"); f.step(30);
+ const stable = resident.snapshot(); f.fail(mode); missing.set("好"); f.step(200);
+ const reads = () => f.sent.filter(r => r.method === "text.glyph" && JSON.parse(r.payload).text === "好");
+ expect(reads()).toHaveLength(3); expect(missing.snapshot().pending).toBe(true);
+ f.fail(); f.step(200); expect(reads()).toHaveLength(3); // budget stays exhausted within the old session
+ if (offline) { f.connect(0); f.step(2); }
+ f.connect(2); f.step(200);
+ expect(reads()).toHaveLength(4); expect(missing.snapshot().pending).toBe(false);
+ expect(resident.snapshot()).toBe(stable); expect(f.freed).toEqual([]);
+ expect(f.sent.filter(r => r.method === "text.glyph" && JSON.parse(r.payload).text === "你")).toHaveLength(1);
+ f.resources.dispose();
+ });
diff --git a/tools/ime/verify.ts b/tools/ime/verify.ts
index 6f43d95c0..e746a6727 100644
--- a/tools/ime/verify.ts
+++ b/tools/ime/verify.ts
@@ -2,6 +2,8 @@
import { strict as assert } from "node:assert";
import { resolve } from "node:path";
import { RimeEngine } from "./rime.ts";
+import { createIme } from "../../framework/src/ime.ts";
+import { createOffloadClient } from "../../framework/src/offload.ts";
import { IME } from "../../contracts/spec/ime.ts";
const engine = new RimeEngine(resolve(Bun.argv[2] ?? ".pocket/ime"));
const keys = (s: string) => Array.from(s, c => c.charCodeAt(0));
@@ -65,5 +67,29 @@ try {
assert.deepEqual(await compose([IME.left, IME.right]), await compose([]));
assert.equal((await compose([...keys("nihao"), IME.enter])).commit, "nihao");
assert.equal((await compose([...keys("nihao"), 32])).commit, "你好");
- console.log("Rime acceptance passed: phrases, selection, replay, paging, read-only windows, absolute selection, deletion, bounded character caret, raw commit, space");
+ // Exercise the guest state machine against real conversion, holding every
+ // response for three frames while both confirmed words arrive beforehand.
+ let frame = 0;
+ const pending: { id: number; payload: string; at: number }[] = [], replies: string[] = [], committed: string[] = [];
+ const transcripts: number[][] = [];
+ const io = createOffloadClient({ session: () => 1, take: () => replies.shift(), submit(raw) {
+ const request = JSON.parse(raw); pending.push({ ...request, at: frame + 3 });
+ transcripts.push(JSON.parse(request.payload)); return true;
+ } });
+ const ime = createIme({ io, now: () => frame / 60, changed() {}, commit: text => committed.push(text) });
+ for (const ch of "ni") ime.key(ch.charCodeAt(0)); ime.accept();
+ for (const ch of "hao") ime.key(ch.charCodeAt(0)); ime.accept();
+ ime.key(97);
+ for (frame = 1; frame <= 35; frame++) {
+ while (pending[0]?.at <= frame) {
+ const request = pending.shift()!;
+ replies.push(JSON.stringify({ id: request.id, payload: await engine.compose(request.payload) }));
+ }
+ io.step(); ime.step();
+ }
+ assert.deepEqual(committed, ["你", "好"]);
+ assert.equal(ime.state().raw, "a");
+ assert.deepEqual(transcripts, [keys("ni"), [...keys("ni"), IME.select], keys("hao"), [...keys("hao"), IME.select], keys("a")]);
+ ime.commitRaw(); assert.deepEqual(committed, ["你", "好", "a"]); ime.dispose(); io.dispose();
+ console.log("Rime acceptance passed: phrases, selection, replay, paging, read-only windows, absolute selection, deletion, bounded character caret, raw commit, space, ordered guest confirmations under delayed replies");
} finally { engine.close(); }
diff --git a/tools/test.ts b/tools/test.ts
index b94cf4ae6..335641499 100644
--- a/tools/test.ts
+++ b/tools/test.ts
@@ -68,6 +68,7 @@ const SUITE: readonly Stage[] = [
"tests/meizu-m8-profile.test.ts",
"tests/blackberry-classic.test.ts",
"tests/pocket-input.test.ts",
+ "tests/contact-latch.test.ts",
"tests/ios-profile.test.ts",
"tests/iphone2g-device-contract.test.ts",
"tests/iphone2g-toolchain.test.ts",
From 5930bd5f8339a570bbb7cfc362d4a04475dbbe27 Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Sun, 13 Sep 2026 17:53:58 -0700
Subject: [PATCH 5/7] fix(ime): preserve contact semantics and share text
layout metrics
---
.github/workflows/native-c-harness.yml | 10 +++
.github/workflows/resources.yml | 4 +
apps/clear/app.tsx | 12 +--
apps/clear/remote-text.tsx | 14 +++-
apps/clear/rows.tsx | 7 +-
contracts/spec/spec.ts | 6 ++
docs/TEXT_RESOURCES.md | 2 +-
docs/TOUCH.md | 6 ++
engine/quickjs-c/pocket_runtime.c | 23 +++---
engine/quickjs-c/pocket_runtime.h | 16 +++-
framework/src/devtools.ts | 2 +-
framework/src/font-coverage.ts | 28 +++++++
framework/src/gesture-core.ts | 14 +++-
framework/src/pak.ts | 9 ++-
framework/src/text.ts | 15 +++-
framework/src/touch.ts | 47 +++++++----
hosts/ios-legacy/runtime.c | 62 +++++++--------
hosts/shared/contact_latch.h | 53 ++++++++++---
tests/clear-text.test.ts | 104 +++++++++++++++++++++++++
tests/contact-latch.test.ts | 64 ++++++++++++---
tests/devtools.test.ts | 13 +++-
tests/fixtures/contact-latch.c | 36 ++++++++-
tests/ipodtouch4-profile.test.ts | 5 +-
tests/meizu-m8-profile.test.ts | 2 +-
tests/text.test.ts | 28 +++++++
tools/test.ts | 2 +-
26 files changed, 469 insertions(+), 115 deletions(-)
create mode 100644 framework/src/font-coverage.ts
create mode 100644 tests/clear-text.test.ts
diff --git a/.github/workflows/native-c-harness.yml b/.github/workflows/native-c-harness.yml
index 21dabd23e..c609cbd0f 100644
--- a/.github/workflows/native-c-harness.yml
+++ b/.github/workflows/native-c-harness.yml
@@ -6,8 +6,12 @@ on:
- ".github/workflows/native-c-harness.yml"
- "engine/quickjs-c/**"
- "hosts/shared/contact_latch.h"
+ - "hosts/ios-legacy/**"
- "hosts/android/**"
- "tests/contact-latch.test.ts"
+ - "tests/clear-text.test.ts"
+ - "tests/clear-ime-loading.test.ts"
+ - "apps/clear/**"
- "tests/fixtures/contact-latch.c"
- "engine/ui-cabi/**"
- "engine/core/**"
@@ -30,8 +34,12 @@ on:
- ".github/workflows/native-c-harness.yml"
- "engine/quickjs-c/**"
- "hosts/shared/contact_latch.h"
+ - "hosts/ios-legacy/**"
- "hosts/android/**"
- "tests/contact-latch.test.ts"
+ - "tests/clear-text.test.ts"
+ - "tests/clear-ime-loading.test.ts"
+ - "apps/clear/**"
- "tests/fixtures/contact-latch.c"
- "engine/ui-cabi/**"
- "engine/core/**"
@@ -80,6 +88,8 @@ jobs:
run: bun test --conditions=browser tests/quickjs-c-harness.test.ts tests/renderer.test.ts tests/virtual-list.test.ts tests/vue-vapor-dom.test.ts
- name: Native contact lifetimes and cancellation
run: bun test tests/contact-latch.test.ts
+ - name: Clear input and text chain
+ run: bun tools/build.ts clear-main --framework=vue-vapor && bun test --conditions=browser tests/clear-text.test.ts tests/clear-ime-loading.test.ts
- name: UI singleton access and alignment policy
run: cargo test --locked --manifest-path engine/ui-cabi/Cargo.toml --features harness-access
- name: Link and execute the real C allocator
diff --git a/.github/workflows/resources.yml b/.github/workflows/resources.yml
index 9c6f16aea..60ce4c041 100644
--- a/.github/workflows/resources.yml
+++ b/.github/workflows/resources.yml
@@ -6,6 +6,8 @@ on:
- 'framework/src/offload.ts'
- 'framework/src/ime.ts'
- 'framework/src/text.ts'
+ - 'framework/src/font-coverage.ts'
+ - 'framework/src/pak.ts'
- 'contracts/spec/ime.ts'
- 'contracts/spec/text.ts'
- 'tools/text-glyph-provider.ts'
@@ -30,6 +32,8 @@ on:
- 'framework/src/offload.ts'
- 'framework/src/ime.ts'
- 'framework/src/text.ts'
+ - 'framework/src/font-coverage.ts'
+ - 'framework/src/pak.ts'
- 'contracts/spec/ime.ts'
- 'contracts/spec/text.ts'
- 'tools/text-glyph-provider.ts'
diff --git a/apps/clear/app.tsx b/apps/clear/app.tsx
index 16ed96b48..0296b0705 100644
--- a/apps/clear/app.tsx
+++ b/apps/clear/app.tsx
@@ -25,7 +25,7 @@ import { animate, jump } from "@pocketjs/framework/animation";
import { onFrame } from "@pocketjs/framework/lifecycle";
import { createGesture } from "@pocketjs/framework/gesture";
import { createScroller } from "@pocketjs/framework/kinetics";
-import { getOps, reportAppAction } from "@pocketjs/framework/host";
+import { reportAppAction } from "@pocketjs/framework/host";
import { after } from "@pocketjs/framework/clock";
import {
clearDone,
@@ -50,7 +50,6 @@ import {
SCREEN_H,
SCREEN_W,
SWITCH_MS,
- TITLE_FONT_SLOT,
} from "./metrics.ts";
import { KB_H } from "./keyboard-metrics.ts";
import { makeSlots, PARKED_Y, renderRow, resetSlotMotion, type RowSlot } from "./rows.tsx";
@@ -123,14 +122,6 @@ export default () => {
throw new Error("clear: row pool exhausted");
}
- function measureTitle(slot: RowSlot, text: string): number {
- if (slot.textFor !== text) {
- slot.textFor = text;
- slot.textW = text === "" ? 0 : getOps().measureText(text, TITLE_FONT_SLOT);
- }
- return slot.textW;
- }
-
/** Re-derive every slot from the model. Structural motion (row y, colors)
* animates when `animated`; text and looks snap. */
function layout(animated: boolean): void {
@@ -170,7 +161,6 @@ export default () => {
slot.y = y;
if (slot.strike && editor.editing() !== todo) {
- jump(slot.strike, "width", measureTitle(slot, todo.text));
jump(slot.strike, "scaleX", todo.done ? 1 : 0);
jump(slot.strike, "bgColor", todo.done ? DONE_TEXT : "#ffffff");
}
diff --git a/apps/clear/remote-text.tsx b/apps/clear/remote-text.tsx
index 46e4997ff..9f5706362 100644
--- a/apps/clear/remote-text.tsx
+++ b/apps/clear/remote-text.tsx
@@ -1,5 +1,6 @@
import { Text, View, type NodeMirror } from "@pocketjs/framework/components";
import { animate, jump } from "@pocketjs/framework/animation";
+import { getOps } from "@pocketjs/framework/host";
import { virtualNow } from "@pocketjs/framework/clock";
import { onFrame } from "@pocketjs/framework/lifecycle";
import { textResources } from "@pocketjs/framework/text";
@@ -13,10 +14,17 @@ export function remoteText(text: () => string, width: number, size: 12 | 14 | 16
waiting: () => boolean = () => false, visible: () => boolean = () => true, priority: number | (() => number) = 1,
measured?: (width: number) => void) {
const height = size + 8;
- if (!hasCompanion()) return
- {text()}
- ;
const style = { width, size, density: 2, bold, fontSlot: (size === 20 ? 4 : size === 16 ? 2 : size === 14 ? 1 : 0) + (bold ? 7 : 0) };
+ if (!hasCompanion()) {
+ let previous: string | undefined;
+ onFrame(() => {
+ const value = text();
+ if (value !== previous) { previous = value; measured?.(Math.min(width, getOps().measureText(value, style.fontSlot))); }
+ });
+ return
+ {text()}
+ ;
+ }
const label = textResources().createLayout(style);
let painter: ReturnType | undefined, content: NodeMirror | null = null, skeleton: NodeMirror | null = null;
let loading = false, pulseAt = Infinity, pulseHigh = false, retained = "", measuredRevision = -1;
diff --git a/apps/clear/rows.tsx b/apps/clear/rows.tsx
index 0997f2e72..604a8955f 100644
--- a/apps/clear/rows.tsx
+++ b/apps/clear/rows.tsx
@@ -31,7 +31,6 @@ export interface RowSlot {
gradTo: string;
/** Measured title width (strike-through line length). */
textW: number;
- textFor: string;
textVisible: boolean;
textPriority: number;
}
@@ -52,7 +51,6 @@ export function makeSlots(count: number): RowSlot[] {
gradFrom: "",
gradTo: "",
textW: 0,
- textFor: "",
textVisible: false,
textPriority: 2,
}));
@@ -112,7 +110,10 @@ export function renderRow(slot: RowSlot) {
{remoteText(() => slot.text.value ?? "", 296, 20, () => slot.done.value ? "#666666" : "#ffffff", true,
- undefined, () => slot.textVisible, () => slot.textPriority)}
+ undefined, () => slot.textVisible, () => slot.textPriority, width => {
+ slot.textW = width;
+ if (slot.strike) jump(slot.strike, "width", width);
+ })}
{
diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts
index 3b2b8fef4..f54c6fa24 100644
--- a/contracts/spec/spec.ts
+++ b/contracts/spec/spec.ts
@@ -1525,6 +1525,12 @@ export const BTN = {
// is unchanged. Deadzone/normalization is runtime policy (framework/src/frame.ts), not
// host policy — hosts pass the raw value through.
+// Touch words use bit 30 for a terminal system cancellation. Decode its ID
+// with the word's legacy/wide layout; it is not an active contact. The third
+// frame argument carries at most eight active words plus eight cancellations.
+// Hosts preserve DOWN order; the fifth argument carries surfaces for both.
+// Absence without a cancellation means ordinary UP. See docs/TOUCH.md.
+
// Optional sixth frame argument carries the right stick with identical packing.
// Omission reads as center; touch/hit/surface arguments retain their positions.
export const ANALOG_CENTER = 0x8080;
diff --git a/docs/TEXT_RESOURCES.md b/docs/TEXT_RESOURCES.md
index 0397af515..769fa00ce 100644
--- a/docs/TEXT_RESOURCES.md
+++ b/docs/TEXT_RESOURCES.md
@@ -15,7 +15,7 @@
| Companion text provider | Font-file reads, font metrics and coverage rasterization |
| Rime provider | Preedit, candidate windows and committed Unicode text |
-**A text change updates positions before requesting resources.** Latin spans use the core's baked font metrics and text nodes. Other scalar values use cached advances and coverage. Deleting a resident Han character, moving the caret, or reordering resident Han characters requires no new raster request. A cache miss affects that cell; existing Latin and Han content stays visible. Color and container alignment are presentation state.
+**A text change updates positions before requesting resources.** Scalars present in the selected baked font atlas use its metrics and text nodes, including symbols such as `£¥€•` before any companion connection. The framework reads that slot's cmap once without copying its coverage pixels. Scalars absent from the atlas use cached companion advances and coverage. Deleting a resident Han character, moving the caret, or reordering resident Han characters requires no new raster request. A cache miss affects that cell; existing Latin and Han content stays visible. Color and container alignment are presentation state. **Text and its decorations share one layout width.** Clear updates the completion line from the rendered label's measurement callback, including when glyph advances arrive or a row slot changes text. The local-only label path supplies the same callback.
The companion exposes `text.font` and `text.glyph` through `tools/text-glyph-provider.ts`. `text.font` returns an identity derived from the font contents and rasterizer revision, alongside the declared `scalar` mapping. Glyph identity includes that face, scalar value, logical size, weight and raster density. The first valid face response admits requests. Reconnection verifies the face identity while resident coverage remains usable offline. After the font handshake, `retryFailed()` grants failed glyph reads a new retry budget for that face, including failures during texture upload. Healthy resident entries keep their handles and layout identity. Retries remain bounded within each connection session. A changed face creates different cache keys.
diff --git a/docs/TOUCH.md b/docs/TOUCH.md
index aa3fff350..adae24ddb 100644
--- a/docs/TOUCH.md
+++ b/docs/TOUCH.md
@@ -252,3 +252,9 @@ and backspace holds without assigning focus to character keys. The keyboard
view supplies contact geometry and virtual time. **Release clears the hold**;
backspace repeats at most twice per step, and a held space enters caret dragging.
The view owns key-cap feedback, character insertion, layout and modality.
+
+## 接触顺序与系统取消
+
+**宿主按 DOWN 顺序交付接触。** 存储槽位和平台指针 ID 不决定回调顺序。Android 和旧版 UIKit 宿主共用有界接触锁存器:普通短按保留一个采样,新接触拥有独立的 guest ID,已采样的结束接触可以腾出容量。
+
+**系统取消不产生 UP 或 tap。** touch word 的 bit 30 标记 CANCEL,其 ID 使用相同的 legacy/wide 解码;坐标不参与取消处理。每帧最多携带八个活动接触和八个取消记录,surface 仍由第 5 参指定。`touches()` 只返回活动接触。手势层先取消旧生命周期,再分配新接触,避免 Space 的取消被解释为提交,并允许满容量的接触在一帧内替换。首次采样前取消的接触不进入 guest。DevTools 保存取消记录以支持重放。
diff --git a/engine/quickjs-c/pocket_runtime.c b/engine/quickjs-c/pocket_runtime.c
index 8f7b6eae7..12d4e48e0 100644
--- a/engine/quickjs-c/pocket_runtime.c
+++ b/engine/quickjs-c/pocket_runtime.c
@@ -708,6 +708,7 @@ static int run_frame(
uint32_t buttons,
const PocketRuntimeContact *contacts,
unsigned int contact_count,
+ const int *cancelled, unsigned int cancelled_count,
unsigned int tick_count
) {
unsigned int tick;
@@ -735,12 +736,7 @@ static int run_frame(
}
for (index = 0; index < contact_count; index += 1) {
const PocketRuntimeContact *contact = &contacts[index];
- uint32_t id = (uint32_t)(contact->id & 0xff);
- uint32_t x = (uint32_t)(contact->x < 0 ? 0 : contact->x > 1023 ? 1023 : contact->x);
- uint32_t y = (uint32_t)(contact->y < 0 ? 0 : contact->y > 1023 ? 1023 : contact->y);
- uint32_t packed = x > 511 || y > 511
- ? 0x80000000U | (id << 20) | (y << 10) | x
- : (id << 18) | (y << 9) | x;
+ uint32_t packed = pocket_runtime_pack_contact(contact);
if (JS_SetPropertyUint32(
context,
touch_array,
@@ -760,6 +756,13 @@ static int run_frame(
return 0;
}
}
+ for (index = 0; index < cancelled_count && index < POCKET_RUNTIME_MAX_CONTACTS; index++) {
+ if (JS_SetPropertyUint32(context, touch_array, contact_count + index,
+ JS_NewInt32(context, (int32_t)pocket_runtime_pack_cancel(cancelled[index]))) < 0) {
+ JS_FreeValue(context, hit_array); JS_FreeValue(context, touch_array);
+ take_exception(context); runtime_failed = 1; return 0;
+ }
+ }
JSValue arguments[4] = {
JS_NewUint32(context, buttons),
JS_NewInt32(context, POCKET_ANALOG_CENTER),
@@ -816,12 +819,12 @@ int pocket_runtime_tick(const PocketRuntimeInput *input) {
input->touch_y,
input->touch_hit
);
- return run_frame(input->buttons, &contact, count, 1);
+ return run_frame(input->buttons, &contact, count, NULL, 0, 1);
}
int pocket_runtime_tick_contacts(const PocketRuntimeContactsInput *input) {
if (input == 0) return 0;
- return run_frame(input->buttons, input->contacts, input->contact_count, 1);
+ return run_frame(input->buttons, input->contacts, input->contact_count, input->cancelled, input->cancelled_count, 1);
}
int pocket_runtime_frame_contacts(
@@ -829,7 +832,7 @@ int pocket_runtime_frame_contacts(
unsigned int tick_count
) {
if (input == 0) return 0;
- return run_frame(input->buttons, input->contacts, input->contact_count, tick_count);
+ return run_frame(input->buttons, input->contacts, input->contact_count, input->cancelled, input->cancelled_count, tick_count);
}
int pocket_runtime_frame_ticks(
@@ -841,7 +844,7 @@ int pocket_runtime_frame_ticks(
) {
PocketRuntimeContact contact;
unsigned int count = single_contact(&contact, touch_down, touch_x, touch_y, touch_hit);
- return run_frame(0, &contact, count, tick_count);
+ return run_frame(0, &contact, count, NULL, 0, tick_count);
}
int pocket_runtime_frame(int touch_down, int touch_x, int touch_y, int touch_hit) {
diff --git a/engine/quickjs-c/pocket_runtime.h b/engine/quickjs-c/pocket_runtime.h
index a9f7fbae5..3047c3899 100644
--- a/engine/quickjs-c/pocket_runtime.h
+++ b/engine/quickjs-c/pocket_runtime.h
@@ -56,7 +56,7 @@ int pocket_runtime_tick(const PocketRuntimeInput *input);
* frame() wire words — legacy x:9/y:9/id:8 below 512 logical pixels, the
* wide bit-31 form above — so a single id-0 contact is byte-identical to the
* single-touch entry points and every existing tape. The guest snapshot caps
- * at eight contacts (framework/src/touch.ts).
+ * at eight active contacts, plus eight terminal cancellations (framework/src/touch.ts).
*/
#define POCKET_RUNTIME_MAX_CONTACTS 8
typedef struct {
@@ -69,7 +69,21 @@ typedef struct {
uint32_t buttons;
unsigned int contact_count;
PocketRuntimeContact contacts[POCKET_RUNTIME_MAX_CONTACTS];
+ unsigned int cancelled_count;
+ int cancelled[POCKET_RUNTIME_MAX_CONTACTS];
} PocketRuntimeContactsInput;
+/* Bit 30 is a terminal CANCEL record, not an active contact. At most eight
+ * active contacts and eight cancellations travel in one frame's touch words. */
+#define POCKET_TOUCH_CANCEL_WORD 0x40000000U
+static inline uint32_t pocket_runtime_pack_contact(const PocketRuntimeContact *contact) {
+ uint32_t id = (uint32_t)(contact->id & 255);
+ uint32_t x = (uint32_t)(contact->x < 0 ? 0 : contact->x > 1023 ? 1023 : contact->x);
+ uint32_t y = (uint32_t)(contact->y < 0 ? 0 : contact->y > 1023 ? 1023 : contact->y);
+ return x > 511 || y > 511 ? 0x80000000U | (id << 20) | (y << 10) | x : (id << 18) | (y << 9) | x;
+}
+static inline uint32_t pocket_runtime_pack_cancel(int id) {
+ return POCKET_TOUCH_CANCEL_WORD | ((uint32_t)(id & 255) << 18);
+}
int pocket_runtime_tick_contacts(const PocketRuntimeContactsInput *input);
int pocket_runtime_frame_contacts(
const PocketRuntimeContactsInput *input,
diff --git a/framework/src/devtools.ts b/framework/src/devtools.ts
index fe8004488..0f9349f5f 100644
--- a/framework/src/devtools.ts
+++ b/framework/src/devtools.ts
@@ -291,7 +291,7 @@ function recordMask(
const right = rightAnalog ?? ANALOG_CENTER;
if (right !== ANALOG_CENTER && !state.tapeRightAnalog) state.tapeRightAnalog = new Uint16Array(TAPE_CAP).fill(ANALOG_CENTER);
// Defensive copy: hosts may reuse the packed-contact buffer across frames.
- const contacts = touch && touch.length > 0 ? touch.slice(0, 8) : null;
+ const contacts = touch && touch.length > 0 ? touch.slice(0, 16) : null;
if (contacts && !state.tapeTouch) {
// First contact of the session: allocate the ring (touch-free sessions
// never reach here). Frames recorded before this point had no contacts.
diff --git a/framework/src/font-coverage.ts b/framework/src/font-coverage.ts
new file mode 100644
index 000000000..a784428b1
--- /dev/null
+++ b/framework/src/font-coverage.ts
@@ -0,0 +1,28 @@
+import { FONT_MAGIC, FONT_HEADER_SIZE, FONT_CMAP_ENTRY_SIZE } from "../../contracts/spec/spec.ts";
+import { entries, get } from "./pak.ts";
+
+/** Read each slot's baked cmap once, without copying its coverage pixels.
+ * Coverage is a property of the shipped atlas, not Unicode's ASCII range. */
+export function createBakedFontCoverage() {
+ const slots = new Map>();
+ return (scalar: string, slot: number): boolean => {
+ let coverage = slots.get(slot);
+ if (!coverage) {
+ coverage = new Set();
+ const key = `ui:font.${slot}`;
+ if (entries(key).includes(key)) {
+ const header = new DataView(get(key, 0, FONT_HEADER_SIZE).buffer);
+ const version = header.getUint16(4, true), count = header.getUint16(6, true);
+ if (header.getUint32(0, true) === FONT_MAGIC && (version === 2 || version === 3) && header.getUint8(12) === slot) {
+ const cmap = new DataView(get(key, FONT_HEADER_SIZE, FONT_HEADER_SIZE + count * FONT_CMAP_ENTRY_SIZE).buffer);
+ for (let i = 0; i < count; i++) {
+ const offset = i * FONT_CMAP_ENTRY_SIZE;
+ if (cmap.getUint16(offset + 4, true) !== 0) coverage.add(cmap.getUint32(offset, true));
+ }
+ }
+ }
+ slots.set(slot, coverage);
+ }
+ return coverage.has(scalar.codePointAt(0)!);
+ };
+}
diff --git a/framework/src/gesture-core.ts b/framework/src/gesture-core.ts
index 7e6e9c7a2..475599a32 100644
--- a/framework/src/gesture-core.ts
+++ b/framework/src/gesture-core.ts
@@ -57,7 +57,7 @@ import { simulationHz, virtualFrame } from "./clock.ts";
import type { SurfaceId } from "./display.ts";
import { resolveTouchHit } from "./input.ts";
import type { NodeMirror } from "./renderer.ts";
-import { __allTouches } from "./touch.ts";
+import { __allTouches, __cancelledTouches } from "./touch.ts";
export type GesturePhase = "down" | "move" | "up" | "cancel";
@@ -658,7 +658,17 @@ export function __runGestures(): void {
const snap = __allTouches();
if (snap.length === 0 && liveCount === 0) return;
- for (const t of tracks) t.present = false;
+ // Retire old lifetimes before allocating new ones: cancellation must stop
+ // a pending Space before a new DOWN can consume it as a two-thumb chord.
+ // This also frees all eight slots when every contact changes in one frame.
+ const cancelled = __cancelledTouches();
+ for (const t of tracks) {
+ if (t.used && cancelled.some(c => c.surface === t.surface && c.id === t.id)) {
+ for (const rec of t.owners) if (rec.flags[t.slot] & OBSERVING) fireCancel(rec, t);
+ releaseTrack(t);
+ } else if (t.used && !snap.some(c => c.surface === t.surface && c.id === t.id)) finishTrack(t);
+ t.present = false;
+ }
for (const c of snap) {
let found: Track | null = null;
diff --git a/framework/src/pak.ts b/framework/src/pak.ts
index a7f43a219..7d60fa6d3 100644
--- a/framework/src/pak.ts
+++ b/framework/src/pak.ts
@@ -101,8 +101,8 @@ export function entries(prefix = ""): string[] {
return out;
}
-/** Raw bytes of a blob as a fresh Uint8Array (copy); throws on a missing key. */
-export function get(key: string): Uint8Array {
+/** Raw bytes (or a bounded byte range) as a fresh copy; throws if absent. */
+export function get(key: string, start = 0, end?: number): Uint8Array {
ensureLoaded();
const e = map ? map.get(key) : undefined;
if (!e) {
@@ -113,7 +113,10 @@ export function get(key: string): Uint8Array {
);
}
// .slice() copies into a fresh, offset-0, length-exact ArrayBuffer.
- return bytes!.slice(e.off, e.off + e.len);
+ const stop = end ?? e.len;
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(stop) || start < 0 || stop < start || stop > e.len)
+ throw new RangeError("pak: invalid byte range");
+ return bytes!.slice(e.off + start, e.off + stop);
}
/** Advisory element dtype (spec PAK_DTYPE) of a blob; throws if absent. */
diff --git a/framework/src/text.ts b/framework/src/text.ts
index 54b36a717..256f14bee 100644
--- a/framework/src/text.ts
+++ b/framework/src/text.ts
@@ -2,6 +2,7 @@ import { TEXT, type TextFace, type TextGlyph, type TextGlyphRequest } from "../.
import { createResourceScheduler, type ResourceDemand } from "./resource-cache.ts";
import { offloadResource } from "./resource-offload.ts";
import { offload, uploadCoverage } from "./offload.ts";
+import { createBakedFontCoverage } from "./font-coverage.ts";
import { getOps } from "./host.ts";
import { registerServicePump } from "./services.ts";
@@ -17,10 +18,12 @@ type Channel = Pick, "request" | "cancel" | "session"
export function createTextResources(options: {
io: Channel;
measure(text: string, slot: number): number;
+ local?(scalar: string, slot: number): boolean;
upload(mask: string, width: number, height: number): number | undefined;
free(handle: number): void;
maxGlyphs?: number;
}) {
+ const local = options.local ?? ((scalar: string) => /^[\x20-\x7e]$/.test(scalar));
const io = options.io, limit = options.maxGlyphs ?? TEXT.maxGlyphs;
if (!Number.isInteger(limit) || limit < 1 || limit > TEXT.maxGlyphs) throw new Error("Invalid text cache budget");
let face = "", session = 0, faceRequest = 0, facePending = false, retry = 0, dead = false;
@@ -75,9 +78,15 @@ export function createTextResources(options: {
let x = 0, start = 0, pending = false;
// Same scalar cmap model as the core's baked fonts. Shaping runs and
// grapheme caret boundaries must come from a shaper, not this loop.
- for (const token of text.match(/[\x20-\x7e]+|[^\x20-\x7e]/gu) ?? []) {
+ const tokens: { text: string; local: boolean }[] = [];
+ for (const scalar of text) {
+ const baked = local(scalar, style.fontSlot), previous = tokens.at(-1);
+ if (baked && previous?.local) previous.text += scalar;
+ else tokens.push({ text: scalar, local: baked });
+ }
+ for (const { text: token, local: baked } of tokens) {
const end = start + token.length;
- if (/^[\x20-\x7e]+$/.test(token)) {
+ if (baked) {
const width = options.measure(token, style.fontSlot);
parts.push({ kind: "local", text: token, x, width, start, end }); x += width;
} else {
@@ -143,7 +152,7 @@ let resources: ReturnType | undefined;
/** One cache and upload scheduler shared by labels in every UI framework. */
export function textResources() {
if (!resources) {
- resources = createTextResources({ io: offload(), measure: (s, slot) => getOps().measureText(s, slot),
+ resources = createTextResources({ io: offload(), local: createBakedFontCoverage(), measure: (s, slot) => getOps().measureText(s, slot),
upload: (mask, w, h) => uploadCoverage(mask, w, h, 0xffffffff), free: h => getOps().freeTexture?.(h) });
registerServicePump(() => resources!.step());
}
diff --git a/framework/src/touch.ts b/framework/src/touch.ts
index 0184e5519..8449ecad6 100644
--- a/framework/src/touch.ts
+++ b/framework/src/touch.ts
@@ -29,43 +29,52 @@ const WIDE_MARKER = 0x80000000;
const WIDE_COORD_BITS = 10;
const WIDE_COORD_MASK = (1 << WIDE_COORD_BITS) - 1;
const WIDE_ID_SHIFT = WIDE_COORD_BITS * 2;
+const CANCEL_MARKER = 0x40000000;
const EMPTY: readonly TouchContact[] = Object.freeze([]);
let primarySnapshot: readonly TouchContact[] = EMPTY;
let auxiliarySnapshot: readonly TouchContact[] = EMPTY;
let allSnapshot: readonly TouchContact[] = EMPTY;
+let cancelledSnapshot: readonly TouchContact[] = EMPTY;
/**
* Internal host-frame hook.
*
* Existing hosts pack x:9, y:9, id:8 with bit 31 clear. Native viewports
* wider than 512 use the append-only wide form: bit31=1, x:10, y:10, id:8.
- * Per-contact detection keeps every PSP/Vita tape and host byte-compatible.
+ * Bit 30 carries a terminal CANCEL; at most eight active contacts plus eight
+ * cancellations fit in one frame. Existing words keep their byte encoding.
*/
export function __setTouches(
packed: readonly number[] | undefined,
hits?: readonly number[],
surfaces?: readonly number[],
): void {
+ cancelledSnapshot = EMPTY;
if (!packed || packed.length === 0) {
primarySnapshot = EMPTY;
auxiliarySnapshot = EMPTY;
allSnapshot = EMPTY;
return;
}
- const all = packed.slice(0, 8).map((value, index) => {
- const wide = (value & WIDE_MARKER) !== 0;
- const coordBits = wide ? WIDE_COORD_BITS : LEGACY_COORD_BITS;
- const coordMask = wide ? WIDE_COORD_MASK : LEGACY_COORD_MASK;
- const idShift = wide ? WIDE_ID_SHIFT : LEGACY_ID_SHIFT;
- return Object.freeze({
- surface: surfaces?.[index] === 1 ? "auxiliary" as const : "primary" as const,
- id: (value >>> idShift) & 0xff,
- x: value & coordMask,
- y: (value >>> coordBits) & coordMask,
- hit: hits?.[index],
- });
- });
+ const all: TouchContact[] = [], cancelled: TouchContact[] = [];
+ for (let index = 0; index < Math.min(packed.length, 16); index++) {
+ const value = packed[index];
+ const output = value & CANCEL_MARKER ? cancelled : all;
+ if (output.length >= 8) continue;
+ const wide = (value & WIDE_MARKER) !== 0;
+ const coordBits = wide ? WIDE_COORD_BITS : LEGACY_COORD_BITS;
+ const coordMask = wide ? WIDE_COORD_MASK : LEGACY_COORD_MASK;
+ const idShift = wide ? WIDE_ID_SHIFT : LEGACY_ID_SHIFT;
+ output.push(Object.freeze({
+ surface: surfaces?.[index] === 1 ? "auxiliary" as const : "primary" as const,
+ id: (value >>> idShift) & 0xff,
+ x: value & coordMask,
+ y: (value >>> coordBits) & coordMask,
+ hit: hits?.[index],
+ }));
+ }
+ cancelledSnapshot = Object.freeze(cancelled);
allSnapshot = Object.freeze(all);
primarySnapshot = Object.freeze(all.filter((contact) => contact.surface === "primary"));
auxiliarySnapshot = Object.freeze(all.filter((contact) => contact.surface === "auxiliary"));
@@ -81,12 +90,16 @@ export function auxiliaryTouches(): readonly TouchContact[] {
return auxiliarySnapshot;
}
+/** Terminal system cancellations; never exposed as active touches. */
+export function __cancelledTouches(): readonly TouchContact[] { return cancelledSnapshot; }
+
/** Internal gesture stream across every simultaneously presented surface. */
export function __allTouches(): readonly TouchContact[] {
return allSnapshot;
}
export function __resetTouches(): void {
+ cancelledSnapshot = EMPTY;
primarySnapshot = EMPTY;
auxiliarySnapshot = EMPTY;
allSnapshot = EMPTY;
@@ -118,7 +131,8 @@ export function createTouchHitFacts(
return undefined;
}
const seen = new Set();
- const hits = packed.slice(0, 8).map((value) => {
+ const hits = packed.slice(0, 16).map((value) => {
+ if (value & CANCEL_MARKER) return 0;
const wide = (value & WIDE_MARKER) !== 0;
const coordBits = wide ? WIDE_COORD_BITS : LEGACY_COORD_BITS;
const coordMask = wide ? WIDE_COORD_MASK : LEGACY_COORD_MASK;
@@ -145,3 +159,6 @@ export function __packTouchWide(id: number, x: number, y: number): number {
(x & WIDE_COORD_MASK)
) >>> 0;
}
+
+/** Test/TS-host helper for a terminal cancellation on the touch wire. */
+export function __packTouchCancel(id: number): number { return (CANCEL_MARKER | ((id & 255) << LEGACY_ID_SHIFT)) >>> 0; }
diff --git a/hosts/ios-legacy/runtime.c b/hosts/ios-legacy/runtime.c
index 3df43b58f..6e34cceeb 100644
--- a/hosts/ios-legacy/runtime.c
+++ b/hosts/ios-legacy/runtime.c
@@ -1,4 +1,5 @@
#include "pocket_runtime.h"
+#include "../shared/contact_latch.h"
/* The svc transport's state, for the acceptance record: "absent" on builds
* without the network channel, else discover / connecting / hello / up /
* up-usb / backoff (svcwire.c). */
@@ -236,13 +237,12 @@ typedef struct {
int live;
int ending;
int was_sent;
- int needs_hit;
- int hit;
int x;
int y;
} PocketTouchSlot;
static PocketTouchSlot g_touch_slots[POCKET_TOUCH_SLOT_COUNT];
+static PocketContactLatch g_contacts;
/* Most recent contact position and hit, kept for the acceptance record. */
static int g_touch_x;
static int g_touch_y;
@@ -1295,27 +1295,10 @@ static void pocket_tick(id self, SEL command, id timer) {
return;
}
- {
- int index;
- frame_input.buttons = 0;
- frame_input.contact_count = 0;
- for (index = 0; index < POCKET_TOUCH_SLOT_COUNT; index += 1) {
- PocketTouchSlot *slot = &g_touch_slots[index];
- PocketRuntimeContact *contact;
- if (!slot->live && !slot->ending) continue;
- if (slot->needs_hit) {
- slot->hit = pocket_runtime_hit_test_bounds((float)slot->x, (float)slot->y);
- g_last_touch_hit = slot->hit;
- slot->needs_hit = 0;
- }
- contact = &frame_input.contacts[frame_input.contact_count];
- contact->id = index;
- contact->x = slot->x;
- contact->y = slot->y;
- contact->hit = slot->hit;
- frame_input.contact_count += 1;
- }
- }
+ memset(&frame_input, 0, sizeof frame_input);
+ pocket_contacts_sample(&g_contacts, &frame_input, POCKET_LOGICAL_WIDTH, POCKET_LOGICAL_HEIGHT,
+ POCKET_LOGICAL_WIDTH, POCKET_LOGICAL_HEIGHT, pocket_runtime_hit_test_bounds);
+ if (frame_input.contact_count) g_last_touch_hit = frame_input.contacts[frame_input.contact_count - 1].hit;
delivered_touch = frame_input.contact_count > 0;
frame_started_us = now_us();
if (!pocket_runtime_frame_contacts(&frame_input, 2)) {
@@ -1334,8 +1317,6 @@ static void pocket_tick(id self, SEL command, id timer) {
/* The release-latched contact has now been delivered once. */
slot->ending = 0;
slot->was_sent = 0;
- slot->needs_hit = 0;
- slot->hit = 0;
}
}
}
@@ -1463,6 +1444,8 @@ static PocketTouchSlot *touch_slot_for(id touch) {
}
static void touch_slot_begin(PocketTouchSlot *slot, id touch, int x, int y) {
+ if (!pocket_contact_event(&g_contacts, POCKET_TOUCH_DOWN, (int)(slot - g_touch_slots),
+ (float)x, (float)y, POCKET_LOGICAL_WIDTH, POCKET_LOGICAL_HEIGHT)) return;
slot->touch = touch;
slot->live = 1;
slot->ending = 0;
@@ -1474,12 +1457,8 @@ static void touch_slot_begin(PocketTouchSlot *slot, id touch, int x, int y) {
g_touch_sequences += 1;
g_touch_awaiting_completion = 0;
if (g_state == POCKET_STATE_RUNNING) {
- slot->hit = pocket_runtime_hit_test_bounds((float)x, (float)y);
- slot->needs_hit = 0;
- g_last_touch_hit = slot->hit;
- } else {
- slot->hit = 0;
- slot->needs_hit = 1;
+ g_last_touch_hit = pocket_runtime_hit_test_bounds((float)x, (float)y);
+ pocket_contact_hit(&g_contacts, (int)(slot - g_touch_slots), g_last_touch_hit);
}
}
@@ -1493,6 +1472,8 @@ static PocketTouchSlot *touch_slot_allocate(void) {
}
static void touch_slot_move(PocketTouchSlot *slot, int x, int y) {
+ pocket_contact_event(&g_contacts, POCKET_TOUCH_MOVE, (int)(slot - g_touch_slots),
+ (float)x, (float)y, POCKET_LOGICAL_WIDTH, POCKET_LOGICAL_HEIGHT);
slot->x = x;
slot->y = y;
g_touch_x = x;
@@ -1502,6 +1483,8 @@ static void touch_slot_move(PocketTouchSlot *slot, int x, int y) {
static void touch_slot_end(PocketTouchSlot *slot, int x, int y) {
if (!slot->live) return;
touch_slot_move(slot, x, y);
+ pocket_contact_event(&g_contacts, POCKET_TOUCH_UP, (int)(slot - g_touch_slots),
+ (float)x, (float)y, POCKET_LOGICAL_WIDTH, POCKET_LOGICAL_HEIGHT);
slot->touch = NULL;
slot->live = 0;
if (touch_live_count() == 0) {
@@ -1509,8 +1492,6 @@ static void touch_slot_end(PocketTouchSlot *slot, int x, int y) {
}
if (slot->was_sent) {
slot->ending = 0;
- slot->needs_hit = 0;
- slot->hit = 0;
} else {
/* Keep a very short tap alive until at least one delivered guest frame. */
slot->ending = 1;
@@ -1597,6 +1578,19 @@ static void visit_touch_ended(id self, id touch, int x, int y) {
if (slot != NULL) touch_slot_end(slot, x, y);
}
+static void visit_touch_cancelled(id self, id touch, int x, int y) {
+ PocketTouchSlot *slot = touch_slot_for(touch);
+ (void)self;
+ if (!slot) return;
+ pocket_contact_event(&g_contacts, POCKET_TOUCH_CANCEL, (int)(slot - g_touch_slots),
+ (float)x, (float)y, POCKET_LOGICAL_WIDTH, POCKET_LOGICAL_HEIGHT);
+ memset(slot, 0, sizeof *slot);
+}
+static void pocket_touches_cancelled(id self, SEL command, id touches, id event) {
+ (void)command; (void)event;
+ visit_touches(self, touches, visit_touch_cancelled);
+}
+
static void pocket_touches_began(
id self,
SEL command,
@@ -1918,7 +1912,7 @@ static Class register_view_class(void) {
class_addMethod(
cls,
sel_registerName("touchesCancelled:withEvent:"),
- (void (*)(void))pocket_touches_ended,
+ (void (*)(void))pocket_touches_cancelled,
"v@:@@"
) &&
class_addMethod(
diff --git a/hosts/shared/contact_latch.h b/hosts/shared/contact_latch.h
index 6b3cc20a7..d732d3852 100644
--- a/hosts/shared/contact_latch.h
+++ b/hosts/shared/contact_latch.h
@@ -9,14 +9,16 @@
* A released stream can coexist with a new stream using the same platform ID.
* The caller serializes events/sampling and supplies the native bounds hit. */
typedef struct {
- int used, platform_id, id, ending, sampled;
- float x, y;
+ int used, platform_id, id, ending, sampled, hit_ready;
+ float x, y, start_x, start_y;
int hit;
} PocketLatchedContact;
typedef struct {
PocketLatchedContact contacts[POCKET_RUNTIME_MAX_CONTACTS];
unsigned next_id, previous_count;
int previous[POCKET_RUNTIME_MAX_CONTACTS];
+ unsigned cancelled_count;
+ int cancelled[POCKET_RUNTIME_MAX_CONTACTS];
} PocketContactLatch;
static inline int pocket_contact_id_used(const PocketContactLatch *state, int id) {
@@ -25,6 +27,17 @@ static inline int pocket_contact_id_used(const PocketContactLatch *state, int id
for (unsigned i = 0; i < state->previous_count; i++) if (state->previous[i] == id) return 1;
return 0;
}
+/* Keep resident entries in DOWN order, independent of reusable storage. */
+static inline void pocket_contact_remove(PocketContactLatch *state, PocketLatchedContact *contact) {
+ unsigned index = (unsigned)(contact - state->contacts);
+ memmove(contact, contact + 1, (POCKET_RUNTIME_MAX_CONTACTS - index - 1) * sizeof *contact);
+ memset(&state->contacts[POCKET_RUNTIME_MAX_CONTACTS - 1], 0, sizeof *contact);
+}
+static inline void pocket_contact_cancel(PocketContactLatch *state, PocketLatchedContact *contact) {
+ if (contact->sampled && state->cancelled_count < POCKET_RUNTIME_MAX_CONTACTS)
+ state->cancelled[state->cancelled_count++] = contact->id;
+ pocket_contact_remove(state, contact);
+}
/* Return 1 only when a new contact is admitted. At capacity, reclaim a sampled
* release; never evict a held finger or an unsampled ordinary tap. */
static inline int pocket_contact_event(PocketContactLatch *state, PocketTouchPhase phase, int platform_id,
@@ -35,7 +48,7 @@ static inline int pocket_contact_event(PocketContactLatch *state, PocketTouchPha
if (c->used && !c->ending && c->platform_id == platform_id) { contact = c; break; }
}
if (phase == POCKET_TOUCH_CANCEL) {
- if (contact) memset(contact, 0, sizeof *contact);
+ if (contact) pocket_contact_cancel(state, contact);
return 0;
}
if (!isfinite(x) || !isfinite(y)) return 0;
@@ -46,12 +59,15 @@ static inline int pocket_contact_event(PocketContactLatch *state, PocketTouchPha
if (!c->used || (c->ending && c->sampled)) { contact = c; break; }
}
if (!contact) return 0;
- memset(contact, 0, sizeof *contact);
+ if (contact->used) pocket_contact_remove(state, contact);
+ // Append after all earlier DOWNs, including an unsampled short tap.
+ for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++)
+ if (!state->contacts[i].used) { contact = &state->contacts[i]; break; }
// At most 16 IDs are reserved by the resident and previous-frame sets.
int id;
do { id = (int)(state->next_id++ & 255); } while (pocket_contact_id_used(state, id));
contact->used = 1; contact->platform_id = platform_id; contact->id = id;
- contact->x = x; contact->y = y;
+ contact->x = contact->start_x = x; contact->y = contact->start_y = y;
return 1;
}
if (contact) {
@@ -60,20 +76,37 @@ static inline int pocket_contact_event(PocketContactLatch *state, PocketTouchPha
}
return 0;
}
-/* Cancellation has no tap latch. Previously delivered contacts disappear in
- * the next snapshot; a contact cancelled before sampling is never delivered. */
+/* Hosts whose input runs on the render thread can capture the committed
+ * bounds at DOWN. Other hosts resolve the DOWN position at first sampling. */
+static inline void pocket_contact_hit(PocketContactLatch *state, int platform_id, int hit) {
+ for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++) {
+ PocketLatchedContact *c = &state->contacts[i];
+ if (c->used && !c->ending && c->platform_id == platform_id && !c->hit_ready) {
+ c->hit = hit; c->hit_ready = 1; return;
+ }
+ }
+}
+/* Cancellation has no tap latch. Published IDs carry a terminal cancellation
+ * alongside the next active snapshot; unsampled contacts are discarded. */
static inline void pocket_contacts_cancel(PocketContactLatch *state) {
- memset(state->contacts, 0, sizeof state->contacts);
+ while (state->contacts[0].used) pocket_contact_cancel(state, &state->contacts[0]);
}
static inline void pocket_contacts_sample(PocketContactLatch *state, PocketRuntimeContactsInput *frame,
int width, int height, int logical_width, int logical_height, int (*hit_test)(float, float)) {
frame->contact_count = 0;
+ frame->cancelled_count = state->cancelled_count;
+ memcpy(frame->cancelled, state->cancelled, state->cancelled_count * sizeof *state->cancelled);
+ state->cancelled_count = 0;
for (unsigned i = 0; i < POCKET_RUNTIME_MAX_CONTACTS; i++) {
PocketLatchedContact *contact = &state->contacts[i];
if (!contact->used) continue;
- if (contact->ending && contact->sampled) { memset(contact, 0, sizeof *contact); continue; }
+ if (contact->ending && contact->sampled) { pocket_contact_remove(state, contact); i--; continue; }
int x = (int)(contact->x * logical_width / width), y = (int)(contact->y * logical_height / height);
- if (!contact->sampled) contact->hit = hit_test((float)x, (float)y);
+ if (!contact->hit_ready) {
+ contact->hit = hit_test((float)(int)(contact->start_x * logical_width / width),
+ (float)(int)(contact->start_y * logical_height / height));
+ contact->hit_ready = 1;
+ }
PocketRuntimeContact *out = &frame->contacts[frame->contact_count++];
out->id = contact->id; out->x = x; out->y = y; out->hit = contact->hit;
contact->sampled = 1;
diff --git a/tests/clear-text.test.ts b/tests/clear-text.test.ts
new file mode 100644
index 000000000..65b709603
--- /dev/null
+++ b/tests/clear-text.test.ts
@@ -0,0 +1,104 @@
+import { afterAll, expect, test } from "bun:test";
+import { bootWorld, treeHasText } from "../hosts/sim/sim.ts";
+import { __packTouchWide, __packTouchCancel } from "../framework/src/touch.ts";
+import { KB_LAYERS } from "../apps/clear/kb-layout.ts";
+import { KB_H, KB_PAD, KB_ROW_H, KB_GAP } from "../apps/clear/keyboard-metrics.ts";
+import { PROP } from "../contracts/spec/spec.ts";
+import { IME } from "../contracts/spec/ime.ts";
+import type { OffloadRequest } from "../contracts/spec/offload.ts";
+import type { HostOps } from "../framework/src/host.ts";
+
+afterAll(() => { delete (globalThis as { offload?: unknown }).offload; });
+type Tree = { i: number; x?: string; k?: Tree[] };
+function ancestors(tree: Tree, text: string): Tree[] {
+ if (tree.x === text) return [tree];
+ for (const child of tree.k ?? []) { const path = ancestors(child, text); if (path.length) return [tree, ...path]; }
+ return [];
+}
+for (const connected of [false, true]) test(`Clear text layout owns offline symbols and completed-row width (connected=${connected})`, async () => {
+ const width = 360, height = 800, replies: string[] = [], held: OffloadRequest[] = [], sent: OffloadRequest[] = [];
+ const props = new Map>(), nativeText = new Set();
+ let allowTitle = false, ops: HostOps;
+ const answer = (r: OffloadRequest) => {
+ const g = JSON.parse(r.payload), width = g.size * 2, height = 64;
+ replies.push(JSON.stringify({ id: r.id, payload: JSON.stringify({ face: g.face, advance: g.size === 20 ? 17 : 16,
+ xoff: 0, width, height, mask: Buffer.alloc(width * height / 4, 255).toString("base64") }) }));
+ };
+ const world = await bootWorld("clear-main.vue-vapor", 60, { offload: {
+ session: () => connected ? 1 : 0, take: () => replies.shift(),
+ submit(raw: string) {
+ const r = JSON.parse(raw) as OffloadRequest; sent.push(r);
+ if (r.method === "text.font") replies.push(JSON.stringify({ id: r.id, payload: JSON.stringify({ id: "a".repeat(64), mapping: "scalar" }) }));
+ if (r.method === "text.glyph") { if (JSON.parse(r.payload).size === 20 && !allowTitle) held.push(r); else answer(r); }
+ if (r.method === "ime.compose") {
+ const keys = JSON.parse(r.payload) as number[], selected = keys.some(k => k >= IME.select);
+ replies.push(JSON.stringify({ id: r.id, payload: JSON.stringify({ preedit: selected ? "" : "n", raw: selected ? "" : "n", rawCaret: selected ? 0 : 1,
+ caret: selected ? 0 : 1, commit: selected ? "你好世界" : "", candidates: selected ? [] : ["你好世界"], page: 0, last: true }) }));
+ }
+ return true;
+ },
+ uploadCoverage(mask: string, w: number, h: number) { const envelope = 2 ** Math.ceil(Math.log2(w)); return ops.uploadTexture(new Uint8Array(envelope * h * 4).fill(255), envelope, h, 3); },
+ } }, native => {
+ ops = native as unknown as HostOps;
+ for (const method of ["setText", "replaceText"] as const) {
+ const original = ops[method].bind(ops);
+ ops[method] = (id, text) => { nativeText.add(text); return original(id, text); };
+ }
+ const record = (id: number, prop: number, value: number) => { if (!props.has(id)) props.set(id, new Map()); props.get(id)!.set(prop, value); };
+ const set = ops.setProp.bind(ops), batch = ops.setPropBatch?.bind(ops);
+ ops.setProp = (id, prop, value) => { record(id, prop, value); set(id, prop, value); };
+ if (batch) ops.setPropBatch = records => { const values = new Float64Array(records); for (let i = 0; i < values.length; i += 3) record(values[i], values[i + 1], values[i + 2]); batch(records); };
+ }, { width, height, rasterDensity: 2 });
+ async function step(touch?: number[]) { world.frame(0, undefined, touch); world.tick(); await Promise.resolve(); }
+ async function idle(n = 25) { for (let i = 0; i < n; i++) await step(); }
+ async function tap(x: number, y: number) { await step([__packTouchWide(0, x, y)]); await step(); }
+ const keyY = (row: number) => height - KB_H + KB_PAD + row * (KB_ROW_H + KB_GAP) + KB_ROW_H / 2;
+ const key = async (layer: keyof typeof KB_LAYERS, label: string) => {
+ for (let row = 0; row < KB_LAYERS[layer].length; row++) for (const k of KB_LAYERS[layer][row]) if ((k.label ?? k.ch) === label) {
+ await tap((k.x + k.w / 2) * width / 320, keyY(row)); return;
+ }
+ throw new Error(`missing key ${label}`);
+ };
+ await idle(); await tap(100, 31); await idle(); await tap(100, 31); await idle();
+ // System CANCEL after a sampled Space must leave the full editor unchanged.
+ const before = JSON.stringify(world.getTree());
+ await step([__packTouchWide(0, 166 * width / 320, keyY(3))]);
+ await step([__packTouchCancel(0)]); await idle(3);
+ expect(treeHasText(world.getTree(), "Swipe right to complete|")).toBe(true);
+ expect(treeHasText(world.getTree(), "Swipe right to complete |")).toBe(false);
+ expect(before).toContain("Swipe right to complete");
+ // Remove the demo title through the real keyboard.
+ for (let i = 0; i < "Swipe right to complete".length; i++) await tap(width - 20, keyY(2));
+ if (!connected) {
+ await key("lower", "123"); await key("numbers", "#+=");
+ for (const symbol of "£¥€•") await key("symbols", symbol);
+ await idle(); expect(treeHasText(world.getTree(), "£¥€•|")).toBe(true);
+ // Local text creates native text children, with no glyph placeholder cells.
+ expect(sent).toEqual([]); expect(nativeText.has("£¥€•|")).toBe(true);
+ await tap(width - 20, keyY(3)); await idle();
+ expect(treeHasText(world.getTree(), "£¥€•")).toBe(true);
+ } else {
+ await key("lower", "n"); await idle();
+ await step([__packTouchWide(0, 166 * width / 320, keyY(3))]);
+ await step([__packTouchCancel(0)]); await idle(3);
+ expect(sent.filter(r => r.method === "ime.compose").some(r => (JSON.parse(r.payload) as number[]).some(k => k >= IME.select))).toBe(false);
+ await tap(20, height - KB_H - 22); await idle(8);
+ await tap(width - 20, keyY(3)); await idle();
+ expect(treeHasText(world.getTree(), "你好世界")).toBe(true);
+ // Completion occurs while title glyph metrics are still unavailable.
+ for (let i = 0; i <= 16; i++) await step([__packTouchWide(0, 30 + i * 15, 31)]);
+ await step(); await idle(40);
+ }
+ const title = connected ? "你好世界" : "£¥€•";
+ // The front contains the text container and strike as siblings.
+ const strike = ancestors(world.getTree() as Tree, title).flatMap(n => n.k ?? []).find(c => props.get(c.i)?.get(PROP.insetT) === 30 && props.get(c.i)?.get(PROP.height) === 2);
+ expect(strike).toBeDefined();
+ if (connected) {
+ expect(props.get(strike!.i)!.get(PROP.width)).toBe(80);
+ expect(props.get(strike!.i)!.get(PROP.scaleX)).toBe(1);
+ allowTitle = true; for (const r of held.splice(0)) answer(r); await idle(100);
+ expect(props.get(strike!.i)!.get(PROP.width)).toBe(68);
+ const drawn = ancestors(world.getTree() as Tree, title).at(-1)!;
+ expect(drawn.k!.map(c => props.get(c.i)!.get(PROP.insetL))).toEqual([0, 17, 34, 51]);
+ } else expect(props.get(strike!.i)!.get(PROP.width)).toBe(ops!.measureText(title, 11));
+});
diff --git a/tests/contact-latch.test.ts b/tests/contact-latch.test.ts
index 7d61732a0..8d9c207e7 100644
--- a/tests/contact-latch.test.ts
+++ b/tests/contact-latch.test.ts
@@ -1,16 +1,60 @@
-import { expect, test } from "bun:test";
+import { afterAll, afterEach, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { attachGesture, resetGestures, __runGestures } from "../framework/src/gesture-core.ts";
+import { __setTouches, __resetTouches, touches } from "../framework/src/touch.ts";
+import { createKeyboardTouch } from "../apps/clear/keyboard-touch.ts";
+const directory = mkdtempSync(join(tmpdir(), "pocket-contact-latch-"));
+const binary = join(directory, "test");
+const build = Bun.spawnSync(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
+ "tests/fixtures/contact-latch.c", "-o", binary], { stderr: "pipe" });
+if (build.exitCode) throw new Error(build.stderr.toString());
+afterAll(() => rmSync(directory, { recursive: true, force: true }));
+afterEach(() => { resetGestures(); __resetTouches(); });
+function trace(events: string) {
+ const run = Bun.spawnSync([binary, "trace"], { stdin: Buffer.from(events), stdout: "pipe", stderr: "pipe" });
+ expect(run.exitCode, run.stderr.toString()).toBe(0);
+ return run.stdout.toString().trim().split("\n").map(line => JSON.parse(line) as { packed: number[]; hits: number[] });
+}
+function deliver(frame: { packed: number[]; hits: number[] }) { __setTouches(frame.packed, frame.hits); __runGestures(); }
test("native contact lifetimes survive pointer reuse and suppress cancelled tap latches", () => {
- const directory = mkdtempSync(join(tmpdir(), "pocket-contact-latch-"));
- try {
- const binary = join(directory, "test");
- const build = Bun.spawnSync(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined",
- "tests/fixtures/contact-latch.c", "-o", binary], { stderr: "pipe" });
- expect(build.exitCode, build.stderr.toString()).toBe(0);
- const run = Bun.spawnSync([binary], { stderr: "pipe" });
- expect(run.exitCode, run.stderr.toString()).toBe(0);
- } finally { rmSync(directory, { recursive: true, force: true }); }
+ const run = Bun.spawnSync([binary], { stderr: "pipe" });
+ expect(run.exitCode, run.stderr.toString()).toBe(0);
+});
+test("native DOWN order survives storage reuse through the gesture onDown callbacks", () => {
+ let text = "";
+ attachGesture({ onDown: c => { text += String.fromCharCode(c.x); } });
+ const frames = trace("d 0 194\nf 0 0\nd 1 220\nu 0 194\nd 0 210\nf 0 0\nm 1 224\nf 0 0\n");
+ for (const frame of frames) deliver(frame);
+ expect(text).toBe("ani");
+ expect(touches().map(c => c.x)).toEqual([112, 105]);
+});
+for (const cancel of ["c 0 200", "x 0 0"]) for (const sampled of [false, true])
+ test(`native ${cancel[0]} reaches keyboard cancel without Space commit (sampled=${sampled})`, () => {
+ const events: string[] = [];
+ const keyboard = createKeyboardTouch({ space: () => events.push("space"), backspace() {}, caret() {}, trackpad() {} });
+ attachGesture({
+ onDown: c => { keyboard.begin(c.id, c.x, c.y, c.x === 100 ? "space" : "other", { x: 0, y: 0, w: 400, h: 800 }, 0); },
+ onUp: c => keyboard.release(c.id),
+ onCancel: c => { events.push("cancel"); keyboard.release(c.id, true); },
+ });
+ for (const frame of trace(`d 0 200\n${sampled ? "f 0 0\n" : ""}${cancel}\nd 0 300\nf 0 0\nu 0 300\nf 0 0\n`)) deliver(frame);
+ expect(events).toEqual(sampled ? ["cancel"] : []);
+ });
+test("eight cancelled contacts leave room for eight new DOWNs in the same guest frame", () => {
+ const downs: number[] = [], cancels: number[] = [], ups: number[] = [];
+ attachGesture({ onDown: c => downs.push(c.id), onCancel: c => cancels.push(c.id), onUp: c => ups.push(c.id) });
+ const down = Array.from({ length: 8 }, (_, id) => `d ${id} 200`).join("\n");
+ for (const frame of trace(`${down}\nf 0 0\nx 0 0\n${down}\nf 0 0\nf 0 0\n`)) deliver(frame);
+ expect(downs).toHaveLength(16); expect(new Set(downs).size).toBe(16);
+ expect(cancels).toEqual(downs.slice(0, 8)); expect(ups).toEqual([]); expect(touches()).toHaveLength(8);
+});
+test("eight normal releases also free guest tracks before replacement DOWNs", () => {
+ let count = 0;
+ attachGesture({ onDown: () => count++ });
+ const events = (phase: string) => Array.from({ length: 8 }, (_, id) => `${phase} ${id} 200`).join("\n");
+ for (const frame of trace(`${events("d")}\nf 0 0\n${events("u")}\n${events("d")}\nf 0 0\n`)) deliver(frame);
+ expect(count).toBe(16);
});
diff --git a/tests/devtools.test.ts b/tests/devtools.test.ts
index d69576c80..8c366d2ef 100644
--- a/tests/devtools.test.ts
+++ b/tests/devtools.test.ts
@@ -22,7 +22,7 @@ import {
fmt,
type Tape,
} from "../framework/src/devtools.ts";
-import { touches, __packTouch } from "../framework/src/touch.ts";
+import { touches, __packTouch, __packTouchCancel } from "../framework/src/touch.ts";
import { onFrame, rightAnalogRaw, rightAnalogX, rightAnalogY } from "../framework/src/lifecycle.ts";
import {
createComponent,
@@ -409,6 +409,17 @@ describe("tape v2 touch track", () => {
);
}
+ test("a full snapshot retains all terminal cancellations in the recorded tape", () => {
+ mountApp(() => View({}));
+ const words = [...Array.from({ length: 8 }, (_, i) => __packTouch(i + 8, 10, 20)),
+ ...Array.from({ length: 8 }, (_, i) => __packTouchCancel(i))];
+ frameTouch(0, words);
+ push({ t: "dumpTape" }); frame(0);
+ const tape = sent("tape")[0].tape as Tape;
+ expect(tape.touch).toEqual([[0, words]]);
+ expect(expandTapeTouch(tape)![0]).toEqual(words);
+ });
+
test("a touch-free session still exports v:1 with no touch key", () => {
mountApp(() => View({}));
frame(BTN.UP);
diff --git a/tests/fixtures/contact-latch.c b/tests/fixtures/contact-latch.c
index b0dbbe85b..aafdd4606 100644
--- a/tests/fixtures/contact-latch.c
+++ b/tests/fixtures/contact-latch.c
@@ -1,5 +1,6 @@
#include "../../hosts/shared/contact_latch.h"
#include
+#include
static unsigned hits;
static int hit(float x, float y) { hits++; return (int)(x + y); }
static PocketRuntimeContactsInput sample(PocketContactLatch *state) {
@@ -13,7 +14,28 @@ static PocketRuntimeContactsInput sample(PocketContactLatch *state) {
static int event(PocketContactLatch *s, PocketTouchPhase phase, int id, float x) {
return pocket_contact_event(s, phase, id, x, 200, 720, 1600);
}
-int main(void) {
+static void emit(PocketContactLatch *s) {
+ PocketRuntimeContactsInput f = sample(s);
+ printf("{\"packed\":[");
+ for (unsigned i = 0; i < f.contact_count; i++) printf("%s%u", i ? "," : "", pocket_runtime_pack_contact(&f.contacts[i]));
+ for (unsigned i = 0; i < f.cancelled_count; i++) printf("%s%u", f.contact_count || i ? "," : "", pocket_runtime_pack_cancel(f.cancelled[i]));
+ printf("],\"hits\":[");
+ for (unsigned i = 0; i < f.contact_count; i++) printf("%s%d", i ? "," : "", f.contacts[i].hit);
+ printf("]}\n");
+}
+int main(int argc, char **argv) {
+ (void)argv;
+ if (argc > 1) {
+ PocketContactLatch state = {0};
+ char command; int id; float x;
+ while (scanf(" %c %d %f", &command, &id, &x) == 3) {
+ if (command == 'f') emit(&state);
+ else if (command == 'x') pocket_contacts_cancel(&state);
+ else event(&state, command == 'd' ? POCKET_TOUCH_DOWN : command == 'u' ? POCKET_TOUCH_UP :
+ command == 'c' ? POCKET_TOUCH_CANCEL : POCKET_TOUCH_MOVE, id, x);
+ }
+ return 0;
+ }
PocketContactLatch s = {0};
assert(event(&s, POCKET_TOUCH_DOWN, 19, 100));
PocketRuntimeContactsInput first = sample(&s);
@@ -30,6 +52,14 @@ int main(void) {
assert(next.contacts[0].hit == captured_hit && hits == previous_hits);
event(&s, POCKET_TOUCH_UP, 19, 400); assert(sample(&s).contact_count == 0);
+ // A MOVE before the first sample must not change the DOWN hit identity.
+ assert(event(&s, POCKET_TOUCH_DOWN, 0, 100)); event(&s, POCKET_TOUCH_MOVE, 0, 400);
+ next = sample(&s); assert(next.contacts[0].x == 200 && next.contacts[0].hit == 150);
+ pocket_contacts_cancel(&s); sample(&s);
+ assert(event(&s, POCKET_TOUCH_DOWN, 0, 100)); pocket_contact_hit(&s, 0, 999);
+ event(&s, POCKET_TOUCH_MOVE, 0, 400); next = sample(&s); assert(next.contacts[0].hit == 999);
+ pocket_contacts_cancel(&s); sample(&s);
+
// Ordinary sub-frame taps latch once; cancellation never latches a press.
assert(event(&s, POCKET_TOUCH_DOWN, 0, 100)); event(&s, POCKET_TOUCH_CANCEL, 0, 100);
assert(sample(&s).contact_count == 0); assert(sample(&s).contact_count == 0);
@@ -51,8 +81,8 @@ int main(void) {
first = sample(&s); assert(first.contact_count == 8);
assert(!event(&s, POCKET_TOUCH_DOWN, 99, 100));
event(&s, POCKET_TOUCH_UP, 0, 100); assert(event(&s, POCKET_TOUCH_DOWN, 0, 200));
- next = sample(&s); assert(next.contact_count == 8 && next.contacts[0].id != first.contacts[0].id);
- for (unsigned i = 1; i < 8; i++) assert(next.contacts[i].id == first.contacts[i].id);
+ next = sample(&s); assert(next.contact_count == 8 && next.contacts[7].id != first.contacts[0].id);
+ for (unsigned i = 1; i < 8; i++) assert(next.contacts[i - 1].id == first.contacts[i].id);
pocket_contacts_cancel(&s); assert(sample(&s).contact_count == 0);
// Cancellation on pause also drops contacts that have never been sampled.
diff --git a/tests/ipodtouch4-profile.test.ts b/tests/ipodtouch4-profile.test.ts
index c7d701a5b..afda286c9 100644
--- a/tests/ipodtouch4-profile.test.ts
+++ b/tests/ipodtouch4-profile.test.ts
@@ -159,8 +159,9 @@ describe("private iPod touch 4 profile", () => {
expect(runtime).toContain("pocket_runtime_frame_contacts(&frame_input, 2)");
expect(runtime).toContain("pocket_runtime_hit_test_bounds");
expect(guest).toContain("POCKET_RUNTIME_MAX_CONTACTS");
- expect(guest).toContain("(id << 18) | (y << 9) | x");
- expect(guest).toContain("0x80000000U | (id << 20) | (y << 10) | x");
+ expect(guest).toContain("pocket_runtime_pack_contact(contact)");
+ expect(runtime).toContain("pocket_contacts_sample(&g_contacts");
+ expect(runtime).toContain("pocket_touches_cancelled");
});
diff --git a/tests/meizu-m8-profile.test.ts b/tests/meizu-m8-profile.test.ts
index d4ddf9215..2e18d1ef8 100644
--- a/tests/meizu-m8-profile.test.ts
+++ b/tests/meizu-m8-profile.test.ts
@@ -171,7 +171,7 @@ describe("private Meizu M8 build profile", () => {
expect(runtime).not.toContain("HWND_TOPMOST");
expect(runtime).toContain("word == VK_HOME || word == VK_ESCAPE");
expect(runtime).not.toContain("case WM_ACTIVATE:");
- expect(guestRuntime).toContain("0x80000000U | (id << 20) | (y << 10) | x");
+ expect(guestRuntime).toContain("pocket_runtime_pack_contact(contact)");
expect(guestRuntime).toContain("return pocket_runtime_frame_ticks(touch_down, touch_x, touch_y, touch_hit, 2)");
expect(runtime).toContain("pocket_runtime_frame_ticks(touch_down, touch_x, touch_y, touch_hit, 1)");
expect(runtime).toContain("touch_hit = pocket_runtime_hit_test_bounds");
diff --git a/tests/text.test.ts b/tests/text.test.ts
index 212a132c7..16a895775 100644
--- a/tests/text.test.ts
+++ b/tests/text.test.ts
@@ -1,4 +1,8 @@
import { describe, expect, test } from "bun:test";
+import { createBakedFontCoverage } from "../framework/src/font-coverage.ts";
+import { loadPack, resetPack } from "../framework/src/pak.ts";
+import { pack, keyFont, PAK_DTYPE } from "../framework/compiler/pak.ts";
+import { bakeAtlases } from "../framework/compiler/bake-font.ts";
import { existsSync } from "node:fs";
import { createTextResources } from "../framework/src/text.ts";
import { createOffloadClient } from "../framework/src/offload.ts";
@@ -107,3 +111,27 @@ for (const mode of ["load", "upload"] as const) for (const offline of [true, fal
expect(f.sent.filter(r => r.method === "text.glyph" && JSON.parse(r.payload).text === "你")).toHaveLength(1);
f.resources.dispose();
});
+
+
+test("shipped baked coverage keeps non-ASCII symbols local before the first companion session", async () => {
+ const [atlas] = await bakeAtlases({ slots: [11], codepoints: Array.from("£¥€•你").map(c => c.codePointAt(0)!) });
+ loadPack(pack([{ key: keyFont(11), dtype: PAK_DTYPE.u8, data: atlas!.bytes }]).buffer as ArrayBuffer);
+ try {
+ let requests = 0;
+ const local = createBakedFontCoverage();
+ for (const c of "£¥€•") expect(local(c, 11)).toBe(true);
+ expect(local("你", 11)).toBe(false); // the font has no glyph, despite being requested at bake
+ expect(local("£", 4)).toBe(false); // coverage is per installed slot
+ const resources = createTextResources({ io: { session: () => 0, request: () => { requests++; return 0; }, cancel() {} },
+ measure: s => s.length * 8, local, upload() { throw new Error("local text must not upload"); }, free() {} });
+ const label = resources.createLayout({ width: 300, size: 20, density: 2, bold: true, fontSlot: 11 });
+ label.set("A£¥€•B");
+ expect(label.snapshot().parts).toEqual([{ kind: "local", text: "A£¥€•B", x: 0, width: 48, start: 0, end: 6 }]);
+ expect(label.snapshot().pending).toBe(false);
+ for (let i = 0; i < 200; i++) resources.step();
+ expect(requests).toBe(0);
+ label.set("£你€");
+ expect(label.snapshot().parts.map(p => [p.text, p.kind])).toEqual([["£", "local"], ["你", "glyph"], ["€", "local"]]);
+ resources.dispose();
+ } finally { resetPack(); }
+});
diff --git a/tools/test.ts b/tools/test.ts
index 335641499..7390c7164 100644
--- a/tools/test.ts
+++ b/tools/test.ts
@@ -152,7 +152,7 @@ const SUITE: readonly Stage[] = [
name: "clear journeys",
prep: [["bun", "tools/build.ts", "clear-main", "--framework=vue-vapor"]],
browser: true,
- tests: ["tests/clear.test.ts", "tests/clear-ime-loading.test.ts"],
+ tests: ["tests/clear.test.ts", "tests/clear-ime-loading.test.ts", "tests/clear-text.test.ts"],
},
{
name: "octane smoke",
From 0a838826f891ac83c415c0265713539dbb69afba Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Sun, 13 Sep 2026 17:56:32 -0700
Subject: [PATCH 6/7] ci(ime): install the WebAssembly target for Clear
regression tests
---
.github/workflows/native-c-harness.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/native-c-harness.yml b/.github/workflows/native-c-harness.yml
index c609cbd0f..eae579a96 100644
--- a/.github/workflows/native-c-harness.yml
+++ b/.github/workflows/native-c-harness.yml
@@ -74,6 +74,8 @@ jobs:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
- run: bun install --frozen-lockfile
- name: Install the UI C ABI's pinned nightly
run: |
From 2d06681f87b099e6206ead9cc9adbb35b79ac44e Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Sun, 13 Sep 2026 17:59:06 -0700
Subject: [PATCH 7/7] ci(ime): build simulator before timed regression tests
---
.github/workflows/native-c-harness.yml | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/native-c-harness.yml b/.github/workflows/native-c-harness.yml
index eae579a96..3843569c0 100644
--- a/.github/workflows/native-c-harness.yml
+++ b/.github/workflows/native-c-harness.yml
@@ -90,8 +90,10 @@ jobs:
run: bun test --conditions=browser tests/quickjs-c-harness.test.ts tests/renderer.test.ts tests/virtual-list.test.ts tests/vue-vapor-dom.test.ts
- name: Native contact lifetimes and cancellation
run: bun test tests/contact-latch.test.ts
+ - name: Build Clear simulator inputs
+ run: bun tools/wasm.ts && bun tools/build.ts clear-main --framework=vue-vapor
- name: Clear input and text chain
- run: bun tools/build.ts clear-main --framework=vue-vapor && bun test --conditions=browser tests/clear-text.test.ts tests/clear-ime-loading.test.ts
+ run: bun test --conditions=browser tests/clear-text.test.ts tests/clear-ime-loading.test.ts
- name: UI singleton access and alignment policy
run: cargo test --locked --manifest-path engine/ui-cabi/Cargo.toml --features harness-access
- name: Link and execute the real C allocator