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(