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
11 changes: 11 additions & 0 deletions shells/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 32 additions & 10 deletions shells/desktop/src/system-ui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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.",
"",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -826,7 +835,7 @@ export default function App() {
const data: AboutData = { kind: "about", armed: createState<string | null>(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"],
Expand Down Expand Up @@ -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() },
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
}

Expand Down
23 changes: 20 additions & 3 deletions shells/desktop/src/system-ui/pocket-apps.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
// 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";

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;
Expand All @@ -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";
3 changes: 2 additions & 1 deletion shells/desktop/src/system-ui/programs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -673,7 +674,7 @@ export function AboutView(props: {
<View class="flex-1 flex-col p-[10] gap-[8]">
<View class="flex-row items-center gap-[10]">
<Image class="w-[32] h-[32]" src={props.theme.icon("computer", 32)} />
<UiText theme={props.theme} xl t="Pocket Shell Desktop" />
<UiText theme={props.theme} xl t={DESKTOP_NAME} />
</View>
<View class="h-[2] flex-col">
<View class={props.theme.popupSeparatorDark} />
Expand Down
9 changes: 8 additions & 1 deletion shells/desktop/src/system-ui/svc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,7 +40,8 @@ export interface HostEvent {
| "mouse"
| "scroll"
| "paste"
| "ime";
| "ime"
| "open";
w?: number;
h?: number;
epoch?: number;
Expand All @@ -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 =
Expand Down
124 changes: 123 additions & 1 deletion shells/desktop/test/system-ui-sim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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,
Expand Down Expand Up @@ -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<number>((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[] };
Expand Down Expand Up @@ -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);
});
Loading
Loading