Add a VS Code-specific foreground permission responder while preserving the server-owned implementation for web, desktop, and mobile runtimes. Persist the authoritative VS Code policy in extension global state and expose matching GET/PUT bridge routes. Broadcast policy updates to the sidebar, session editor panels, and agent manager so every active webview observes the same explicit per-session policy. Resolve missing child-session lineage through OpenCode, honor nearest explicit ancestor overrides, deduplicate concurrent requests, retry transient replies, and reconcile pending permissions after enablement, bootstrap, and reconnect. Treat resolved requests as handled and route notification suppression through the same responder outcome. Keep post-toggle reconciliation failures non-fatal after policy persistence and fail closed when lineage or replies cannot be confirmed. Document that auto-accept intentionally cannot run while every OpenChamber webview is closed or suspended.
565 lines
21 KiB
TypeScript
565 lines
21 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import * as vscode from 'vscode';
|
|
import { randomUUID } from 'crypto';
|
|
import { removeProviderConfig, getProviderSources } from './opencodeConfig';
|
|
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
|
|
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
|
|
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
|
import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials';
|
|
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
|
|
import type { BridgeContext, BridgeResponse } from './bridge';
|
|
|
|
type BridgeMessageInput = {
|
|
id: string;
|
|
type: string;
|
|
payload?: unknown;
|
|
};
|
|
|
|
type SystemRuntimeDeps = {
|
|
resolveUserPath: (value: string, baseDirectory: string) => string;
|
|
fetchModelsMetadata: () => Promise<unknown>;
|
|
updateCheckUrl: string;
|
|
clientReloadDelayMs: number;
|
|
};
|
|
|
|
const NOTIFICATION_CLAIM_TTL_MS = 10_000;
|
|
const notificationClaims = new Map<string, number>();
|
|
|
|
const claimNotification = (key: string): boolean => {
|
|
const now = Date.now();
|
|
for (const [claimKey, claimedAt] of notificationClaims) {
|
|
if (now - claimedAt > NOTIFICATION_CLAIM_TTL_MS) {
|
|
notificationClaims.delete(claimKey);
|
|
}
|
|
}
|
|
|
|
const existing = notificationClaims.get(key);
|
|
if (existing && now - existing <= NOTIFICATION_CLAIM_TTL_MS) {
|
|
return false;
|
|
}
|
|
|
|
notificationClaims.set(key, now);
|
|
return true;
|
|
};
|
|
|
|
|
|
const getOpenChamberConfigDir = (): string => {
|
|
if (process.platform === 'win32') {
|
|
const appData = process.env.APPDATA;
|
|
if (appData) return path.join(appData, 'openchamber');
|
|
}
|
|
return path.join(os.homedir(), '.config', 'openchamber');
|
|
};
|
|
|
|
const sanitizeInstallScope = (scope: string): 'vscode' | 'web' => {
|
|
if (scope === 'vscode' || scope === 'web') return scope;
|
|
return 'web';
|
|
};
|
|
|
|
const getOrCreateInstallId = (scope: string): string => {
|
|
const configDir = getOpenChamberConfigDir();
|
|
const normalizedScope = sanitizeInstallScope(scope);
|
|
const idPath = path.join(configDir, `install-id-${normalizedScope}`);
|
|
|
|
try {
|
|
const existing = fs.readFileSync(idPath, 'utf8').trim();
|
|
if (existing) return existing;
|
|
} catch {
|
|
// Generate new id.
|
|
}
|
|
|
|
const installId = randomUUID();
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
fs.writeFileSync(idPath, `${installId}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
return installId;
|
|
};
|
|
|
|
const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => {
|
|
if (value === 'darwin') return 'macos';
|
|
if (value === 'win32') return 'windows';
|
|
if (value === 'linux') return 'linux';
|
|
return 'web';
|
|
};
|
|
|
|
const mapNodeArchToApiArch = (value: string): 'arm64' | 'x64' | 'unknown' => {
|
|
if (value === 'arm64' || value === 'aarch64') return 'arm64';
|
|
if (value === 'x64' || value === 'amd64') return 'x64';
|
|
return 'unknown';
|
|
};
|
|
|
|
type ParsedDiffHunk = {
|
|
newStart: number;
|
|
oldLines: string[];
|
|
newLines: string[];
|
|
};
|
|
|
|
const VIRTUAL_DIFF_SCHEME = 'openchamber-diff';
|
|
const virtualDiffContents = new Map<string, string>();
|
|
let virtualDiffCounter = 0;
|
|
let virtualDiffProviderDisposable: vscode.Disposable | null = null;
|
|
|
|
const ensureVirtualDiffProviderRegistered = (ctx?: BridgeContext): void => {
|
|
if (virtualDiffProviderDisposable) {
|
|
return;
|
|
}
|
|
|
|
virtualDiffProviderDisposable = vscode.workspace.registerTextDocumentContentProvider(
|
|
VIRTUAL_DIFF_SCHEME,
|
|
{
|
|
provideTextDocumentContent: (uri: vscode.Uri) => {
|
|
const key = new URLSearchParams(uri.query).get('key') || '';
|
|
return virtualDiffContents.get(key) ?? '';
|
|
},
|
|
},
|
|
);
|
|
|
|
if (ctx?.context) {
|
|
ctx.context.subscriptions.push(virtualDiffProviderDisposable);
|
|
}
|
|
};
|
|
|
|
const createVirtualOriginalDiffUri = (modifiedPath: string, content: string): vscode.Uri => {
|
|
const key = `${Date.now()}-${++virtualDiffCounter}`;
|
|
virtualDiffContents.set(key, content);
|
|
|
|
if (virtualDiffContents.size > 100) {
|
|
const firstKey = virtualDiffContents.keys().next().value;
|
|
if (firstKey) {
|
|
virtualDiffContents.delete(firstKey);
|
|
}
|
|
}
|
|
|
|
return vscode.Uri.from({
|
|
scheme: VIRTUAL_DIFF_SCHEME,
|
|
path: `/${path.basename(modifiedPath) || 'original'}`,
|
|
query: `key=${encodeURIComponent(key)}`,
|
|
});
|
|
};
|
|
|
|
const parseUnifiedDiffHunks = (patch: string): ParsedDiffHunk[] => {
|
|
const lines = patch.split(/\r?\n/);
|
|
const hunks: ParsedDiffHunk[] = [];
|
|
|
|
let current: ParsedDiffHunk | null = null;
|
|
|
|
for (const line of lines) {
|
|
const headerMatch = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
if (headerMatch) {
|
|
if (current) {
|
|
hunks.push(current);
|
|
}
|
|
current = {
|
|
newStart: Number(headerMatch[1] || 1),
|
|
oldLines: [],
|
|
newLines: [],
|
|
};
|
|
continue;
|
|
}
|
|
|
|
if (!current) continue;
|
|
|
|
if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('\\ No newline')) {
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('-')) {
|
|
current.oldLines.push(line.slice(1));
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('+')) {
|
|
current.newLines.push(line.slice(1));
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith(' ')) {
|
|
const content = line.slice(1);
|
|
current.oldLines.push(content);
|
|
current.newLines.push(content);
|
|
}
|
|
}
|
|
|
|
if (current) {
|
|
hunks.push(current);
|
|
}
|
|
|
|
return hunks;
|
|
};
|
|
|
|
const reconstructOriginalContentFromPatch = (modifiedContent: string, patch: string): string | null => {
|
|
const hunks = parseUnifiedDiffHunks(patch);
|
|
if (hunks.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const lines = modifiedContent.split('\n');
|
|
for (let index = hunks.length - 1; index >= 0; index -= 1) {
|
|
const hunk = hunks[index];
|
|
if (!hunk) {
|
|
continue;
|
|
}
|
|
const startIndex = Math.max(0, hunk.newStart - 1);
|
|
const replaceCount = hunk.newLines.length;
|
|
lines.splice(startIndex, replaceCount, ...hunk.oldLines);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
};
|
|
|
|
const fetchFreeZenModels = async (): Promise<Array<{ id: string; owned_by?: string }>> => [];
|
|
|
|
export async function handleSystemBridgeMessage(
|
|
message: BridgeMessageInput,
|
|
ctx: BridgeContext | undefined,
|
|
deps: SystemRuntimeDeps,
|
|
): Promise<BridgeResponse | null> {
|
|
const { id, type, payload } = message;
|
|
|
|
switch (type) {
|
|
case 'api:opencode/directory': {
|
|
const target = (payload as { path?: string })?.path;
|
|
if (!target) {
|
|
return { id, type, success: false, error: 'Path is required' };
|
|
}
|
|
const baseDirectory =
|
|
ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
const resolvedPath = deps.resolveUserPath(target, baseDirectory);
|
|
const result = await ctx?.manager?.setWorkingDirectory(resolvedPath);
|
|
if (!result) {
|
|
return { id, type, success: false, error: 'OpenCode manager unavailable' };
|
|
}
|
|
return { id, type, success: true, data: result };
|
|
}
|
|
|
|
case 'api:models/metadata': {
|
|
try {
|
|
const data = await deps.fetchModelsMetadata();
|
|
return { id, type, success: true, data };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { id, type, success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
case 'api:opencode/version': {
|
|
try {
|
|
const apiUrl = ctx?.manager?.getApiUrl();
|
|
if (!apiUrl) {
|
|
return { id, type, success: true, data: { version: null, error: 'OpenCode manager unavailable' } };
|
|
}
|
|
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
|
const response = await fetch(new URL('global/health', base).toString(), {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json', ...ctx?.manager?.getOpenCodeAuthHeaders() },
|
|
});
|
|
const health = await response.json().catch(() => null) as { version?: unknown; error?: unknown } | null;
|
|
if (!response.ok) {
|
|
const message = typeof health?.error === 'string' ? health.error : response.statusText || 'Failed to read OpenCode version';
|
|
return { id, type, success: true, data: { version: null, error: message } };
|
|
}
|
|
const version = typeof health?.version === 'string' && health.version.trim().length > 0
|
|
? health.version.trim().replace(/^v/, '')
|
|
: null;
|
|
return { id, type, success: true, data: { version } };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { id, type, success: true, data: { version: null, error: errorMessage } };
|
|
}
|
|
}
|
|
|
|
case 'api:session-activity:get': {
|
|
return { id, type, success: true, data: getSessionActivitySnapshot() };
|
|
}
|
|
|
|
case 'api:notifications:claim': {
|
|
const key = typeof (payload as { key?: unknown } | undefined)?.key === 'string'
|
|
? (payload as { key: string }).key.trim()
|
|
: '';
|
|
return { id, type, success: true, data: { claimed: key ? claimNotification(key) : false } };
|
|
}
|
|
|
|
case 'api:zen:models': {
|
|
const models = await fetchFreeZenModels();
|
|
return { id, type, success: true, data: { models } };
|
|
}
|
|
|
|
case 'api:openchamber:update-check': {
|
|
try {
|
|
const body = (payload && typeof payload === 'object' ? payload : {}) as Record<string, unknown>;
|
|
const currentVersion = typeof body.currentVersion === 'string' && body.currentVersion.trim().length > 0
|
|
? body.currentVersion.trim()
|
|
: String(ctx?.context?.extension?.packageJSON?.version || 'unknown');
|
|
const instanceMode = typeof body.instanceMode === 'string' && body.instanceMode.trim().length > 0
|
|
? body.instanceMode.trim()
|
|
: 'local';
|
|
const deviceClass = typeof body.deviceClass === 'string' && body.deviceClass.trim().length > 0
|
|
? body.deviceClass.trim()
|
|
: 'desktop';
|
|
const platformRaw = typeof body.platform === 'string' && body.platform.trim().length > 0
|
|
? body.platform.trim()
|
|
: os.platform();
|
|
const archRaw = typeof body.arch === 'string' && body.arch.trim().length > 0
|
|
? body.arch.trim()
|
|
: os.arch();
|
|
const reportUsage = body.reportUsage !== false;
|
|
|
|
const installId = getOrCreateInstallId('vscode');
|
|
const requestBody = {
|
|
appType: 'vscode',
|
|
deviceClass,
|
|
platform: mapNodePlatformToApiPlatform(platformRaw),
|
|
arch: mapNodeArchToApiArch(archRaw),
|
|
channel: 'stable',
|
|
currentVersion,
|
|
installId,
|
|
instanceMode,
|
|
reportUsage,
|
|
};
|
|
|
|
const response = await fetch(deps.updateCheckUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => 'update check failed');
|
|
return { id, type, success: false, error: text || `Update check failed with ${response.status}` };
|
|
}
|
|
|
|
const data = await response.json();
|
|
return { id, type, success: true, data };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { id, type, success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
case 'editor:openFile': {
|
|
const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number };
|
|
try {
|
|
const doc = await vscode.workspace.openTextDocument(filePath);
|
|
const options: vscode.TextDocumentShowOptions = {};
|
|
if (typeof line === 'number') {
|
|
const pos = new vscode.Position(Math.max(0, line - 1), column || 0);
|
|
options.selection = new vscode.Range(pos, pos);
|
|
}
|
|
await vscode.window.showTextDocument(doc, options);
|
|
return { id, type, success: true };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { id, type, success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
case 'editor:openDiff': {
|
|
const { original, modified, label, line, patch } = payload as {
|
|
original: string;
|
|
modified: string;
|
|
label?: string;
|
|
line?: number;
|
|
patch?: string;
|
|
};
|
|
try {
|
|
const modifiedUri = vscode.Uri.file(modified);
|
|
const modifiedDoc = await vscode.workspace.openTextDocument(modifiedUri);
|
|
let originalUri = original ? vscode.Uri.file(original) : modifiedUri;
|
|
|
|
if (typeof patch === 'string' && patch.trim().length > 0) {
|
|
const originalContent = reconstructOriginalContentFromPatch(modifiedDoc.getText(), patch);
|
|
if (typeof originalContent === 'string') {
|
|
ensureVirtualDiffProviderRegistered(ctx);
|
|
originalUri = createVirtualOriginalDiffUri(modified, originalContent);
|
|
}
|
|
}
|
|
|
|
const leftLabel = original ? path.basename(original) : `${path.basename(modified)} (before)`;
|
|
const title = label || `${leftLabel} ↔ ${path.basename(modified)}`;
|
|
|
|
await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title);
|
|
|
|
if (typeof line === 'number' && Number.isFinite(line)) {
|
|
const targetLine = Math.max(0, Math.trunc(line) - 1);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const targetEditor = vscode.window.visibleTextEditors.find(
|
|
(editor) => editor.document.uri.toString() === modifiedUri.toString(),
|
|
);
|
|
if (targetEditor) {
|
|
const target = new vscode.Position(targetLine, 0);
|
|
targetEditor.selection = new vscode.Selection(target, target);
|
|
targetEditor.revealRange(new vscode.Range(target, target), vscode.TextEditorRevealType.InCenter);
|
|
}
|
|
}
|
|
|
|
return { id, type, success: true };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { id, type, success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
case 'api:provider/auth:delete': {
|
|
const { providerId, scope, directory } = (payload || {}) as { providerId?: string; scope?: string; directory?: string };
|
|
if (!providerId) {
|
|
return { id, type, success: false, error: 'Provider ID is required' };
|
|
}
|
|
const normalizedScope = typeof scope === 'string' ? scope : 'auth';
|
|
const workingDirectory = typeof directory === 'string' && directory.trim().length > 0
|
|
? directory.trim()
|
|
: ctx?.manager?.getWorkingDirectory();
|
|
try {
|
|
let removed = false;
|
|
if (normalizedScope === 'auth') {
|
|
removed = removeProviderAuth(providerId);
|
|
} else if (normalizedScope === 'user' || normalizedScope === 'project' || normalizedScope === 'custom') {
|
|
removed = removeProviderConfig(providerId, workingDirectory, normalizedScope);
|
|
} else if (normalizedScope === 'all') {
|
|
const authRemoved = removeProviderAuth(providerId);
|
|
const userRemoved = removeProviderConfig(providerId, workingDirectory, 'user');
|
|
const projectRemoved = workingDirectory
|
|
? removeProviderConfig(providerId, workingDirectory, 'project')
|
|
: false;
|
|
const customRemoved = removeProviderConfig(providerId, workingDirectory, 'custom');
|
|
removed = authRemoved || userRemoved || projectRemoved || customRemoved;
|
|
} else {
|
|
return { id, type, success: false, error: 'Invalid scope' };
|
|
}
|
|
|
|
if (removed) {
|
|
await ctx?.manager?.restart();
|
|
}
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: {
|
|
success: true,
|
|
removed,
|
|
requiresReload: removed,
|
|
message: removed
|
|
? `Provider ${providerId} disconnected successfully. Reloading interface…`
|
|
: `Provider ${providerId} was not configured.`,
|
|
reloadDelayMs: removed ? deps.clientReloadDelayMs : undefined,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { id, type, success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
case 'api:provider/source:get': {
|
|
const { providerId, directory } = (payload || {}) as { providerId?: string; directory?: string };
|
|
if (!providerId) {
|
|
return { id, type, success: false, error: 'Provider ID is required' };
|
|
}
|
|
try {
|
|
const workingDirectory = typeof directory === 'string' && directory.trim().length > 0
|
|
? directory.trim()
|
|
: ctx?.manager?.getWorkingDirectory();
|
|
const sources = getProviderSources(providerId, workingDirectory);
|
|
const auth = getProviderAuth(providerId);
|
|
sources.auth.exists = Boolean(auth);
|
|
return { id, type, success: true, data: { providerId, sources } };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { id, type, success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
case 'api:quota:providers': {
|
|
try {
|
|
const providers = listConfiguredQuotaProviders();
|
|
return { id, type, success: true, data: { providers } };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { id, type, success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
case 'api:quota:credentials': {
|
|
const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown };
|
|
try {
|
|
if (!providerId || !['opencode-go', 'ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' };
|
|
if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) };
|
|
if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; }
|
|
if (method === 'IMPORT') {
|
|
if (providerId !== 'cursor') return { id, type, success: false, error: 'Import unavailable' };
|
|
const credential = importCursorCredential();
|
|
await validateCredential(providerId, credential);
|
|
return { id, type, success: true, data: writeCredential(providerId, credential) };
|
|
}
|
|
if (method === 'PUT') {
|
|
const credential = normalizeCredential(providerId, input);
|
|
if (!credential) return { id, type, success: false, error: 'Invalid credential' };
|
|
if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string });
|
|
else await validateCredential(providerId, credential);
|
|
return { id, type, success: true, data: writeCredential(providerId, credential) };
|
|
}
|
|
if (method === 'VALIDATE') {
|
|
const credential = readCredential(providerId);
|
|
if (!credential) return { id, type, success: false, error: 'Not configured' };
|
|
if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string });
|
|
else await validateCredential(providerId, credential);
|
|
return { id, type, success: true, data: { valid: true } };
|
|
}
|
|
return { id, type, success: false, error: 'Unsupported method' };
|
|
} catch (error) {
|
|
return { id, type, success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
case 'api:quota:get': {
|
|
const { providerId } = (payload || {}) as { providerId?: string };
|
|
if (!providerId) {
|
|
return { id, type, success: false, error: 'Provider ID is required' };
|
|
}
|
|
try {
|
|
const result = await fetchQuotaForProvider(providerId);
|
|
return { id, type, success: true, data: result };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { id, type, success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
case 'vscode:command': {
|
|
const { command, args } = (payload || {}) as { command?: string; args?: unknown[] };
|
|
if (!command) {
|
|
return { id, type, success: false, error: 'Command is required' };
|
|
}
|
|
try {
|
|
const result = await vscode.commands.executeCommand(command, ...(args || []));
|
|
return { id, type, success: true, data: { result } };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { id, type, success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
case 'vscode:openExternalUrl': {
|
|
const { url } = (payload || {}) as { url?: string };
|
|
const target = typeof url === 'string' ? url.trim() : '';
|
|
if (!target) {
|
|
return { id, type, success: false, error: 'URL is required' };
|
|
}
|
|
try {
|
|
await vscode.env.openExternal(vscode.Uri.parse(target));
|
|
return { id, type, success: true, data: { opened: true } };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return { id, type, success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|