Files
agent-desktop/console/src/os/DesktopOS.tsx
T
LinJie a2e5e13bce feat(pack): 统一 CLI 为 yunopc-hub、桌面品牌对齐,并补充 README 打包/增量更新文档
- Windows/macOS 打包脚本与 pineagents.spec:CLI 可执行名由 qwenpaw 改为 yunopc-hub
- NSIS 安装器:CLI 注册命令与多语言文案统一为 yunopc-hub / YunOPC-Hub / 云超服
- 桌面 OS(MenuBar/BootScreen/DesktopOS):品牌标识改用 BRAND 单一来源
- README:补充规范化版本号设置、macOS/Windows 一键构建、增量更新完整命令

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-08 13:06:49 +08:00

551 lines
19 KiB
TypeScript

/**
* DesktopOS.tsx — Plan B PoC entry point: a full-screen desktop shell that
* reuses the existing routeRegistry components inside draggable windows.
*
* Zero changes to page components: each window renders the same Component the
* router would mount, wrapped in Suspense + ChunkErrorBoundary. Windows are
* one-per-app (route id) so global page stores don't clash across instances.
*
* Reachable at /os (registered in App.tsx) — isolated from MainLayout so the
* classic sidebar layout is untouched.
*/
import { Suspense, useMemo, useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { App, Dropdown, Spin, type MenuProps } from "antd";
import { Grid2X2, Image as ImageIcon, Trash2 } from "lucide-react";
import { useRoutes } from "../plugins/registry/hooks";
import { BRAND } from "../constants/branding";
import { uninstallPlugin } from "../api/modules/plugin";
import { ChunkErrorBoundary } from "../components/ChunkErrorBoundary";
import { useIsMobile } from "../hooks/useIsMobile";
import { useAgentStore } from "../stores/agentStore";
import { useSyncCodingMode } from "../stores/useSyncCodingMode";
import { useShallow } from "zustand/react/shallow";
import { useOsWindows } from "./osWindowStore";
import { useOsPlugins } from "./osPluginStore";
import { OS_APPS, STORE_APP, SETTINGS_APP, type OsAppDef } from "./osApps";
import { useOsApps, resolveAppDef } from "./osAppRegistry";
import { useOsStyles, MENUBAR_H } from "./useOsStyles";
import { useOsNotifyPoller } from "./useOsNotifyPoller";
import { isAgentAvailableInChat } from "../utils/agentVisibility";
import { purgeAppState, removePluginAppState } from "./osCleanup";
import WindowFrame from "./WindowFrame";
import WindowRouter from "./WindowRouter";
import { baseFromRoutePath } from "./osRouteMap";
import MenuBar from "./MenuBar";
import Dock from "./Dock";
import SpacesPanel from "./SpacesPanel";
import { shouldRevealDock, useEdgeReveal } from "./useEdgeReveal";
import { useOsIcons, defaultIconPos } from "./osIconStore";
import { useOsDock } from "./osDockStore";
import { useIconDrag } from "./useIconDrag";
import { arrangeApps } from "./iconArrangement";
import Launcher from "./Launcher";
import AppStore from "./AppStore";
import SettingsApp from "./SettingsApp";
import MissionControl from "./MissionControl";
import NotificationCenter from "./NotificationCenter";
import BootScreen from "./BootScreen";
import ConsolePollService from "../components/ConsolePollService";
import WallpaperPicker from "./WallpaperPicker";
import { useOsWallpaper } from "./osWallpaperStore";
import { wallpaperBackground } from "./wallpapers";
import { buttonRoleProps } from "./a11y";
import { useOsAppLauncher } from "./useOsAppLauncher";
import {
getPawAppIdFromPath,
setActivePawAppId,
} from "../plugins/pawapp-sdk/context";
import { getOsRootHref } from "../utils/navigationMode";
import "./osWindowBody.css";
/** Session flag so the boot splash plays once per browser session. */
const BOOT_FLAG_KEY = "qwenpaw.os.booted";
function shouldPlayBoot(): boolean {
try {
return window.sessionStorage.getItem(BOOT_FLAG_KEY) !== "1";
} catch {
return true;
}
}
export default function DesktopOS() {
const { styles, cx } = useOsStyles();
const { t, i18n } = useTranslation();
const { message } = App.useApp();
const launchApp = useOsAppLauncher();
const isMobile = useIsMobile();
const routes = useRoutes();
// Narrow subscription: only the fields the desktop shell renders from.
// Actions are referentially stable; geometry churn stays inside frames.
const {
windows,
order,
activeId,
launcherOpen,
setLauncher,
spaceId,
switchSpace,
missionControlOpen,
setMissionControl,
} = useOsWindows(
useShallow((s) => ({
windows: s.windows,
order: s.order,
activeId: s.activeId,
launcherOpen: s.launcherOpen,
setLauncher: s.setLauncher,
spaceId: s.spaceId,
switchSpace: s.switchSpace,
missionControlOpen: s.missionControlOpen,
setMissionControl: s.setMissionControl,
})),
);
const { uninstall } = useOsPlugins();
const { selectedAgent, refreshAgents } = useAgentStore();
useSyncCodingMode();
// Single app registry: desktop icons, window chrome and the launcher all
// read from the same source (catalog + system + dynamic plugin apps).
const { apps: visibleApps, appById } = useOsApps();
const { wallpaperId } = useOsWallpaper();
// Desktop right-click menu and wallpaper picker overlay.
const [wpOpen, setWpOpen] = useState(false);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [selectedIconId, setSelectedIconId] = useState<string | null>(null);
// Power-on splash: overlays the desktop, fades out, then unmounts. Played
// once per browser session (survives OS <-> classic layout switches).
// Stable identity so BootScreen's timer effect doesn't restart on every
// parent re-render.
const [booting, setBooting] = useState(shouldPlayBoot);
const handleBootDone = useCallback(() => {
setBooting(false);
try {
window.sessionStorage.setItem(BOOT_FLAG_KEY, "1");
} catch {
/* storage unavailable — splash will just replay next mount */
}
}, []);
// Poll approvals + unread inbox events → macOS-style notifications.
useOsNotifyPoller();
// Load agents once so Mission Control can list them as spaces.
useEffect(() => {
refreshAgents().catch(() => {
/* backend offline in PoC — current agent still shows as a space */
});
}, [refreshAgents]);
// Keep the active space aligned with the selected agent (full-screen-app
// switch semantics). Runs when the agent changes outside Mission Control.
useEffect(() => {
if (selectedAgent && selectedAgent !== spaceId) {
switchSpace(selectedAgent);
}
}, [selectedAgent, spaceId, switchSpace]);
// F3 toggles Mission Control, Escape closes it, Ctrl+←/→ switch Spaces —
// mirrors macOS full-screen-app navigation.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "F3") {
e.preventDefault();
setMissionControl(!useOsWindows.getState().missionControlOpen);
} else if (e.key === "Escape") {
setMissionControl(false);
} else if (
e.ctrlKey &&
(e.key === "ArrowLeft" || e.key === "ArrowRight")
) {
e.preventDefault();
const agentState = useAgentStore.getState();
const ids = agentState.agents
.filter(isAgentAvailableInChat)
.map((a) => a.id);
const current = agentState.selectedAgent;
if (!ids.includes(current)) ids.unshift(current);
if (ids.length < 2) return;
const idx = ids.indexOf(current);
const nextIdx =
e.key === "ArrowLeft"
? (idx - 1 + ids.length) % ids.length
: (idx + 1) % ids.length;
const nextId = ids[nextIdx];
agentState.setSelectedAgent(nextId);
useOsWindows.getState().switchSpace(nextId);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [setMissionControl]);
// Map route id -> Component for O(1) lookup when rendering window content.
const componentById = useMemo(() => {
const map = new Map<string, React.ComponentType>();
for (const r of routes) map.set(r.id, r.Component);
return map;
}, [routes]);
// Map route id -> registry path so each window can seed its own router base.
const routeById = useMemo(() => {
const map = new Map<string, (typeof routes)[number]>();
for (const route of routes) map.set(route.id, route);
return map;
}, [routes]);
// The desktop has one browser entry point: /os. Window navigation stays in
// each app's MemoryRouter and never becomes a browser deep link.
useEffect(() => {
const activeRoute = activeId ? routeById.get(activeId) : undefined;
const pawAppId =
activeRoute?.source !== "core" && activeRoute?.path.startsWith("/apps/")
? getPawAppIdFromPath(activeRoute.path)
: "";
setActivePawAppId(pawAppId || null);
const browserPath = getOsRootHref(window.location.pathname);
if (
`${window.location.pathname}${window.location.search}` !== browserPath
) {
window.history.replaceState(
{ ...window.history.state, osApp: activeId },
"",
browserPath,
);
}
}, [activeId, routeById]);
const openWindows = order
.map((id) => windows[id])
.filter((w): w is NonNullable<typeof w> => Boolean(w));
// Desktop keeps the menu bar visible. A maximized active window hides the
// Dock until the pointer reaches the bottom edge; mobile keeps it visible.
const { topHot, bottomHot } = useEdgeReveal();
const activeWindow = activeId ? windows[activeId] : undefined;
const activeWindowMaximized = Boolean(
activeWindow?.maximized && !activeWindow.minimized,
);
const dockRevealed = shouldRevealDock(
isMobile,
activeWindowMaximized,
bottomHot,
);
// Persisted desktop icon positions + transient drag handlers. While a
// drag is in flight the position lives in the DOM only (rAF-coalesced);
// the persisted store is written once when the gesture ends.
const {
positions: iconPositions,
layout: iconLayout,
setPosition,
setLayout: setIconLayout,
arrange: arrangeIcons,
} = useOsIcons();
const pinDock = useOsDock((s) => s.pin);
const iconDragHandlers = useIconDrag(
setPosition,
MENUBAR_H,
(id, event, moved) => {
if (!moved) return false;
const overDock = document
.elementFromPoint?.(event.clientX, event.clientY)
?.closest("[data-os-dock-dropzone]");
if (!overDock) return false;
pinDock(id);
return true;
},
);
const displayedApps = useMemo(
() =>
iconLayout === "free"
? visibleApps
: arrangeApps(visibleApps, iconLayout, t, i18n.resolvedLanguage),
[i18n.resolvedLanguage, iconLayout, t, visibleApps],
);
const visibleAppIds = useMemo(
() => displayedApps.map((app) => app.routeId),
[displayedApps],
);
// Viewport changed (browser zoom, resize, DPI/monitor switch): pull
// persisted windows into view and reflow desktop icons so newly available
// vertical space is used immediately. Mobile uses its own scrollable grid
// and must not rewrite the saved desktop layout.
useEffect(() => {
const onResize = () => {
useOsWindows.getState().clampToViewport();
if (window.innerWidth > 768) {
useOsIcons
.getState()
.reflowToViewport(visibleAppIds, window.innerHeight);
}
};
onResize();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [visibleAppIds]);
const closeDesktopMenu = () => setCtxMenu(null);
const handleArrangeIcons = () => {
arrangeIcons(
displayedApps.map((app) => app.routeId),
window.innerHeight,
);
setIconLayout("free");
closeDesktopMenu();
};
const desktopMenuItems: MenuProps["items"] = [
{
key: "arrange",
icon: <Grid2X2 size={15} />,
label: t("os.arrangeDesktop", "Clean up"),
onClick: handleArrangeIcons,
},
{ type: "divider" },
{
key: "wallpaper",
icon: <ImageIcon size={15} />,
label: t("os.changeWallpaper", "Change wallpaper"),
onClick: () => {
setWpOpen(true);
closeDesktopMenu();
},
},
];
// Plugin apps are removed from the backend and live frontend state. Built-in
// catalog apps are toggled locally; system apps are not uninstallable.
const handleUninstall = async (a: OsAppDef) => {
const name = t(a.labelKey, a.fallback);
if (a.source) {
const source = a.source;
try {
await uninstallPlugin(source);
removePluginAppState(source);
message.success(
t("os.uninstalledApp", { name, defaultValue: "Uninstalled" }),
);
} catch (err) {
message.error(
err instanceof Error
? err.message
: t("os.uninstallFailed", "Uninstall failed"),
);
}
return;
}
uninstall(a.routeId);
purgeAppState([a.routeId]);
message.info(t("os.uninstalledApp", { name, defaultValue: "Uninstalled" }));
};
// Renders a single desktop icon (double-click opens; right-click uninstalls
// when applicable). Positioning is handled by the caller.
const renderIcon = (a: OsAppDef) => {
const Icon = a.Icon;
const uninstallable =
Boolean(a.source) || OS_APPS.some((o) => o.routeId === a.routeId);
const activate = () => void launchApp(a.routeId);
const iconEl = (
<div
className={cx(
styles.desktopIcon,
selectedIconId === a.routeId && styles.desktopIconSelected,
)}
onDoubleClick={activate}
onClick={(event) => {
event.stopPropagation();
setSelectedIconId(a.routeId);
if (isMobile) activate();
}}
onFocus={() => setSelectedIconId(a.routeId)}
title={t(a.labelKey, a.fallback)}
{...buttonRoleProps(activate, t(a.labelKey, a.fallback))}
>
<div className={styles.iconTile} style={{ background: a.accent }}>
<Icon size={26} />
</div>
<span>{t(a.labelKey, a.fallback)}</span>
</div>
);
if (!uninstallable) return iconEl;
return (
<Dropdown
trigger={["contextMenu"]}
menu={{
items: [
{
key: "uninstall",
danger: true,
icon: <Trash2 size={14} />,
label: t("os.uninstall", "Uninstall"),
onClick: () => void handleUninstall(a),
},
],
}}
>
{iconEl}
</Dropdown>
);
};
return (
<div
className={styles.desktop}
style={{ background: wallpaperBackground(wallpaperId) }}
onPointerDown={() => {
setSelectedIconId(null);
if (launcherOpen) setLauncher(false);
if (ctxMenu) setCtxMenu(null);
}}
onContextMenu={(e) => {
// Only the empty desktop background opens the wallpaper menu; icons,
// Dock and menu bar keep their own context behaviour.
if (e.target !== e.currentTarget) return;
e.preventDefault();
setLauncher(false);
setCtxMenu({ x: e.clientX, y: e.clientY });
}}
>
{/* Desktop icons. Mobile keeps the fixed grid; desktop uses persisted,
free-drag positions. */}
{isMobile ? (
<div className={styles.iconsGrid}>
{displayedApps.map((a) => (
<div key={a.routeId}>{renderIcon(a)}</div>
))}
</div>
) : (
<div className={styles.iconsLayer}>
{displayedApps.map((a, i) => {
const pos =
iconLayout === "free"
? iconPositions[a.routeId] ??
defaultIconPos(i, window.innerHeight)
: defaultIconPos(i, window.innerHeight);
return (
<div
key={a.routeId}
className={styles.iconAbsolute}
style={{ left: pos.x, top: pos.y }}
{...(iconLayout === "free"
? iconDragHandlers(a.routeId, pos)
: {})}
>
{renderIcon(a)}
</div>
);
})}
</div>
)}
{/* Persistent background watermark — brand mark. Sits at the
lowest layer and never intercepts pointer events, so it reads as a
backdrop behind icons and app windows rather than a card. */}
<div className={styles.emptyHint}>
<img src="/logo-light.svg" alt="" />
<div className={styles.emptyBrandName}>{BRAND.name}</div>
</div>
{/* Windows layer */}
<div className={styles.windowsLayer}>
{openWindows.map((win) => {
const def =
appById.get(win.id) ?? resolveAppDef(win.id) ?? OS_APPS[0];
const isStore = win.id === STORE_APP.routeId;
const isSettings = win.id === SETTINGS_APP.routeId;
const Component = componentById.get(win.id);
if (!isStore && !isSettings && !Component) {
return null;
}
return (
<WindowFrame
key={win.id}
win={win}
title={t(def.labelKey, def.fallback)}
Icon={def.Icon}
accent={def.accent}
isMobile={isMobile}
themedSurface={!isStore && !isSettings}
minW={def.minW}
minH={def.minH}
>
<ChunkErrorBoundary resetKey={win.id}>
<Suspense
fallback={
<div className={styles.loading}>
<Spin />
</div>
}
>
{isStore ? (
<AppStore />
) : isSettings ? (
<SettingsApp />
) : Component ? (
<WindowRouter
routeId={win.id}
base={baseFromRoutePath(routeById.get(win.id)?.path)}
element={<Component />}
/>
) : null}
</Suspense>
</ChunkErrorBoundary>
</WindowFrame>
);
})}
</div>
{launcherOpen && <Launcher apps={visibleApps} />}
{missionControlOpen && <MissionControl />}
<NotificationCenter />
{/* Global approval/message feed (same as MainLayout). Populates the
shared ApprovalContext so the Inbox + Chat windows show pending
tool approvals inside the OS, matching the browser layout. */}
<ConsolePollService />
<SpacesPanel visible={topHot} />
<MenuBar hidden={isMobile} />
<Dock revealed={dockRevealed} />
{ctxMenu && (
<Dropdown
open
trigger={[]}
placement="bottomLeft"
overlayClassName={styles.desktopContextMenu}
menu={{ items: desktopMenuItems }}
popupRender={(menu) => (
<div
onPointerDown={(event) => event.stopPropagation()}
onContextMenu={(event) => event.stopPropagation()}
>
{menu}
</div>
)}
onOpenChange={(open) => {
if (!open) closeDesktopMenu();
}}
>
<span
className={styles.desktopMenuAnchor}
style={{ left: ctxMenu.x, top: ctxMenu.y }}
onPointerDown={(event) => event.stopPropagation()}
/>
</Dropdown>
)}
{wpOpen && <WallpaperPicker onClose={() => setWpOpen(false)} />}
{booting && <BootScreen onDone={handleBootDone} />}
</div>
);
}