feat(plugin): add SCUM ownership migration foundation
This commit is contained in:
@@ -25,16 +25,19 @@ const plugin: GamePluginResponse = {
|
||||
manifestRef: "artifact://manifests/game.scum/1.0.0",
|
||||
createFormSchemaRef: "schemas/create-form.schema.json",
|
||||
requiredRunCapabilities: [],
|
||||
declaredPermissions: ["server.read", "server.logs.read", "server.remote.access", "server.game-client.read", "server.game-client.command"],
|
||||
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false, remoteAccess: true },
|
||||
declaredPermissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command"],
|
||||
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false, remoteAccess: false },
|
||||
lifecycleActions: {},
|
||||
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
|
||||
bridgeActions: ["server.instances.read", "logs.query"],
|
||||
pages: [{
|
||||
key: "files-config",
|
||||
title: "文件与配置",
|
||||
path: "/files-config",
|
||||
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read", "server.remote.access"],
|
||||
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"]
|
||||
bundleKey: "scum-server-plugin",
|
||||
bundleVersion: "1.0.0",
|
||||
bundleIntegritySha256: "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b",
|
||||
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read"],
|
||||
bridgeActions: ["server.instances.read", "logs.query"]
|
||||
}],
|
||||
tags: ["scum"],
|
||||
aiPurposes: [],
|
||||
@@ -42,10 +45,9 @@ const plugin: GamePluginResponse = {
|
||||
gameClientBridge: {
|
||||
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }],
|
||||
snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
|
||||
queryTemplates: [{ key: "scum.player.search", title: "Search player", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-search.parameters.json", resultSchemaRef: "schemas/bridge/player-search.result.json", maxRows: 50, timeoutSeconds: 10 }],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
|
||||
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"] }]
|
||||
},
|
||||
status: "installed"
|
||||
};
|
||||
@@ -72,20 +74,19 @@ function props(serverId = "server-1"): PageComponentProps {
|
||||
}
|
||||
|
||||
describe("PluginPageHostPage", () => {
|
||||
it("renders SCUM operations from manifest-owned declarations", () => {
|
||||
it("renders a generic manifest-owned bundle declaration", () => {
|
||||
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
|
||||
expect(html).toContain("文件与配置");
|
||||
expect(html).toContain("平台托管上下文");
|
||||
expect(html).toContain("命令目录");
|
||||
expect(html).toContain("快照目录");
|
||||
expect(html).toContain("查询模板");
|
||||
expect(html).toContain("scum-server-plugin@1.0.0");
|
||||
expect(html).toContain("完整性");
|
||||
expect(html).toContain("返回服务器");
|
||||
expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i);
|
||||
});
|
||||
|
||||
it("shows a declared availability reason when server context is missing", () => {
|
||||
it("does not mount a bundle without client-side availability validation", () => {
|
||||
const html = renderToStaticMarkup(<PluginPageHostPage {...props("")} initialPlugin={plugin} />);
|
||||
expect(html).toContain("SCUM 运维声明不可用");
|
||||
expect(html).toContain("缺少服务器实例上下文");
|
||||
expect(html).toContain("未绑定服务器");
|
||||
expect(html).toContain("正在校验并加载插件页面 bundle");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { ArrowLeft, PlugZap } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import * as React from "react";
|
||||
import { useCallback, useEffect, useState, type ComponentType } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
import { PageFrame } from "../components/PageFrame";
|
||||
import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench";
|
||||
import { GamePlayerIntelligencePanel } from "../components/GamePlayerIntelligencePanel";
|
||||
import { ScumMapTrajectoryPanel } from "../components/ScumMapTrajectoryPanel";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
|
||||
import { normalizeScumRouteKey, resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
|
||||
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
|
||||
import { loadPluginPageBundle } from "../utils/pluginPageBundles";
|
||||
|
||||
type PluginPageState =
|
||||
| { status: "loading" }
|
||||
@@ -24,9 +22,12 @@ interface PluginPageHostPageProps extends PageComponentProps {
|
||||
|
||||
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) {
|
||||
const pluginId = params.pluginId ?? "";
|
||||
const routeKey = normalizeScumRouteKey(pluginId, params.routeKey ?? "");
|
||||
const routeKey = params.routeKey ?? "";
|
||||
const serverId = params.serverId ?? "";
|
||||
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
|
||||
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspace?: unknown; availability: { available: boolean; reason?: string } }> | null>(null);
|
||||
const [bundleError, setBundleError] = useState("");
|
||||
const [availability, setAvailability] = useState<{ available: boolean; reason?: string }>({ available: false, reason: "正在验证 Companion 可用性。" });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!pluginId || !routeKey) {
|
||||
@@ -49,6 +50,20 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
||||
}
|
||||
}, [initialPlugin, load]);
|
||||
|
||||
const readyPlugin = state.status === "ready" ? state.plugin : undefined;
|
||||
const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey);
|
||||
const declaredBundlePage = readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin).pages.find((candidate) => candidate.key === routeKey) : undefined;
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!declaredBundlePage) return () => { active = false; };
|
||||
globalThis.__PLUGIN_PAGE_REACT__ = React;
|
||||
setBundle(null); setBundleError("");
|
||||
void loadPluginPageBundle(declaredBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); });
|
||||
if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; }
|
||||
void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => { if (active) setAvailability({ available: status.available, reason: status.reason }); }).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); });
|
||||
return () => { active = false; };
|
||||
}, [declaredBundlePage, serverId]);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <LoadingState label="正在加载插件页面声明…" />;
|
||||
}
|
||||
@@ -56,7 +71,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
||||
return <ErrorState title="插件页面不可用" reason={state.reason} onRetry={() => void load()} />;
|
||||
}
|
||||
|
||||
const page = state.plugin.pages.find((candidate) => candidate.key === routeKey);
|
||||
const page = declaredPage;
|
||||
if (!page) {
|
||||
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
|
||||
}
|
||||
@@ -67,9 +82,6 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
||||
serverInstanceId: serverId || undefined,
|
||||
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
|
||||
});
|
||||
const isScumOperations = state.plugin.id === scumOperationsPluginId && routeKey === scumOperationsRouteKey;
|
||||
const scumResolution = isScumOperations ? resolveScumOperationsPageContract(state.plugin, serverId) : null;
|
||||
|
||||
return (
|
||||
<div className="console-page">
|
||||
<PageFrame
|
||||
@@ -92,24 +104,11 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scumResolution && !scumResolution.available && <ErrorState title="SCUM 运维声明不可用" reason={scumResolution.reason} compact />}
|
||||
{scumResolution?.available && (
|
||||
<div className="action-list" aria-label="SCUM operations declarations">
|
||||
<span><strong>命令目录</strong> {scumResolution.contract.commands.length} 项,均来自插件 operations 页面声明</span>
|
||||
<span><strong>快照目录</strong> {scumResolution.contract.snapshots.length} 项,按插件 schemaVersion 投影</span>
|
||||
<span><strong>查询模板</strong> {scumResolution.contract.queryTemplates.length} 项,仅保留声明式 SQLite 模板引用</span>
|
||||
</div>
|
||||
)}
|
||||
{!scumResolution && (
|
||||
<EmptyState
|
||||
title="插件页面已接入 Host Bridge"
|
||||
description="当前页面只接收 manifest 声明与平台安全上下文;具体操作由对应插件页面实现。"
|
||||
/>
|
||||
)}
|
||||
<div className="action-list" aria-label="plugin page declarations"><span><strong>Bundle</strong> {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}</span><span><strong>完整性</strong> {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}</span><span><strong>Companion</strong> {availability.available ? "可用" : "不可用"}</span></div>
|
||||
</section>
|
||||
{scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />}
|
||||
{scumResolution?.available && <GamePlayerIntelligencePanel serverInstanceId={serverId} />}
|
||||
{scumResolution?.available && <ScumMapTrajectoryPanel serverInstanceId={serverId} />}
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, availability })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1997,6 +1997,9 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact
|
||||
key: item.key,
|
||||
title: item.title,
|
||||
path: item.path,
|
||||
bundleKey: item.bundleKey,
|
||||
bundleVersion: item.bundleVersion,
|
||||
bundleIntegritySha256: item.bundleIntegritySha256,
|
||||
permissions: item.permissions as PluginBridgeManifestContract["declaredPermissions"],
|
||||
bridgeActions: item.bridgeActions as PluginBridgeAction[] | undefined
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user