feat(plugin): add SCUM ownership migration foundation

This commit is contained in:
npc0-hue
2026-07-28 18:11:00 +08:00
parent d24074134a
commit 271e1e684f
29 changed files with 328 additions and 315 deletions
+15 -14
View File
@@ -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");
});
});
+26 -27
View File
@@ -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>
);
}
+3
View File
@@ -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
})),