Files
app/tauri/src/runtime/app-management/app-registry.ts
T

182 lines
7.7 KiB
TypeScript

/**
* Runtime 本地 Core App 注册表。
*
* 当前 MVP 只注册默认且可恢复的 `chat`;后续 App Registry/市场只能通过 Runtime
* 管理记录,App 自身不得直接篡改注册表。
*
* Runtime-local application registry for the first App MVP.
*
* `app_scope` deliberately is a local routing key, not a publisher identity
* or a marketplace namespace. The initial registry is static, but the same
* record shape is the boundary that the later App Registry/Market will own.
*/
import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest";
import { BUNDLED_REFERENCE_MANIFESTS } from "@/runtime/app-management/reference-miniapps";
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
export type AppScope = "chat" | "app-registry" | "settings" | "runtime" | (string & {});
export type MiniAppKind = "system" | "bundled";
const SYSTEM_APP_SCOPE: AppScope = "chat";
export type AppToolDefinition = {
name: string;
handling: "direct" | "interactive" | "launch" | "foreground" | "operation";
/** JSON-object property names accepted by this tool. */
parameters: readonly string[];
requires_permissions?: readonly string[];
input_schema?: JsonObject;
output_schema?: JsonObject;
target?: {
app_scope: AppScope;
requires_foreground: boolean;
restore_previous_focus: boolean;
};
};
/**
* A Runtime-validated app manifest. This deliberately contains declarative
* metadata only: no bundle URL, executable code, or Host capability handle.
*/
export type AppManifest = {
app_scope: AppScope;
version: string;
tools: readonly AppToolDefinition[];
permissions: readonly string[];
};
export type CoreAppRecord = {
app_scope: AppScope;
kind: MiniAppKind;
enabled: boolean;
default_eligible: boolean;
recovery: boolean;
manifest: AppManifest;
};
/**
* The Runtime installation boundary uses the frozen SDK v1 vocabulary.
* Legacy host labels (`core` / `extension`) are intentionally not accepted
* here; callers must migrate before they can register an App.
*/
export type CoreAppRecordInput = Omit<CoreAppRecord, "kind"> & { kind: MiniAppKind };
const INITIAL_CORE_APPS: readonly CoreAppRecord[] = [
{
app_scope: "chat", kind: "system", enabled: true, default_eligible: true, recovery: true,
manifest: {
app_scope: "chat", version: "1.0.0", permissions: [],
tools: [
{ name: "choice", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] },
{ name: "confirm", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] },
{ name: "input", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] },
],
},
},
];
/**
* The Runtime owns this registry. Apps may later request operations through
* a Runtime SDK, but they never mutate this collection directly.
*/
export class CoreAppRegistry {
private readonly apps = new Map<AppScope, CoreAppRecord>();
public constructor(records: readonly CoreAppRecordInput[] = INITIAL_CORE_APPS) {
for (const record of records) this.register(record);
if (records === INITIAL_CORE_APPS) {
for (const manifest of BUNDLED_REFERENCE_MANIFESTS) this.installMiniAppManifest(manifest);
}
}
public list(): readonly CoreAppRecord[] {
return [...this.apps.values()].map(copyRecord);
}
public get(appScope: AppScope): CoreAppRecord | undefined {
const record = this.apps.get(appScope);
return record ? copyRecord(record) : undefined;
}
public defaultApp(): CoreAppRecord {
const selected = this.list().find(record => record.enabled && record.default_eligible);
if (selected) return selected;
const recovery = this.list().find(record => record.enabled && record.recovery);
if (recovery) return recovery;
throw new Error("Runtime has no enabled recovery application.");
}
/** Runtime-only installation path; extension Apps never receive this object. */
public install(record: CoreAppRecordInput): void { this.register(record); }
/** Admits a validated SDK v1 Manifest without exposing the Manifest object to Apps. */
public installMiniAppManifest(manifest: MiniAppManifestV1, options: Pick<CoreAppRecord, "enabled" | "default_eligible" | "recovery"> = { enabled: true, default_eligible: false, recovery: false }): void {
const validation = validateMiniAppManifest(manifest);
if (!validation.accepted) throw new Error(`MiniApp Manifest denied: ${validation.detail}`);
this.install({
app_scope: manifest.app_scope,
kind: manifest.kind,
...options,
manifest: {
app_scope: manifest.app_scope,
version: manifest.version,
permissions: [...manifest.requested_capabilities],
tools: manifest.tools.map(tool => ({
name: tool.id,
handling: tool.handling,
parameters: [],
input_schema: tool.input_schema,
output_schema: tool.output_schema,
target: tool.target,
...(tool.permissions?.length ? { requires_permissions: [...tool.permissions] } : {}),
})),
},
});
}
private register(record: CoreAppRecordInput): void {
if (!isAppScope(record.app_scope) || record.manifest.app_scope !== record.app_scope) throw new Error("Core App Registry received an invalid app_scope.");
if (record.kind === "system" && record.app_scope !== SYSTEM_APP_SCOPE) throw new Error("Only the Interact system MiniApp may use kind=system in SDK v1.");
if (this.apps.has(record.app_scope)) throw new Error(`Core App Registry has duplicate app_scope: ${record.app_scope}`);
if (!isManifest(record.manifest)) throw new Error(`Core App Registry has an invalid manifest: ${record.app_scope}`);
this.apps.set(record.app_scope, copyRecord(record));
}
}
function copyRecord(record: CoreAppRecord): CoreAppRecord {
return {
...record,
manifest: {
...record.manifest,
permissions: [...record.manifest.permissions],
tools: record.manifest.tools.map(tool => ({ ...tool, parameters: [...tool.parameters], ...(tool.input_schema ? { input_schema: clone(tool.input_schema) } : {}), ...(tool.output_schema ? { output_schema: clone(tool.output_schema) } : {}) })),
},
};
}
function isManifest(manifest: AppManifest): boolean {
const toolNames = new Set<string>();
return typeof manifest.version === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version)
&& manifest.permissions.every(permission => /^[a-z][a-z0-9._-]{0,63}$/.test(permission))
&& manifest.tools.every(tool => {
const valid = /^[a-z][a-z0-9._-]{0,63}$/.test(tool.name)
&& !toolNames.has(tool.name)
&& ["direct", "interactive", "launch", "foreground", "operation"].includes(tool.handling)
&& tool.parameters.every(parameter => /^[a-z][a-z0-9_]{0,63}$/.test(parameter))
&& (!tool.input_schema || isJsonObject(tool.input_schema))
&& (!tool.output_schema || isJsonObject(tool.output_schema))
&& (!tool.requires_permissions || (tool.requires_permissions.length > 0
&& new Set(tool.requires_permissions).size === tool.requires_permissions.length
&& tool.requires_permissions.every(permission => /^[a-z][a-z0-9._-]{0,63}$/.test(permission)
&& manifest.permissions.includes(permission))));
toolNames.add(tool.name);
return valid;
});
}
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
function isJsonObject(value: unknown): value is JsonObject { return Boolean(value && typeof value === "object" && !Array.isArray(value)); }
export function isAppScope(value: unknown): value is AppScope {
return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
}