From cd84835d720db928e3cdb2bb13e33bdae65d4975 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:38:24 +0800 Subject: [PATCH] feat(desktop): open apps from the host and take the desktop name from the System manifest The System UI protocol gains a host line, {t:"open", package}. It opens the installed app with that package id. When the app's window is already open the line restores it if it is minimized, raises it and focuses it, and it closes an open launcher or dropdown first because those belong to the window that loses focus. A package id outside the installed catalog is ignored. The System UI no longer spells its own name. The Aqua screen bar with no window focused, the About window, its heading and menu entries, and the welcome note read the System manifest's title through pocket-apps.ts. This repository's manifest keeps "Pocket Shell Desktop", so its output is unchanged; a product that installs the System UI under its own pocket.system.json gets its own name. Co-Authored-By: Claude Opus 5.5 --- shells/desktop/README.md | 11 ++ shells/desktop/src/system-ui/app.tsx | 42 +++++-- shells/desktop/src/system-ui/pocket-apps.ts | 23 +++- shells/desktop/src/system-ui/programs.tsx | 3 +- shells/desktop/src/system-ui/svc.ts | 9 +- shells/desktop/test/system-ui-sim.test.ts | 124 +++++++++++++++++++- shells/desktop/test/system-ui.test.ts | 27 ++++- 7 files changed, 222 insertions(+), 17 deletions(-) diff --git a/shells/desktop/README.md b/shells/desktop/README.md index e816f9d..fc34664 100644 --- a/shells/desktop/README.md +++ b/shells/desktop/README.md @@ -71,6 +71,17 @@ pocket.system.json The System UI is in `src/system-ui`. Demo applications are consumed from the pinned `vendor/pocketjs` submodule and are not copied into this product. +The System UI takes the desktop's name from the System manifest's `title`. The +Aqua screen bar with no window focused, the About dialog and its menu entries, +and the welcome note show it, so a product that installs this System UI under +its own `pocket.system.json` gets its own name in each of them. + +The host and the System UI exchange JSON lines over the `system-ui` companion +service; `src/system-ui/svc.ts` lists every line. Besides input, the host can +send `{t:"open", package}` to open an installed app by package id. When that +app's window is already open, the line restores it if it is minimized, raises +it and focuses it. A package id outside the installed catalog is ignored. + The experimental framework implementation is pinned directly in `vendor/pocketjs` from [PocketJS PR #399](https://github.com/pocket-nexus/pocketjs/pull/399), which adds GPU composition on top of [PR #390](https://github.com/pocket-nexus/pocketjs/pull/390). A fresh `setup` uses diff --git a/shells/desktop/src/system-ui/app.tsx b/shells/desktop/src/system-ui/app.tsx index 538c8b1..955331e 100644 --- a/shells/desktop/src/system-ui/app.tsx +++ b/shells/desktop/src/system-ui/app.tsx @@ -16,7 +16,8 @@ // lines (⌘Q quits and ⌘V pastes host-side) — ⌘W closes, ⌘M minimizes, // ⌘` cycles, ⌘N opens Notepad, ⌘Esc toggles Start, ⌘A/C/X edit the // focused Notepad. Pocket app input and scheduling are owned by the native -// compositor using the focused surface fact emitted by this shell. +// compositor using the focused surface fact emitted by this shell. The host +// can also open an installed app by package id (the svc "open" line). // // Without the System UI companion (sim, goldens, consoles) the app boots a // static arrangement and just renders it — the unmodified-app base case. @@ -116,7 +117,12 @@ import { type EditKind, } from "./notepad.ts"; import { newMines, reveal, toggleFlag } from "./mines.ts"; -import { POCKET_APPS, type PocketAppSpec } from "./pocket-apps.ts"; +import { + DESKTOP_NAME, + POCKET_APPS, + pocketAppByPackage, + type PocketAppSpec, +} from "./pocket-apps.ts"; import { CLASSIC_THEME, nextThemeId, @@ -138,8 +144,11 @@ import { Taskbar, } from "./chrome.tsx"; +/** Label of every About entry and the About window's title. */ +const ABOUT_LABEL = `About ${DESKTOP_NAME}`; + const WELCOME = [ - "Welcome to Pocket Shell Desktop.", + `Welcome to ${DESKTOP_NAME}.`, "", "The desktop shell is one PocketJS guest. Every Pocket app icon starts another isolated QuickJS guest in the same PocketJS host process; each guest keeps its own globals, UI tree and fixed clock.", "", @@ -567,7 +576,7 @@ export default function App() { { label: "Help", width: menuW("Help"), - items: () => [{ label: "About Pocket Shell Desktop", act: openAbout }], + items: () => [{ label: ABOUT_LABEL, act: openAbout }], }, ], data, @@ -635,7 +644,7 @@ export default function App() { { label: "Help", width: menuW("Help"), - items: () => [{ label: "About Pocket Shell Desktop", act: openAbout }], + items: () => [{ label: ABOUT_LABEL, act: openAbout }], }, ], data, @@ -826,7 +835,7 @@ export default function App() { const data: AboutData = { kind: "about", armed: createState(null) }; const w = createWin({ kind: "about", - title: "About Pocket Shell Desktop", + title: ABOUT_LABEL, icon: "computer", geo: centered(ABOUT_GEO.w, ABOUT_GEO.h), buttons: ["close"], @@ -1074,7 +1083,7 @@ export default function App() { /** Aqua's logo menu: About first, the places and the theme picker, the * session commands last — the shape of the menu under the mark. */ const aquaStartItems = (): PopupItem[] => [ - { label: "About Pocket Shell Desktop", act: openAbout }, + { label: ABOUT_LABEL, act: openAbout }, { sep: true, label: "" }, { label: "Programs", icon: "folder", sub: programItems() }, { label: "Documents", icon: "documents", sub: documentItems() }, @@ -1163,14 +1172,15 @@ export default function App() { // ---- menus ---------------------------------------------------------------------- /** Program name the screen bar shows beside the logo — the focused - * window's program, or the shell's own when nothing is focused. */ + * window's program, or the desktop's own (the System manifest's title) + * when nothing is focused. */ function appNameOf(w: WinCtl | undefined): string { - if (!w) return "Pocket Shell Desktop"; + if (!w) return DESKTOP_NAME; if (w.kind === "notepad") return "Notepad"; if (w.kind === "mines") return "Minesweeper"; if (w.kind === "folder") return "Files"; if (w.kind === "pocket") return pocketOf(w).app.title; - return "Pocket Shell Desktop"; + return DESKTOP_NAME; } /** Left x of the first menu title in the screen bar: after the logo and @@ -2140,6 +2150,18 @@ export default function App() { } break; } + case "open": { + // The host launches an app by package id, the way an icon does: + // openPocketApp opens the window, or restores, raises and focuses + // the one already open. An open launcher or dropdown belongs to the + // window that loses focus, so it closes first. + const app = pocketAppByPackage(ev.package); + if (app) { + closeMenus(); + openPocketApp(app); + } + break; + } } } diff --git a/shells/desktop/src/system-ui/pocket-apps.ts b/shells/desktop/src/system-ui/pocket-apps.ts index 21b08a6..18545fd 100644 --- a/shells/desktop/src/system-ui/pocket-apps.ts +++ b/shells/desktop/src/system-ui/pocket-apps.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-only -// Pocket Shell Desktop's visible application catalog comes from its System manifest. -// The native host receives separately resolved complete package plans; this -// module carries only System-owned presentation data. +// The desktop's name and its visible application catalog come from the System +// manifest. The native host receives separately resolved complete package +// plans; this module carries only System-owned presentation data. import type { PocketSystemV1 } from "@pocketjs/framework/manifest"; import systemJson from "../../pocket.system.json"; @@ -9,6 +9,12 @@ import systemJson from "../../pocket.system.json"; const system = systemJson as unknown as PocketSystemV1; const installedPackages = new Set(system.installation.installedPackages); +/** The System manifest's `title`: the name the shell shows for the desktop + * itself — the screen bar with no window focused, the About dialog and its + * menu entries, the welcome note. A product that installs this System UI + * under its own manifest gets its own name in all of them. */ +export const DESKTOP_NAME: string = system.title; + export interface PocketAppSpec { /** Stable package id used by the native compositor surface registry. */ package: string; @@ -26,5 +32,16 @@ export const POCKET_APPS: readonly PocketAppSpec[] = system.applications.catalog viewport: entry.presentation!.viewport, })); +/** The installed app with this package id, or undefined when the id is not + * in the installed catalog (the System UI itself has no presentation and is + * never an app). */ +export function pocketAppByPackage( + packageId: unknown, +): PocketAppSpec | undefined { + return typeof packageId === "string" + ? POCKET_APPS.find((app) => app.package === packageId) + : undefined; +} + export const POCKET_ICON = "icons/pocket-app.svg"; export const POCKET_ICON_SMALL = "icons/pocket-app-16.svg"; diff --git a/shells/desktop/src/system-ui/programs.tsx b/shells/desktop/src/system-ui/programs.tsx index a37d293..3b3694e 100644 --- a/shells/desktop/src/system-ui/programs.tsx +++ b/shells/desktop/src/system-ui/programs.tsx @@ -22,6 +22,7 @@ import { type VSeg, } from "./notepad.ts"; import { MINES_W, type Cell } from "./mines.ts"; +import { DESKTOP_NAME } from "./pocket-apps.ts"; import { PLACES, type AboutData, @@ -673,7 +674,7 @@ export function AboutView(props: { - + diff --git a/shells/desktop/src/system-ui/svc.ts b/shells/desktop/src/system-ui/svc.ts index edd1139..1195439 100644 --- a/shells/desktop/src/system-ui/svc.ts +++ b/shells/desktop/src/system-ui/svc.ts @@ -16,6 +16,10 @@ // {t:"mouse", x, y, d, sh} primary-button pointer stream // {t:"mouse", x, y, d, b:2} right-button press/release // {t:"scroll", dy} wheel delta in logical px +// {t:"open", package} open the installed app with that package id; +// when its window is already open, restore it +// from the task strip, raise it and focus it. +// An id outside the installed catalog is ignored // // guest → host intents: // {t:"quit"} Shut Down @@ -36,7 +40,8 @@ export interface HostEvent { | "mouse" | "scroll" | "paste" - | "ime"; + | "ime" + | "open"; w?: number; h?: number; epoch?: number; @@ -57,6 +62,8 @@ export interface HostEvent { text?: string; /** IME preedit caret (char index into s), null when composition ends. */ c?: number | null; + /** Package id of the installed app an "open" line names. */ + package?: string; } export type CursorKind = diff --git a/shells/desktop/test/system-ui-sim.test.ts b/shells/desktop/test/system-ui-sim.test.ts index 84e6d79..2731c1b 100644 --- a/shells/desktop/test/system-ui-sim.test.ts +++ b/shells/desktop/test/system-ui-sim.test.ts @@ -7,7 +7,7 @@ // (bootWorld mutateOps), so the whole input dialect journey runs // headless — typing, drag selection, ⌘ chords, the notepad context // menu, paste-req — with guest intents (copy payloads!) asserted on -// the wire. +// the wire, and the host's open line launching apps by package id. // // The solid bundle must be prebuilt (the sim's fallback build cannot // resolve the framework-suffixed name): @@ -23,6 +23,7 @@ import { treeHasText, type SimWorld, } from "../../../vendor/pocketjs/hosts/sim/sim.ts"; +import { DESKTOP_NAME } from "../src/system-ui/pocket-apps.ts"; import { AQUA_THEME, CLASSIC_THEME, @@ -112,6 +113,16 @@ function mouse(svc: MockSvc, x: number, y: number, d: boolean, b?: number) { ); } +/** How many text nodes of the tree contain `text`. */ +function treeTextCount(tree: unknown, text: string): number { + if (tree == null) return 0; + const node = tree as { x?: unknown; k?: unknown[] }; + const own = typeof node.x === "string" && node.x.includes(text) ? 1 : 0; + return Array.isArray(node.k) + ? node.k.reduce((sum, child) => sum + treeTextCount(child, text), own) + : own; +} + function treeHasClass(tree: unknown, className: string): boolean { if (tree == null) return false; const node = tree as { c?: unknown; k?: unknown[] }; @@ -350,4 +361,115 @@ describe("system-ui System UI companion journey", () => { expect(treeHasText(tree, "PocketJS: Hero")).toBe(true); expect(svc.sent().some((line) => String(line.t).startsWith("pocket-"))).toBe(false); }, 30000); + + test("the host opens installed apps by package id", async () => { + const svc = mockSvc(); + const provider = await testTextProvider(); + const world = await bootWorld(APP, 60, {offload: provider.ops}, svc.mutateOps); + const guestFrame = world.frame; + world.frame = (...args) => { provider.betweenFrames(); guestFrame(...args); }; + svc.push({ t: "hello", w: 800, h: 600, epoch: 1755650000000 }); + await step(world, 3); + + const HERO = "dev.pocket-nexus.hero"; + const SETTINGS = "dev.pocket-nexus.settings"; + /** The focused flag of the last binding the shell made for a surface. */ + const focusedFlag = (surface: number) => + svc.surfaces().filter(([, handle]) => handle === surface).at(-1)?.[2]; + // A window's title is one text node in its caption and one in its task + // button, so two nodes mean exactly one window. + const titleNodes = (title: string) => treeTextCount(world.getTree(), title); + + // An open line opens the window and gives it focus, as its icon would. + svc.push({ t: "open", package: HERO }); + await step(world, 2); + expect(titleNodes("PocketJS: Hero")).toBe(2); + expect(focusedFlag(1)).toBe(1); + + svc.push({ t: "open", package: SETTINGS }); + await step(world, 2); + expect(titleNodes("PocketJS: Settings")).toBe(2); + expect(focusedFlag(1)).toBe(0); + expect(focusedFlag(2)).toBe(1); + + // A second line for an open app raises and focuses its window; it does + // not open another. + svc.push({ t: "open", package: HERO }); + await step(world, 2); + expect(titleNodes("PocketJS: Hero")).toBe(2); + expect(titleNodes("PocketJS: Settings")).toBe(2); + expect(focusedFlag(1)).toBe(1); + expect(focusedFlag(2)).toBe(0); + + // Cmd+M minimizes Hero and focus falls to Settings; the open line + // restores Hero from the task strip and focuses it again. + svc.push({ t: "key", k: "m", cmd: true }); + await step(world, 2); + expect(focusedFlag(1)).toBe(0); + expect(focusedFlag(2)).toBe(1); + svc.push({ t: "open", package: HERO }); + await step(world, 2); + expect(titleNodes("PocketJS: Hero")).toBe(2); + expect(focusedFlag(1)).toBe(1); + expect(focusedFlag(2)).toBe(0); + + // The line closes an open launcher before it changes focus. + svc.push({ t: "key", k: "escape", cmd: true }); + await step(world, 2); + expect(treeHasText(world.getTree(), "Shut Down...")).toBe(true); + svc.push({ t: "open", package: SETTINGS }); + await step(world, 2); + expect(treeHasText(world.getTree(), "Shut Down...")).toBe(false); + expect(focusedFlag(2)).toBe(1); + + // Ids outside the installed catalog, the System UI's own id and a line + // without an id change nothing. + const before = JSON.stringify(world.getTree()); + const bindings = svc.surfaces().length; + svc.push({ t: "open", package: "dev.pocket-nexus.missing" }); + svc.push({ t: "open", package: "dev.pocket-nexus.desktop.system-ui" }); + svc.push({ t: "open" }); + svc.push({ t: "open", package: 7 }); + await step(world, 2); + expect(JSON.stringify(world.getTree())).toBe(before); + expect(svc.surfaces().length).toBe(bindings); + expect(treeHasText(world.getTree(), "PocketJS: missing")).toBe(false); + }, 30000); + + test("the screen bar and About name the desktop after the System manifest", async () => { + const svc = mockSvc(); + const provider = await testTextProvider(); + const world = await bootWorld(APP, 60, {offload: provider.ops}, svc.mutateOps); + const guestFrame = world.frame; + world.frame = (...args) => { provider.betweenFrames(); guestFrame(...args); }; + svc.push({ t: "hello", w: 800, h: 600, epoch: 1755650000000 }); + await step(world, 3); + + // Classic boots with the welcome note focused: the name is in its text + // only. Closing the note and cycling to Aqua leaves no window focused, + // so the screen bar shows the desktop's own name. + expect(treeHasText(world.getTree(), `Welcome to ${DESKTOP_NAME}.`)).toBe(true); + svc.push({ t: "key", k: "w", cmd: true }); + svc.push({ t: "key", k: "t", cmd: true, sh: true }); + svc.push({ t: "key", k: "t", cmd: true, sh: true }); + await step(world, 3); + let tree = world.getTree(); + expect(treeHasClass(tree, AQUA_THEME.screenBar)).toBe(true); + expect(treeTextCount(tree, DESKTOP_NAME)).toBe(1); + + // The logo menu's first row opens About: the menu row is gone and the + // name is in the screen bar (About is no program of its own), the + // window's caption, its Dock tile's hidden label and its heading. + svc.push({ t: "key", k: "escape", cmd: true }); + await step(world, 2); + expect(treeHasText(world.getTree(), `About ${DESKTOP_NAME}`)).toBe(true); + const row = AQUA_THEME.metrics.screenBarH + 10; + mouse(svc, 30, row, true); + mouse(svc, 30, row, false); + await step(world, 2); + tree = world.getTree(); + expect(treeHasText(tree, "Shut Down...")).toBe(false); + expect(treeHasText(tree, `About ${DESKTOP_NAME}`)).toBe(true); + expect(treeHasText(tree, "github.com/pocket-nexus/pocket-shell")).toBe(true); + }, 30000); }); diff --git a/shells/desktop/test/system-ui.test.ts b/shells/desktop/test/system-ui.test.ts index fe9edbc..67987e9 100644 --- a/shells/desktop/test/system-ui.test.ts +++ b/shells/desktop/test/system-ui.test.ts @@ -70,7 +70,11 @@ import { wrapLine, type Doc, } from "../src/system-ui/notepad.ts"; -import { POCKET_APPS } from "../src/system-ui/pocket-apps.ts"; +import { + DESKTOP_NAME, + POCKET_APPS, + pocketAppByPackage, +} from "../src/system-ui/pocket-apps.ts"; import system from "../pocket.system.json"; import { validateAndResolveBuildPlan, @@ -138,6 +142,27 @@ describe("Pocket app desktop catalog", () => { } }); + test("the desktop takes its name from the System manifest's title", () => { + expect(DESKTOP_NAME).toBe(system.title); + expect(DESKTOP_NAME).toBe("Pocket Shell Desktop"); + }); + + test("a host open line resolves installed apps only", () => { + expect(pocketAppByPackage("dev.pocket-nexus.hero")).toEqual({ + package: "dev.pocket-nexus.hero", + title: "Hero", + viewport: [640, 360], + }); + for (const app of POCKET_APPS) + expect(pocketAppByPackage(app.package)).toBe(app); + // The System UI is installed but has no presentation: it is not an app. + expect(pocketAppByPackage(system.roles.systemUI)).toBeUndefined(); + expect(pocketAppByPackage("dev.pocket-nexus.missing")).toBeUndefined(); + expect(pocketAppByPackage("")).toBeUndefined(); + expect(pocketAppByPackage(undefined)).toBeUndefined(); + expect(pocketAppByPackage(1)).toBeUndefined(); + }); + test("rejects duplicate artifact outputs before any package build", async () => { const packages = await packageInputs(); const hero = packages.find(