Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 74 additions & 18 deletions apps/launcher/app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// apps/launcher/app.tsx — the Pocket Launcher: Cover Flow over every
// PSP-admissible app in the repo (docs/LAUNCHER.md).
// host-admitted app in the repo (docs/LAUNCHER.md).
//
// The deck is the 2D core's perspective pipeline (the same TEX_TRI path
// motions page 4 ships): one perspective root, one 2:1 cover card per app,
Expand All @@ -17,15 +17,27 @@ import { createEffect, createSignal, onMount, Show, untrack } from "solid-js";
import { registerTexture } from "@pocketjs/framework";
import { Image, Text, View, type NodeMirror } from "@pocketjs/framework/components";
import { animate, createJumpBatch } from "@pocketjs/framework/animation";
import { BTN } from "@pocketjs/framework/input";
import { BTN, touches } from "@pocketjs/framework/input";
import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle";
import { appTable, frozenShot, launchApp } from "@pocketjs/framework/launcher";
import { ticksPerFrame } from "@pocketjs/framework/clock";
import { REGISTRY, type RegistryApp } from "./registry.generated.ts";
import { getOps, hostViewport } from "@pocketjs/framework/host";

/** Card box: 192×96 at left-[144] top-[58] (class literals below — the deck
* geometry lives in the transforms, the box never moves). */
/** Rail geometry: first neighbor offset, then per-card spacing. */
export interface RegistryApp {
output: string;
id: string;
title: string;
cover: string;
refl: string;
}

export interface LauncherProps {
registry: readonly RegistryApp[];
}

/** Card box: 192×218, centered from the live host viewport; orientation
* changes only move the deck origin. Rail geometry is the first-neighbor
* offset followed by per-card spacing. */
const RAIL_FIRST = 124;
const RAIL_STEP = 44;
const RAIL_TILT = 55;
Expand Down Expand Up @@ -78,14 +90,20 @@ function displayTitle(app: RegistryApp): string {
return cut > 0 ? title.slice(0, cut) : title;
}

export default function Launcher() {
export default function Launcher(props: LauncherProps) {
const initialViewport = hostViewport(getOps()) ?? { w: 480, h: 272 };
const [viewport, setViewport] = createSignal(initialViewport);
const cardLeft = () => viewport().w / 2 - 96;
const cardTop = () =>
Math.max(24, Math.round((viewport().h - 76 - 218) / 2));

// The host table is the runtime truth for what is embedded; the generated
// registry carries display data (titles + baked covers). Show their
// intersection, in registry order. No table -> browse-only degraded mode.
const table = appTable();
const apps = table
? REGISTRY.filter((r) => table.apps.some((a) => a.output === r.output))
: [...REGISTRY];
? props.registry.filter((r) => table.apps.some((a) => a.output === r.output))
: [...props.registry];
const shot = frozenShot();
if (shot >= 0) registerTexture("launcher.shot", shot);
const resume = table?.resume ?? null;
Expand Down Expand Up @@ -217,6 +235,36 @@ export default function Launcher() {
// CIRCLE confirms (the console's home convention — CROSS-as-confirm had
// users launching with O and landing in the RESUME app every time);
// CROSS and SELECT both back out to the interrupted app.
let previousTouchIds = new Set<number>();
onFrame(() => {
const nextViewport = hostViewport(getOps());
if (
nextViewport &&
(nextViewport.w !== viewport().w || nextViewport.h !== viewport().h)
) {
setViewport(nextViewport);
}
const contacts = touches();
const nextTouchIds = new Set(contacts.map((contact) => contact.id));
const pressed = contacts.find((contact) => !previousTouchIds.has(contact.id));
previousTouchIds = nextTouchIds;
if (!pressed) return;

// Three large screen zones keep touch useful even while perspective
// makes individual cover hit boxes overlap: left/right browse, center
// launches the card that the title identifies.
const third = viewport().w / 3;
if (pressed.x < third) {
pos = null;
setSel((current) => clampSel(current - 1));
} else if (pressed.x >= third * 2) {
pos = null;
setSel((current) => clampSel(current + 1));
} else {
const app = apps[sel()];
if (app) launchApp(app.output);
}
});
onButtonPress(BTN.CIRCLE, () => {
const app = apps[sel()];
if (app) launchApp(app.output);
Expand All @@ -236,18 +284,21 @@ export default function Launcher() {
it next to the covers) — black floor, cool center glow behind the
deck, faint sheen under the cards. Stretched 256×128 → full screen
with bilinear, like the frozen shot. */}
<Image class="absolute left-0 top-0 w-[480] h-[272]" src="covers/launcher-bg.png" />
<Image class="absolute inset-0 w-full h-full" src="covers/launcher-bg.png" />
<Show when={shot >= 0}>
{/* The interrupted app's last frame, stretched back to full screen
under a scrim — the "overlay" illusion (docs/LAUNCHER.md). */}
<Image class="absolute left-0 top-0 w-[480] h-[272]" src="launcher.shot" />
<View class="absolute left-0 top-0 w-[480] h-[272] bg-[#05060a] opacity-75" />
<Image class="absolute inset-0 w-full h-full" src="launcher.shot" />
<View class="absolute inset-0 w-full h-full bg-[#05060a] opacity-75" />
</Show>

<Show
when={apps.length > 0}
fallback={
<Text class="absolute left-0 top-[128] w-[480] text-center text-sm text-slate-400">
<Text
class="absolute left-0 right-0 text-center text-sm text-slate-400"
style={{ insetT: viewport().h / 2 - 8 }}
>
No apps embedded — build with tools/launcher.ts
</Text>
}
Expand All @@ -267,8 +318,10 @@ export default function Launcher() {
<View
ref={(el: NodeMirror) => (cardEls[i] = el)}
debugName="Card"
class="absolute left-[144] top-[52] w-[192] h-[218]"
class="absolute w-[192] h-[218]"
style={{
insetL: cardLeft(),
insetT: cardTop(),
translateX: t.translateX,
translateZ: t.translateZ,
rotateY: t.rotateY,
Expand All @@ -282,7 +335,10 @@ export default function Launcher() {
})}
</View>

<View debugName="TitleBlock" class="absolute left-0 top-[196] w-[480] flex-col items-center gap-1">
<View
debugName="TitleBlock"
class="absolute left-0 right-0 bottom-8 flex-col items-center gap-1"
>
<Text class="text-xl text-slate-100 font-bold">{displayTitle(selected())}</Text>
<Text class="text-xs text-slate-500">
{`${sel() + 1} / ${apps.length} · ${selected().id}`}
Expand All @@ -293,11 +349,11 @@ export default function Launcher() {
</View>
</Show>

<Text class="absolute left-0 bottom-2 w-[480] text-center text-xs text-slate-600">
<Text class="absolute left-0 right-0 bottom-2 text-center text-xs text-slate-600">
{table
? resume
? "tap or hold L / R · CIRCLE launch · CROSS back"
: "tap L / R to step · hold to flow · CIRCLE launch"
? "L/R · CIRCLE launch · CROSS back · touch sides/center"
: "L/R · CIRCLE launch · touch sides/center"
: "browse only — this host cannot switch apps"}
</Text>
</View>
Expand Down
6 changes: 6 additions & 0 deletions apps/launcher/images.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
"covers/launcher-bg.png": {
"linear": true
},
"covers/cover-note-main.png": {
"linear": true
},
"covers/refl-note-main.png": {
"linear": true
},
"covers/cover-cafe-main.png": {
"linear": true
},
Expand Down
3 changes: 2 additions & 1 deletion apps/launcher/main.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @title PocketJS: Launcher
import Launcher from "./app.tsx";
import { mount } from "@pocketjs/framework";
import { REGISTRY } from "./registry.generated.ts";

mount(() => <Launcher />);
mount(() => <Launcher registry={REGISTRY} />);
3 changes: 2 additions & 1 deletion apps/launcher/registry.generated.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// GENERATED by tools/launcher.ts scan — do not edit by hand; COMMIT
// the regenerated file (tests/launcher-sim.test.ts asserts freshness).
// The display-side PSP/Vita union: the launcher app imports it for
// The display-side PSP/Vita/Symbian union: the launcher imports it for
// titles + cover asset keys; each host's target-specific appTable
// (spec op 39) stays the runtime truth for what is embedded.

Expand All @@ -17,6 +17,7 @@ export interface RegistryApp {
}

export const REGISTRY: readonly RegistryApp[] = [
{ output: "note-main", id: "dev.pocket-stack.note", title: "Pocket Note", cover: "covers/cover-note-main.png", refl: "covers/refl-note-main.png" },
{ output: "cafe-main", id: "dev.pocket-stack.cafe", title: "PocketJS: Café", cover: "covers/cover-cafe-main.png", refl: "covers/refl-cafe-main.png" },
{ output: "chrome-main", id: "dev.pocket-stack.chrome", title: "PocketJS: Chrome", cover: "covers/cover-chrome-main.png", refl: "covers/refl-chrome-main.png" },
{ output: "cursor-main", id: "dev.pocket-stack.cursor", title: "PocketJS: Cursor", cover: "covers/cover-cursor-main.png", refl: "covers/refl-cursor-main.png" },
Expand Down
7 changes: 4 additions & 3 deletions contracts/spec/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,10 @@ export const OP = {
// Launching `current` relaunches it fresh — there is
// no suspend anywhere in this protocol.
appShot: 41, // () -> handle | -1. Texture of the frozen frame the
// SELECT summon captured: the FULL 480x272 frame
// downscaled into 256x128 PSM_8888 (stored slightly
// squeezed — draw it at screen aspect to undo it).
// SELECT summon captured: the FULL current logical
// frame downscaled into 256x128 PSM_8888 (stored
// squeezed — draw it at current viewport aspect to
// undo it; consoles are 480x272).
// Valid inside the summoned launcher guest until the
// next switch; -1 otherwise.
} as const;
Expand Down
15 changes: 8 additions & 7 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,15 @@ PocketJS/
plugin collects per-file: (a) candidate class strings, (b) text codepoints
— both from AST literals *and template quasis*, never regex over quotes.
2. **Compile styles & fonts.** `tailwind.ts` validates tokens (all-or-nothing
per literal), assigns styleIds, writes `styles.bin` + `styles.generated.ts`
(excluded from future scans). `bake-font.ts` bakes atlas slots for the
collected charset. `pak.ts` packs styles.bin + atlases + images →
`<app>.pak`.
per literal), assigns styleIds, writes `styles.bin`, and keeps an ignored
`styles.generated.ts` mirror for inspection (excluded from future scans).
`bake-font.ts` bakes atlas slots for the collected charset. `pak.ts` packs
styles.bin + atlases + images → `<app>.pak`.
3. **Pass 2 — bundle.** `Bun.build` with an onLoad plugin that serves the
*cached* pass-1 transforms (styles.generated.ts now exists), `format:
"iife"`, `minify:false`, `target:"browser"`. Output `<app>.js` next to the
pak.
*cached* pass-1 transforms plus this build's in-memory generated style
module, `format:"iife"`, `minify:false`, `target:"browser"`. Output
`<app>.js` next to the pak. Parallel builds therefore cannot import one
another's transient style table.

The PSP build (`tools/psp.ts`) then runs `rustup run nightly-2026-05-28
cargo psp` with the exact env block from `runtime/build.ts` (LLVM PATH,
Expand Down
51 changes: 46 additions & 5 deletions docs/LAUNCHER.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# The Pocket Launcher — in-device app switching

One console package — PSP EBOOT or Vita VPK — with every target-compatible
One device package — PSP EBOOT, Vita VPK, or Nokia E7 SIS — with every target-compatible
app inside and a Cover Flow picker on top. This document is the contract for
the multi-app host, the three `app*` surface ops, and the SELECT summon policy.
RUNTIMES.md owns the ontology; nothing here changes it — a launcher is an
Expand Down Expand Up @@ -28,7 +28,7 @@ the system summon chord.
|----|------|-----------|-----------|
| 39 | `appTable` | `() -> string` | JSON `{ apps: [{output, id, title}], current, resume }`. `current` is the running bundle's output name; `resume` is the app interrupted by the last SELECT summon (null after a cold boot or an explicit launch). Hosts without app switching omit the op (same rule as `debugStats`). |
| 40 | `appLaunch` | `(output: string) -> 0\|1` | Request a switch. The host finishes the CURRENT frame (draw + present), then swaps guests before the next one. Returns 0 for an unknown output (no switch scheduled). Calling it with `current` relaunches fresh. |
| 41 | `appShot` | `() -> handle \| -1` | Texture handle of the frozen frame captured when the running app was summoned away: the FULL 480×272 frame downscaled into 256×128 PSM_8888 (stored slightly squeezed; drawn at screen aspect, which undoes it). Valid in the guest booted by a summon until the next switch; -1 otherwise. |
| 41 | `appShot` | `() -> handle \| -1` | Texture handle of the frozen frame captured when the running app was summoned away: the full current logical frame downscaled into 256×128 PSM_8888 (stored squeezed; drawn at the current viewport aspect, which undoes it). Console frames are 480×272. Valid in the guest booted by a summon until the next switch; -1 otherwise. |

`@pocketjs/framework/launcher` wraps these (`appTable()`, `launchApp()`,
`frozenShot()`, `launcherActive()`) and degrades to `null`/no-op on hosts
Expand All @@ -51,6 +51,12 @@ ops behavior change. Apps that bind SELECT (e.g. Pocket Talk) keep it in their
standalone package and lose it under the launcher — that is the price of a
system chord, stated here once.

The E7 host spells the same system action with the physical QWERTY
Backspace/Home keys. It forwards that action to the launcher as SELECT and
strips it from every other guest. Escape remains CROSS. Q/E map to the
left/right triggers and T/S to TRIANGLE/SQUARE, so the launcher remains fully
operable without an on-screen controller.

## Admission

The embedded set is COMPUTED, not curated: every `apps/*/pocket.json` whose
Expand Down Expand Up @@ -82,14 +88,17 @@ boots the next package; vita2d and input remain process-owned.

## Build pipeline

`bun tools/launcher.ts` owns the artifact chain. `--target psp|vita`
`bun tools/launcher.ts` owns the artifact chain. `--target psp|vita|symbian`
selects admission, bundle variants, packages, and the native backend; `psp`
is the default for compatibility:

1. **scan** — resolve every app manifest for the selected target and dedupe
by `app.output`. PSP writes `dist/launcher-registry.{json,tsv}`; Vita writes
`dist/launcher/vita/launcher-registry.{json,tsv}`. The committed display
registry is the PSP/Vita union, while `appTable()` remains the runtime truth.
registry is the PSP/Vita/Symbian union, while `appTable()` remains the runtime truth.
A plain in-repository `scan` is the only command that updates
`apps/launcher/{registry.generated.ts,images.json}`; external scans leave
those files untouched.
2. **covers** — boot each admitted app in `hosts/sim`, settle 90 virtual frames,
render, box-downscale the full frame to 256×128, write
`apps/launcher/covers/cover-<output>.png` (generated, deterministic —
Expand All @@ -98,7 +107,10 @@ is the default for compatibility:
3. **pack** — every admitted app + the launcher becomes a `.pocket`
package (`contracts/spec/pocket-package.ts`) with the selected target variant. PSP uses
`dist/packages/`; Vita uses `dist/launcher/vita/packages/` so density-2
bundles never overwrite the PSP/sim outputs.
bundles never overwrite the PSP/sim outputs. Pack/build materialize a private
`.launcher-source` under `dist/launcher/<target>/` containing the selected
registry, image metadata, entry, and cover assets; compiler/watch processes
never observe a temporary target registry in the committed source tree.
4. **build** — the selected backend embeds those packages VERBATIM
(`hosts/psp/build.rs` or `hosts/vita/build.rs`; the core reader extracts
js/pak zero-copy at boot). PSP retains its aggregate FNV-1a64 build
Expand All @@ -107,17 +119,46 @@ is the default for compatibility:
`dist/vita/launcher-main.vpk`; ordinary single-app builds retain their
classic inline embed.

For `symbian`, scan uses the private `symbian-e7-dev` resolver rather than
pretending the experimental host is a production target. Only apps with a
real dynamic/live viewport are admitted (currently Hero and Note); fixed
480×272 PSP apps are not silently letterboxed into the E7 catalog. The
committed launcher manifest stays byte-identical for PSP/Vita; inside the
locked Symbian transaction the tool derives its E7-only dynamic viewport and
touch/live enhancements. The Symbian pack step always recompiles every guest,
then writes an eight-field `catalog.tsv` and a 16-byte-aligned `catalog.bin`
containing those exact target-thinned `.pocket` files. The Qt host validates
each package footer, target, ABI, and identity before boot. The native builder
reserves a 1 MiB-aligned GCCE writable-data base from the full raw embedded
byte count, so a large catalog cannot overlap the executable's qrc rodata.

```sh
bun tools/launcher.ts build --target psp -- --release
bun tools/launcher.ts build --target vita -- --release
bun tools/launcher.ts build --target symbian
```

The Symbian build may repeat `--include-manifest /absolute/path/to/pocket.json`
to add explicitly requested external projects. The tool resolves each declared
entry by walking up from that manifest, applies the same private E7 admission
gate, and builds the launcher from its isolated target registry without ever
writing the committed display registry; absolute local paths never enter the
emitted registry or source diff. This
extension is deliberately unavailable to PSP/Vita builds, whose computed
in-repository admission set remains unchanged.

## Hosts

- **hosts/psp** — the original native implementation of everything above.
- **hosts/vita** — the same package-table and ops contract with a
process-global frame/input tape across fresh guests, SELECT interception,
CPU-oracle frozen shots, and an explicit GXM-safe guest-resource reset.
- **hosts/symbian** — the E7 Qt process owns the fullscreen window, timer,
keyboard, touch stream, and catalog blob. Each switch happens after a
synchronous presented frame, then frees the complete QuickJS realm and
native `Ui` before the next package boots. The launcher background, frozen
shot, deck origin, title, and footer use the live 640×360/360×640 viewport;
touch divides the current width into browse/launch thirds.
- **hosts/sim** — `hosts/sim/launcher.ts` drives the same protocol over
per-guest `bootWorld`s: strips SELECT, performs the downscale with the
same box filter, uploads the shot into the next world, answers the three
Expand Down
2 changes: 1 addition & 1 deletion docs/STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ pocketjs/
│ ├─ core/ pocketjs-core — retained UI tree, taffy layout, damage + raster (standalone crate)
│ ├─ backends/ platform render backends (ESP32-P4 PPA is a standalone no_std crate)
│ ├─ wasm/ core compiled to wasm32 for web/sim hosts (standalone crate)
│ ├─ symbian/ no_std core static library for the GCCE/Symbian host (standalone crate)
│ ├─ symbian/ no_std Symbian UI static library: C ABI, capture raster + GLES2 DrawList backend (standalone crate)
│ ├─ pocket3d/ the 3D core family (bsp, cook, gu, vita) + desktop examples
│ ├─ crates/ non-3D engine crates: pocket-mod, pocket-ui-surface, pocket-ui-wgpu, pocket-vrm, pocket-widget
│ └─ Cargo.toml the desktop workspace root (core/, wasm/, symbian/, and
Expand Down
Loading
Loading