diff --git a/.vscodeignore b/.vscodeignore index 0818d0c..61068ee 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,13 +1,16 @@ .vscode/** .vscode-test/** +build/** src/** .gitignore +.mcdev.json **/tsconfig.json **/*.map **/*.ts out/**/*.test.js node_modules/** !node_modules/jsonc-parser/** +!node_modules/fflate/** !node_modules/@vscode/** !bin/** webview/** diff --git a/bin/native/windows/x64/mcdk.exe b/bin/native/windows/x64/mcdk.exe index cf19b8c..b7083cb 100644 Binary files a/bin/native/windows/x64/mcdk.exe and b/bin/native/windows/x64/mcdk.exe differ diff --git a/package-lock.json b/package-lock.json index f2d7e0c..d03ceb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "mcdev-tools", "version": "0.1.30", "dependencies": { + "fflate": "^0.8.3", "jsonc-parser": "^3.2.0" }, "devDependencies": { @@ -44,6 +45,12 @@ "dev": true, "license": "CC-BY-4.0" }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", diff --git a/package.json b/package.json index c46e30b..33b8fed 100644 --- a/package.json +++ b/package.json @@ -342,10 +342,11 @@ "native:build": "cmake --build native/tracy-bridge/build/x64-msvc-release", "native:test": "ctest --test-dir native/tracy-bridge/build/x64-msvc-release --output-on-failure", "native:install": "cmake --install native/tracy-bridge/build/x64-msvc-release", - "test:host-bridge": "npm run compile && node --test out/hostBridge/protocol.test.js out/hostBridge/uiDebugger.test.js out/hostBridge/latestOperationQueue.test.js out/hostBridge/pythonProfiler.test.js out/hostBridge/pythonProfilerReport.test.js out/hostBridge/pythonMemoryProfiler.test.js out/hostBridge/pythonMemoryProfilerReport.test.js out/hostBridge/nativeProfilerPortDiscovery.test.js out/hostBridge/nativeProfilerCapture.test.js out/hostBridge/nativeProfilerReport.test.js out/utils/mcdevDirectory.test.js", + "test:host-bridge": "npm run compile && node --test out/activationGate.test.js out/hostBridge/protocol.test.js out/hostBridge/uiDebugger.test.js out/hostBridge/latestOperationQueue.test.js out/hostBridge/pythonProfiler.test.js out/hostBridge/pythonProfilerReport.test.js out/hostBridge/pythonMemoryProfiler.test.js out/hostBridge/pythonMemoryProfilerReport.test.js out/hostBridge/nativeProfilerPortDiscovery.test.js out/hostBridge/nativeProfilerCapture.test.js out/hostBridge/nativeProfilerReport.test.js out/projectOperations/documentGate.test.js out/projectOperations/previewDocuments.test.js out/projectOperations/previewStore.test.js out/projectOperations/protocol.test.js out/projectOperations/targetResolver.test.js out/projectOperations/webviewProtocol.test.js out/skins/customSkinLibrary.test.js out/skins/vanillaSkins.test.js out/utils/mcdevDirectory.test.js", "lint": "eslint src --ext ts" }, "dependencies": { + "fflate": "^0.8.3", "jsonc-parser": "^3.2.0" }, "devDependencies": { diff --git a/src/activationGate.test.ts b/src/activationGate.test.ts new file mode 100644 index 0000000..75f6bf7 --- /dev/null +++ b/src/activationGate.test.ts @@ -0,0 +1,28 @@ +import * as assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveExtensionFeatureGate } from './activationGate'; + +test('shows project operations for a workspace not recognized by the legacy Add-on heuristic', () => { + assert.deepEqual(resolveExtensionFeatureGate(true, false, false), { + gameFeaturesEnabled: false, + sidebarVisible: true + }); +}); + +test('preserves the existing Add-on and explicit game-feature gates', () => { + assert.deepEqual(resolveExtensionFeatureGate(true, false, true), { + gameFeaturesEnabled: true, + sidebarVisible: true + }); + assert.deepEqual(resolveExtensionFeatureGate(false, true, false), { + gameFeaturesEnabled: true, + sidebarVisible: true + }); +}); + +test('keeps the sidebar reachable without a workspace so it can show the actionable prompt', () => { + assert.deepEqual(resolveExtensionFeatureGate(false, false, false), { + gameFeaturesEnabled: false, + sidebarVisible: true + }); +}); diff --git a/src/activationGate.ts b/src/activationGate.ts new file mode 100644 index 0000000..6ccc03b --- /dev/null +++ b/src/activationGate.ts @@ -0,0 +1,28 @@ +export interface ExtensionFeatureGate { + /** Existing game launch/debug affordances and keybindings. */ + readonly gameFeaturesEnabled: boolean; + /** Configuration/project-operations sidebar registration and visibility. */ + readonly sidebarVisible: boolean; +} + +/** + * Keep the legacy game-feature gate independent from project inspection. + * + * The C++ project inspector supports more layouts than the old lightweight + * Add-on heuristic (for example script packs and gameplay maps), and the + * sidebar also owns the actionable prompt shown before a workspace is opened. + * It therefore remains visible independently of the game-feature gate. + */ +export function resolveExtensionFeatureGate( + _hasWorkspace: boolean, + userEnabled: boolean, + legacyAddonDetected: boolean +): ExtensionFeatureGate { + const gameFeaturesEnabled = userEnabled || legacyAddonDetected; + return { + gameFeaturesEnabled, + // The sidebar itself contains the actionable no-workspace state and must + // remain reachable even before a folder is opened. + sidebarVisible: true + }; +} diff --git a/src/extension.ts b/src/extension.ts index 804ac67..2a37a23 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,12 @@ import { dynamicLibraryManager } from './native/dynamicLibraryManager'; import { GameDebuggerPanel, HostBridgeManager, PreparedHostBridgeLaunch } from './hostBridge'; import { shutdownAllNativeProfilerCaptures } from './hostBridge/nativeProfilerCapture'; import { McdevConfigStore } from './config'; +import { ProjectOperationsService } from './projectOperations'; +import { + MCDK_PREVIEW_SCHEME, + McdevPreviewDocumentProvider +} from './projectOperations/previewDocumentProvider'; +import { resolveExtensionFeatureGate } from './activationGate'; import { McDevToolsDebugConfigurationProvider, McdbgDebugConfigurationProvider, @@ -17,6 +23,8 @@ let extensionContext: vscode.ExtensionContext; let mcdevConfigStore: McdevConfigStore | undefined; let hostBridgeManager: HostBridgeManager | undefined; let gameDebuggerPanel: GameDebuggerPanel | undefined; +let projectOperationsService: ProjectOperationsService | undefined; +let previewDocumentProvider: McdevPreviewDocumentProvider | undefined; export async function activate(context: vscode.ExtensionContext): Promise { console.log('Minecraft ModPC Debug 插件已激活'); @@ -24,6 +32,22 @@ export async function activate(context: vscode.ExtensionContext): Promise mcdevConfigStore = new McdevConfigStore(); context.subscriptions.push(mcdevConfigStore); + projectOperationsService = new ProjectOperationsService({ + executablePath: path.join( + context.extensionPath, + 'bin', + 'native', + 'windows', + 'x64', + 'mcdk.exe' + ) + }); + context.subscriptions.push(projectOperationsService); + previewDocumentProvider = new McdevPreviewDocumentProvider(projectOperationsService); + context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider( + MCDK_PREVIEW_SCHEME, + previewDocumentProvider + )); hostBridgeManager = await HostBridgeManager.create(context, mcdevConfigStore); context.subscriptions.push(hostBridgeManager); gameDebuggerPanel = new GameDebuggerPanel(context.extensionUri, hostBridgeManager); @@ -37,15 +61,33 @@ export async function activate(context: vscode.ExtensionContext): Promise const config = vscode.workspace.getConfiguration('mcdev-tools'); const userEnabled = config.get('enable', false); const isAddon = workspaceFolder ? isMinecraftAddonWorkspace(workspaceFolder) : false; - const pluginEnabled = userEnabled || isAddon; + const featureGate = resolveExtensionFeatureGate( + workspaceFolder !== undefined, + userEnabled, + isAddon + ); - // 设置上下文 - vscode.commands.executeCommand('setContext', 'mcdev-tools:enabled', pluginEnabled); - vscode.commands.executeCommand('setContext', 'mcdev-tools:showSidebar', pluginEnabled); + // Preserve the legacy game/debug feature gate while allowing mcdk's broader + // project inspector to decide whether every open workspace is a valid project. + vscode.commands.executeCommand( + 'setContext', + 'mcdev-tools:enabled', + featureGate.gameFeaturesEnabled + ); + vscode.commands.executeCommand( + 'setContext', + 'mcdev-tools:showSidebar', + featureGate.sidebarVisible + ); - // 只有启用时才注册侧边栏提供器 - if (pluginEnabled) { - const sidebarProvider = new McDevToolsSidebarProvider(context.extensionUri, mcdevConfigStore); + if (featureGate.sidebarVisible) { + const sidebarProvider = new McDevToolsSidebarProvider( + context.extensionUri, + mcdevConfigStore, + projectOperationsService, + previewDocumentProvider, + context.workspaceState + ); const sidebarDisp = vscode.window.registerWebviewViewProvider('mcdev-tools.sidebar', sidebarProvider); context.subscriptions.push(sidebarProvider, sidebarDisp); console.log('McDevToolsSidebarProvider 已注册'); @@ -182,7 +224,19 @@ async function showSidebarPanel(context: vscode.ExtensionContext): Promise if (!mcdevConfigStore) { throw new Error('.mcdev.json configuration store is not initialized'); } - const provider = new McDevToolsSidebarProvider(context.extensionUri, mcdevConfigStore); + if (!projectOperationsService) { + throw new Error('Project operations service is not initialized'); + } + if (!previewDocumentProvider) { + throw new Error('Project preview document provider is not initialized'); + } + const provider = new McDevToolsSidebarProvider( + context.extensionUri, + mcdevConfigStore, + projectOperationsService, + previewDocumentProvider, + context.workspaceState + ); provider.resolveWebviewPanel(panel); context.subscriptions.push(provider); } @@ -267,9 +321,12 @@ export async function deactivate(): Promise { const panel = gameDebuggerPanel; const bridgeManager = hostBridgeManager; const configStore = mcdevConfigStore; + const projectOperations = projectOperationsService; gameDebuggerPanel = undefined; hostBridgeManager = undefined; mcdevConfigStore = undefined; + projectOperationsService = undefined; + previewDocumentProvider = undefined; try { ptvsd.cleanupAllSessions(); @@ -290,6 +347,11 @@ export async function deactivate(): Promise { } catch (error) { console.error('Failed to dispose the .mcdev.json configuration store', error); } + try { + projectOperations?.dispose(); + } catch (error) { + console.error('Failed to dispose the project operations service', error); + } } finally { try { await shutdownAllNativeProfilerCaptures(extensionContext.extensionPath); diff --git a/src/projectOperations/documentGate.test.ts b/src/projectOperations/documentGate.test.ts new file mode 100644 index 0000000..b135ae9 --- /dev/null +++ b/src/projectOperations/documentGate.test.ts @@ -0,0 +1,54 @@ +import * as assert from 'node:assert/strict'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import { + dirtyProjectDocumentPaths, + dirtyWorkspaceDocumentPaths, + isContainedPath +} from './documentGate'; + +test('finds every dirty file document in the first workspace root', () => { + const root = path.resolve('test-data', 'work', 'addon'); + const expected = [ + path.join(root, '.mcdev.json'), + path.join(root, 'BP', 'manifest.json'), + path.join(root, 'scripts', 'main.py') + ].sort((left, right) => left.localeCompare(right)); + assert.deepEqual(dirtyWorkspaceDocumentPaths(root, [ + { scheme: 'file', fsPath: path.join(root, 'BP', 'manifest.json'), isDirty: true }, + { scheme: 'file', fsPath: path.join(root, 'scripts', 'main.py'), isDirty: true }, + { scheme: 'file', fsPath: path.join(root, '.mcdev.json'), isDirty: true }, + { scheme: 'file', fsPath: path.join(root, 'RP', 'manifest.json'), isDirty: false } + ]), expected); +}); + +test('ignores other workspace roots, sibling paths, untitled documents, and clean files', () => { + const root = path.resolve('test-data', 'work', 'addon'); + assert.deepEqual(dirtyWorkspaceDocumentPaths(root, [ + { scheme: 'file', fsPath: path.join(`${root}-other`, 'manifest.json'), isDirty: true }, + { scheme: 'file', fsPath: path.resolve('test-data', 'second-root', 'manifest.json'), isDirty: true }, + { scheme: 'untitled', fsPath: path.join(root, 'new.py'), isDirty: true }, + { scheme: 'file', fsPath: path.join(root, 'manifest.json'), isDirty: false } + ]), []); +}); + +test('path containment does not accept the workspace directory itself or a prefix sibling', () => { + const root = path.resolve('test-data', 'work', 'addon'); + assert.equal(isContainedPath(root, root), false); + assert.equal(isContainedPath(root, path.join(root, 'manifest.json')), true); + assert.equal(isContainedPath(root, path.join(`${root}-old`, 'manifest.json')), false); +}); + +test('also gates dirty documents in a selected external Mod target', () => { + const workspace = path.resolve('test-data', 'work', 'addon'); + const external = path.resolve('test-data', 'external', 'selected-mod'); + const externalFile = path.join(external, 'scripts', 'main.py'); + assert.deepEqual(dirtyProjectDocumentPaths(workspace, external, [ + { scheme: 'file', fsPath: externalFile, isDirty: true }, + { + scheme: 'file', + fsPath: path.resolve('test-data', 'external', 'other-mod', 'main.py'), + isDirty: true + } + ]), [externalFile]); +}); diff --git a/src/projectOperations/documentGate.ts b/src/projectOperations/documentGate.ts new file mode 100644 index 0000000..681c84f --- /dev/null +++ b/src/projectOperations/documentGate.ts @@ -0,0 +1,51 @@ +import * as path from 'path'; + +export interface TextDocumentState { + readonly scheme: string; + readonly fsPath: string; + readonly isDirty: boolean; +} + +/** Returns dirty, on-disk text documents lexically contained by the first workspace root. */ +export function dirtyWorkspaceDocumentPaths( + workspaceRoot: string, + documents: readonly TextDocumentState[] +): string[] { + return dirtyDocumentPathsInRoots([workspaceRoot], documents); +} + +/** Includes a selected target even when included_mod_dirs points outside the workspace. */ +export function dirtyProjectDocumentPaths( + workspaceRoot: string, + targetRoot: string, + documents: readonly TextDocumentState[] +): string[] { + return dirtyDocumentPathsInRoots([workspaceRoot, targetRoot], documents); +} + +export function dirtyDocumentPathsInRoots( + roots: readonly string[], + documents: readonly TextDocumentState[] +): string[] { + const resolvedRoots = [...new Set(roots.map(root => path.resolve(root)))]; + const dirtyPaths = new Set(); + for (const document of documents) { + if (document.scheme !== 'file' || !document.isDirty || !document.fsPath) { + continue; + } + const documentPath = path.resolve(document.fsPath); + if (!resolvedRoots.some(root => isContainedPath(root, documentPath))) { + continue; + } + dirtyPaths.add(documentPath); + } + return [...dirtyPaths].sort((left, right) => left.localeCompare(right)); +} + +export function isContainedPath(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative !== '' + && relative !== '..' + && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative); +} diff --git a/src/projectOperations/index.ts b/src/projectOperations/index.ts new file mode 100644 index 0000000..084d8d5 --- /dev/null +++ b/src/projectOperations/index.ts @@ -0,0 +1,8 @@ +export * from './documentGate'; +export * from './previewStore'; +export * from './previewDocuments'; +export * from './protocol'; +export * from './service'; +export * from './targetResolver'; +export * from './types'; +export * from './webviewProtocol'; diff --git a/src/projectOperations/previewDocumentProvider.ts b/src/projectOperations/previewDocumentProvider.ts new file mode 100644 index 0000000..85b6c05 --- /dev/null +++ b/src/projectOperations/previewDocumentProvider.ts @@ -0,0 +1,36 @@ +import * as vscode from 'vscode'; +import { ProjectPreviewSession } from './previewStore'; +import { ProjectOperationsService } from './service'; +import { + MCDK_PREVIEW_SCHEME, + PreviewDocumentSide, + previewDocumentContent, + previewDocumentUriParts +} from './previewDocuments'; + +export { MCDK_PREVIEW_SCHEME } from './previewDocuments'; + +/** Read-only virtual documents backed only by the bounded in-memory preview store. */ +export class McdevPreviewDocumentProvider implements vscode.TextDocumentContentProvider { + constructor(private readonly service: ProjectOperationsService) {} + + public provideTextDocumentContent(uri: vscode.Uri): string { + const content = previewDocumentContent(uri, this.service); + if (content === undefined) { + throw vscode.FileSystemError.FileNotFound(uri); + } + return content; + } + + public uriFor( + session: ProjectPreviewSession, + fileIndex: number, + side: PreviewDocumentSide + ): vscode.Uri { + try { + return vscode.Uri.from(previewDocumentUriParts(session, fileIndex, side)); + } catch { + throw vscode.FileSystemError.FileNotFound(`Preview file ${fileIndex}`); + } + } +} diff --git a/src/projectOperations/previewDocuments.test.ts b/src/projectOperations/previewDocuments.test.ts new file mode 100644 index 0000000..d6bb9a6 --- /dev/null +++ b/src/projectOperations/previewDocuments.test.ts @@ -0,0 +1,92 @@ +import * as assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + MCDK_PREVIEW_SCHEME, + previewDocumentContent, + previewDocumentUriParts +} from './previewDocuments'; +import { ProjectPreviewSession } from './previewStore'; + +test('builds separate before/after read-only URIs without any disk path', () => { + const session = previewSession(); + const before = previewDocumentUriParts(session, 0, 'before'); + const after = previewDocumentUriParts(session, 0, 'after'); + + assert.equal(before.scheme, MCDK_PREVIEW_SCHEME); + assert.equal(after.scheme, MCDK_PREVIEW_SCHEME); + assert.notEqual(before.path, after.path); + assert.match(before.path, /\/before\.json$/); + assert.match(after.path, /\/after\.json$/); + for (const uri of [before, after]) { + assert.equal(uri.scheme === 'file', false); + assert.equal(uri.path.includes('private'), false); + assert.equal(uri.path.includes('manifest'), false); + assert.equal(uri.path.includes('D:'), false); + } +}); + +test('serves immutable before and after snapshot contents by URI side', () => { + const session = previewSession(); + const lookup = { + getPreviewFileByDocumentToken(token: string, index: number) { + return token === session.documentToken && index === 0 + ? session.preview.files[0] + : undefined; + } + }; + + assert.equal( + previewDocumentContent(previewDocumentUriParts(session, 0, 'before'), lookup), + 'original snapshot' + ); + assert.equal( + previewDocumentContent(previewDocumentUriParts(session, 0, 'after'), lookup), + 'proposed snapshot' + ); +}); + +function previewSession(): ProjectPreviewSession { + const file = { + path: 'D:/private/mod/BP/manifest.json', + before: 'original snapshot', + after: 'proposed snapshot' + }; + const protocolValue = { + id: 'cli-secret-id', + operation: 'bump-version', + root: 'C:/workspace', + target: 'D:/private/mod', + version_part: 'patch', + files: [file], + opaque_approval: 'approval-secret' + }; + return { + documentToken: '0123456789abcdef0123456789abcdef', + viewedIndices: new Set(), + preview: { + id: 'cli-secret-id', + operation: 'bump-version', + root: 'C:/workspace', + target: 'D:/private/mod', + versionPart: 'patch', + files: [file], + opaqueApproval: 'approval-secret', + protocolValue + }, + result: { + operation: 'bump-version', + modifiedFiles: [], + warnings: [], + preview: { + id: 'cli-secret-id', + operation: 'bump-version', + root: 'C:/workspace', + target: 'D:/private/mod', + versionPart: 'patch', + files: [file], + opaqueApproval: 'approval-secret', + protocolValue + } + } + }; +} diff --git a/src/projectOperations/previewDocuments.ts b/src/projectOperations/previewDocuments.ts new file mode 100644 index 0000000..82514bc --- /dev/null +++ b/src/projectOperations/previewDocuments.ts @@ -0,0 +1,62 @@ +import * as path from 'path'; +import { ProjectPreviewSession } from './previewStore'; +import { ProjectPreviewFile } from './types'; + +export const MCDK_PREVIEW_SCHEME = 'mcdk-preview'; +export type PreviewDocumentSide = 'before' | 'after'; + +export interface PreviewDocumentUriLike { + readonly scheme: string; + readonly path: string; +} + +export interface PreviewDocumentLookup { + getPreviewFileByDocumentToken(token: string, fileIndex: number): ProjectPreviewFile | undefined; +} + +export function previewDocumentUriParts( + session: ProjectPreviewSession, + fileIndex: number, + side: PreviewDocumentSide +): PreviewDocumentUriLike { + const file = session.preview.files[fileIndex]; + if (!file) { + throw new Error(`Preview file ${fileIndex} does not exist`); + } + const extension = safeExtension(file.path); + return { + scheme: MCDK_PREVIEW_SCHEME, + // Random token + ordinal + side + extension: never a disk directory or filename. + path: `/${session.documentToken}/${fileIndex}/${side}${extension}` + }; +} + +export function previewDocumentContent( + uri: PreviewDocumentUriLike, + lookup: PreviewDocumentLookup +): string | undefined { + const reference = parsePreviewDocumentUri(uri); + if (!reference) return undefined; + const file = lookup.getPreviewFileByDocumentToken(reference.token, reference.fileIndex); + return reference.side === 'before' ? file?.before : file?.after; +} + +export function parsePreviewDocumentUri( + uri: PreviewDocumentUriLike +): { token: string; fileIndex: number; side: PreviewDocumentSide } | undefined { + if (uri.scheme !== MCDK_PREVIEW_SCHEME) return undefined; + const match = /^\/([0-9a-f]{32})\/(\d+)\/(before|after)(?:\.[a-z0-9]+)?$/i.exec(uri.path); + if (!match) return undefined; + const fileIndex = Number.parseInt(match[2], 10); + if (!Number.isSafeInteger(fileIndex)) return undefined; + return { + token: match[1], + fileIndex, + side: match[3].toLowerCase() as PreviewDocumentSide + }; +} + +function safeExtension(filePath: string): string { + const extension = path.extname(filePath).toLowerCase(); + return /^\.[a-z0-9]{1,12}$/.test(extension) ? extension : '.preview'; +} diff --git a/src/projectOperations/previewStore.test.ts b/src/projectOperations/previewStore.test.ts new file mode 100644 index 0000000..ec6555a --- /dev/null +++ b/src/projectOperations/previewStore.test.ts @@ -0,0 +1,57 @@ +import * as assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { ProjectPreviewStore } from './previewStore'; +import { ProjectOperationResult } from './types'; + +test('keeps at most three in-memory preview sessions and evicts the oldest', () => { + const store = new ProjectPreviewStore(3); + const first = store.add(result('one')); + store.add(result('two')); + store.add(result('three')); + const fourth = store.add(result('four')); + + assert.equal(store.size, 3); + assert.equal(store.get('one'), undefined); + assert.ok(store.get('two')); + assert.ok(store.get('four')); + assert.equal(store.getFileByDocumentToken(first.documentToken, 0), undefined); + assert.equal(store.getFileByDocumentToken(fourth.documentToken, 0)?.after, 'after-four'); +}); + +test('uses opaque random document tokens and tracks viewed files per session', () => { + const store = new ProjectPreviewStore(); + const session = store.add(result('preview-secret-path')); + + assert.match(session.documentToken, /^[0-9a-f]{32}$/); + assert.equal(session.documentToken.includes('secret'), false); + assert.equal(store.markViewed('preview-secret-path', 0), true); + assert.equal(store.markViewed('preview-secret-path', 0), false); + assert.deepEqual([...store.get('preview-secret-path')!.viewedIndices], [0]); +}); + +function result(id: string): ProjectOperationResult { + const protocolValue = { + id, + operation: 'bump-version', + root: 'C:/workspace', + target: 'D:/private/target', + version_part: 'patch', + files: [{ path: `D:/private/target/${id}.json`, before: `before-${id}`, after: `after-${id}` }], + opaque_approval: `approval-${id}` + }; + return { + operation: 'bump-version', + modifiedFiles: [], + warnings: [], + preview: { + id, + operation: 'bump-version', + root: 'C:/workspace', + target: 'D:/private/target', + versionPart: 'patch', + files: [{ path: `D:/private/target/${id}.json`, before: `before-${id}`, after: `after-${id}` }], + opaqueApproval: `approval-${id}`, + protocolValue + } + }; +} diff --git a/src/projectOperations/previewStore.ts b/src/projectOperations/previewStore.ts new file mode 100644 index 0000000..aecefcd --- /dev/null +++ b/src/projectOperations/previewStore.ts @@ -0,0 +1,119 @@ +import { randomBytes } from 'crypto'; +import { + ProjectMutationPreview, + ProjectOperationError, + ProjectOperationResult, + ProjectPreviewFile +} from './types'; + +export const MAX_PREVIEW_SESSIONS = 3; + +interface StoredPreviewSession { + readonly result: ProjectOperationResult; + readonly preview: ProjectMutationPreview; + readonly documentToken: string; + readonly viewed: Set; +} + +export interface ProjectPreviewSession { + readonly result: ProjectOperationResult; + readonly preview: ProjectMutationPreview; + readonly documentToken: string; + readonly viewedIndices: ReadonlySet; +} + +/** In-memory only: approval material and preview contents must never be persisted. */ +export class ProjectPreviewStore { + private readonly sessions = new Map(); + private readonly tokenToId = new Map(); + + constructor(private readonly maximumSessions = MAX_PREVIEW_SESSIONS) { + if (!Number.isInteger(maximumSessions) || maximumSessions < 1) { + throw new Error('maximumSessions must be a positive integer'); + } + } + + public get size(): number { + return this.sessions.size; + } + + public add(result: ProjectOperationResult): ProjectPreviewSession { + const preview = result.preview; + if (!preview) { + throw new ProjectOperationError('invalid_preview', 'mcdk did not return a mutation preview'); + } + + this.remove(preview.id); + while (this.sessions.size >= this.maximumSessions) { + const oldestId = this.sessions.keys().next().value as string | undefined; + if (!oldestId) break; + this.remove(oldestId); + } + + let documentToken: string; + do { + documentToken = randomBytes(16).toString('hex'); + } while (this.tokenToId.has(documentToken)); + const stored: StoredPreviewSession = { + result, + preview, + documentToken, + viewed: new Set() + }; + this.sessions.set(preview.id, stored); + this.tokenToId.set(documentToken, preview.id); + return snapshot(stored); + } + + public get(previewId: string): ProjectPreviewSession | undefined { + const stored = this.sessions.get(previewId); + return stored ? snapshot(stored) : undefined; + } + + public getFileByDocumentToken( + documentToken: string, + fileIndex: number + ): ProjectPreviewFile | undefined { + const previewId = this.tokenToId.get(documentToken); + if (!previewId || !Number.isInteger(fileIndex) || fileIndex < 0) { + return undefined; + } + return this.sessions.get(previewId)?.preview.files[fileIndex]; + } + + public markViewed(previewId: string, fileIndex: number): boolean { + const stored = this.sessions.get(previewId); + if (!stored || !Number.isInteger(fileIndex) || !stored.preview.files[fileIndex]) { + return false; + } + const previousSize = stored.viewed.size; + stored.viewed.add(fileIndex); + return stored.viewed.size !== previousSize; + } + + public remove(previewId: string): boolean { + const stored = this.sessions.get(previewId); + if (!stored) { + return false; + } + this.sessions.delete(previewId); + this.tokenToId.delete(stored.documentToken); + stored.viewed.clear(); + return true; + } + + public clear(): void { + for (const previewId of [...this.sessions.keys()]) { + this.remove(previewId); + } + } +} + +function snapshot(stored: StoredPreviewSession): ProjectPreviewSession { + return { + result: stored.result, + preview: stored.preview, + documentToken: stored.documentToken, + viewedIndices: new Set(stored.viewed) + }; +} diff --git a/src/projectOperations/protocol.test.ts b/src/projectOperations/protocol.test.ts new file mode 100644 index 0000000..79d915d --- /dev/null +++ b/src/projectOperations/protocol.test.ts @@ -0,0 +1,272 @@ +import * as assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { test } from 'node:test'; +import type * as childProcess from 'node:child_process'; +import { + applyPreviewArgs, + bumpVersionPreviewArgs, + inspectArgs, + parseProjectProtocol, + regenerateUuidsPreviewArgs +} from './protocol'; +import { ProjectOperationsService } from './service'; +import { ProjectOperationError } from './types'; + +const project = { + root: 'C:/workspace', + name: 'Demo', + type: 'addon', + behavior_pack_count: 1, + resource_pack_count: 1 +}; + +test('builds target-aware project protocol arguments without shell quoting', () => { + const root = 'C:\\工作区\\Demo'; + const target = 'D:\\外部 Mod\\Pack'; + assert.deepEqual(inspectArgs(root, target), [ + 'project', 'inspect', '--root', root, '--target', target, '--json' + ]); + assert.deepEqual(regenerateUuidsPreviewArgs(root, target), [ + 'project', 'regenerate-uuids', '--root', root, '--target', target, '--preview', '--json' + ]); + assert.deepEqual(bumpVersionPreviewArgs(root, 'minor', target), [ + 'project', 'bump-version', '--root', root, '--target', target, + '--part', 'minor', '--preview', '--json' + ]); + assert.deepEqual(applyPreviewArgs(root), [ + 'project', 'apply-preview', '--root', root, '--json' + ]); +}); + +test('parses protocol v1 and normalizes the CLI project summary', () => { + const result = parseProjectProtocol(JSON.stringify(successEnvelope('inspect', { + project: { + root: 'D:\\Projects\\示例', + type: 'single_pack', + behavior_pack_count: 1, + resource_pack_count: 0, + manifests: [{ version: [1, 2, 3] }], + warnings: ['outside path ignored'] + } + })), 'inspect', 0, '', 'forbidden'); + + assert.deepEqual(result.project, { + name: '示例', + kind: 'pack', + behaviorPackCount: 1, + resourcePackCount: 0, + version: '1.2.3', + issues: undefined, + warnings: ['outside path ignored'] + }); +}); + +test('validates and retains the exact preview object for apply-preview stdin', () => { + const rawPreview = previewValue('preview-1', 'bump-version', 'minor'); + const result = parseProjectProtocol(JSON.stringify(successEnvelope('bump-version', { + preview: rawPreview + })), 'bump-version', 0, '', 'required'); + + assert.equal(result.preview?.id, 'preview-1'); + assert.equal(result.preview?.versionPart, 'minor'); + assert.deepEqual(result.preview?.files, [{ + path: 'C:/workspace/BP/manifest.json', + before: '{"version":[1,0,0]}', + after: '{"version":[1,1,0]}' + }]); + assert.deepEqual(result.preview?.protocolValue, rawPreview); + + assert.throws( + () => parseProjectProtocol(JSON.stringify(successEnvelope('bump-version')), + 'bump-version', 0, '', 'required'), + isProtocolMismatch + ); + assert.throws( + () => parseProjectProtocol(JSON.stringify(successEnvelope('inspect', { + preview: rawPreview + })), 'inspect', 0, '', 'forbidden'), + isProtocolMismatch + ); +}); + +test('preserves stable protocol errors and rejects malformed envelopes', () => { + assert.throws( + () => parseProjectProtocol(JSON.stringify({ + protocol_version: 1, + ok: false, + operation: 'apply-preview', + error: { code: 'preview_stale', message: 'source changed', path: 'D:/workspace/manifest.json' } + }), 'apply-preview', 1), + (error: unknown) => error instanceof ProjectOperationError + && error.code === 'preview_stale' + && error.path === 'D:/workspace/manifest.json' + ); + assert.throws( + () => parseProjectProtocol(JSON.stringify({ + protocol_version: 2, + ok: true, + operation: 'inspect' + }), 'inspect', 0), + isProtocolMismatch + ); + + const valid = successEnvelope('inspect'); + for (const malformed of [ + { ...valid, project: undefined }, + { ...valid, modified_files: [42] }, + { ...valid, warnings: 'none' }, + { ...valid, archive_path: undefined }, + { ...valid, archive_path: 'D:/unexpected.zip' } + ]) { + assert.throws( + () => parseProjectProtocol(JSON.stringify(malformed), 'inspect', 0), + isProtocolMismatch + ); + } +}); + +test('service is globally single-flight and caches by workspace plus target', async () => { + let release: (() => void) | undefined; + const spawnCalls: Array<{ args: readonly string[]; shell: unknown }> = []; + const fakeSpawn = (( + _command: string, + args: readonly string[], + options: childProcess.SpawnOptions + ) => { + const child = fakeChild(false); + spawnCalls.push({ args, shell: options.shell }); + release = () => finishChild(child, successEnvelope('inspect')); + return child; + }) as typeof childProcess.spawn; + const service = new ProjectOperationsService({ + executablePath: 'C:\\extension\\mcdk.exe', + exists: () => true, + spawn: fakeSpawn + }); + const states: boolean[] = []; + service.onDidChangeState(state => states.push(state.busy)); + + const first = service.inspect('C:\\workspace', 'D:\\external-a'); + await assert.rejects( + service.inspect('C:\\workspace', 'D:\\external-b'), + (error: unknown) => error instanceof ProjectOperationError && error.code === 'backend_busy' + ); + release!(); + const result = await first; + + assert.equal(result.project?.kind, 'addon'); + assert.equal(service.getLastResult('C:\\workspace', 'D:\\external-a')?.project?.kind, 'addon'); + assert.equal(service.getLastResult('C:\\workspace', 'D:\\external-b'), undefined); + assert.deepEqual(states, [true, false]); + assert.equal(spawnCalls[0].shell, false); + assert.deepEqual(spawnCalls[0].args, inspectArgs('C:\\workspace', 'D:\\external-a')); +}); + +test('service writes only the retained preview object to apply-preview stdin', async () => { + const inputs: string[] = []; + let call = 0; + const fakeSpawn = (( + _command: string, + _args: readonly string[], + options: childProcess.SpawnOptions + ) => { + const withInput = options.stdio instanceof Array && options.stdio[0] === 'pipe'; + const child = fakeChild(withInput); + if (child.stdin) { + child.stdin.on('data', chunk => inputs.push(Buffer.from(chunk).toString('utf8'))); + } + const current = call++; + queueMicrotask(() => finishChild( + child, + current === 0 + ? successEnvelope('bump-version', { + preview: previewValue('preview-stdin', 'bump-version', 'patch') + }) + : successEnvelope('apply-preview', { + modified_files: ['C:/workspace/BP/manifest.json'] + }) + )); + return child; + }) as typeof childProcess.spawn; + const service = new ProjectOperationsService({ + executablePath: 'C:\\extension\\mcdk.exe', + exists: () => true, + spawn: fakeSpawn + }); + + const session = await service.previewBumpVersion('C:\\workspace', 'patch', 'C:\\workspace\\BP'); + await service.applyPreview('C:\\workspace', session.preview.id); + const parsedInput = JSON.parse(inputs.join('').trim()); + + assert.deepEqual(parsedInput, previewValue('preview-stdin', 'bump-version', 'patch')); + assert.equal(service.getPreviewSession(session.preview.id), undefined); +}); + +test('service shares unsaved Webview configuration state across providers', () => { + const service = new ProjectOperationsService({ + executablePath: 'C:\\extension\\mcdk.exe', + exists: () => true + }); + const firstProvider = {}; + const secondProvider = {}; + service.setConfigurationDirty(firstProvider, true); + service.setConfigurationDirty(secondProvider, true); + service.setConfigurationDirty(firstProvider, false); + assert.equal(service.configurationDirty, true); + service.setConfigurationDirty(secondProvider, false); + assert.equal(service.configurationDirty, false); +}); + +function successEnvelope(operation: string, overrides: Record = {}) { + return { + protocol_version: 1, + ok: true, + operation, + project, + modified_files: [], + archive_path: null, + warnings: [], + preview: null, + ...overrides + }; +} + +function previewValue( + id: string, + operation: 'bump-version' | 'regenerate-uuids', + versionPart?: 'patch' | 'minor' | 'major' +) { + return { + id, + operation, + root: 'C:/workspace', + target: 'C:/workspace/BP', + version_part: versionPart ?? null, + files: [{ + path: 'C:/workspace/BP/manifest.json', + before: '{"version":[1,0,0]}', + after: '{"version":[1,1,0]}' + }], + opaque_approval: `approval-${id}` + }; +} + +function fakeChild(withInput: boolean): childProcess.ChildProcess { + const child = new EventEmitter() as childProcess.ChildProcess; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = withInput ? new PassThrough() : null; + child.kill = (() => true) as childProcess.ChildProcess['kill']; + return child; +} + +function finishChild(child: childProcess.ChildProcess, envelope: unknown): void { + (child.stdout as PassThrough).end(JSON.stringify(envelope)); + (child.stderr as PassThrough).end(); + child.emit('close', 0); +} + +function isProtocolMismatch(error: unknown): boolean { + return error instanceof ProjectOperationError && error.code === 'protocol_mismatch'; +} diff --git a/src/projectOperations/protocol.ts b/src/projectOperations/protocol.ts new file mode 100644 index 0000000..c87f3e3 --- /dev/null +++ b/src/projectOperations/protocol.ts @@ -0,0 +1,362 @@ +import * as path from 'path'; +import { + MutationOperation, + PROJECT_PROTOCOL_VERSION, + ProjectDisplaySummary, + ProjectMutationPreview, + ProjectOperation, + ProjectOperationError, + ProjectOperationResult, + VersionPart +} from './types'; + +export type PreviewExpectation = 'optional' | 'required' | 'forbidden'; + +export function inspectArgs(root: string, target?: string): string[] { + return withTarget(['project', 'inspect', '--root', root], target, ['--json']); +} + +export function regenerateUuidsPreviewArgs(root: string, target?: string): string[] { + return withTarget( + ['project', 'regenerate-uuids', '--root', root], + target, + ['--preview', '--json'] + ); +} + +export function bumpVersionPreviewArgs( + root: string, + part: VersionPart, + target?: string +): string[] { + return withTarget( + ['project', 'bump-version', '--root', root], + target, + ['--part', part, '--preview', '--json'] + ); +} + +export function applyPreviewArgs(root: string): string[] { + return ['project', 'apply-preview', '--root', root, '--json']; +} + +export function parseProjectProtocol( + output: string, + requestedOperation: ProjectOperation, + exitCode: number, + stderr = '', + previewExpectation: PreviewExpectation = 'optional' +): ProjectOperationResult { + const text = output.replace(/^\uFEFF/, '').trim(); + if (!text) { + throw new ProjectOperationError( + 'protocol_mismatch', + stderr.trim() || 'mcdk did not return a JSON response', + { exitCode } + ); + } + + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + throw new ProjectOperationError( + 'protocol_mismatch', + 'mcdk returned invalid JSON', + { exitCode, cause: error } + ); + } + + if (!isRecord(value) || value.protocol_version !== PROJECT_PROTOCOL_VERSION) { + throw new ProjectOperationError( + 'protocol_mismatch', + `Unsupported mcdk project protocol (expected ${PROJECT_PROTOCOL_VERSION})`, + { exitCode } + ); + } + + if (value.ok !== true) { + const protocolError = isRecord(value.error) ? value.error : undefined; + const code = stringValue(protocolError?.code) || 'project_operation_failed'; + const message = stringValue(protocolError?.message) + || stderr.trim() + || `mcdk project operation failed with exit code ${exitCode}`; + throw new ProjectOperationError(code, message, { + path: stringValue(protocolError?.path), + exitCode + }); + } + + if (exitCode !== 0) { + throw new ProjectOperationError( + 'protocol_mismatch', + `mcdk returned success with exit code ${exitCode}`, + { exitCode } + ); + } + + const operation = normalizeOperation(value.operation); + if (operation !== requestedOperation) { + throw new ProjectOperationError( + 'protocol_mismatch', + `mcdk returned operation ${String(value.operation)}, expected ${requestedOperation}`, + { exitCode } + ); + } + + if (!isRecord(value.project)) { + throw new ProjectOperationError( + 'protocol_mismatch', + 'mcdk success response is missing a project object', + { exitCode } + ); + } + const modifiedFiles = requiredStringArray(value.modified_files); + if (!modifiedFiles) { + throw new ProjectOperationError( + 'protocol_mismatch', + 'mcdk success response has an invalid modified_files array', + { exitCode } + ); + } + const warnings = requiredStringArray(value.warnings); + if (!warnings) { + throw new ProjectOperationError( + 'protocol_mismatch', + 'mcdk success response has an invalid warnings array', + { exitCode } + ); + } + if ( + !Object.prototype.hasOwnProperty.call(value, 'archive_path') + || value.archive_path !== null + ) { + throw new ProjectOperationError( + 'protocol_mismatch', + 'mcdk project protocol v1 success response must contain archive_path: null', + { exitCode } + ); + } + + const previewValue = value.preview; + let preview: ProjectMutationPreview | undefined; + if (previewValue !== undefined && previewValue !== null) { + if (previewExpectation === 'forbidden') { + throw protocolError('mcdk returned an unexpected mutation preview', exitCode); + } + preview = parseMutationPreview(previewValue, requestedOperation, exitCode); + } else if (previewExpectation === 'required') { + throw protocolError('mcdk success response is missing a mutation preview', exitCode); + } + + const rawProject = value.project; + const project = normalizeProjectSummary(rawProject, warnings); + return { + operation, + project, + modifiedFiles, + warnings, + preview, + rawProject + }; +} + +export function normalizeProjectSummary( + raw: Readonly>, + operationWarnings: readonly string[] = [] +): ProjectDisplaySummary { + const kind = normalizeKind(raw.kind ?? raw.project_type ?? raw.projectType ?? raw.type); + const behaviorPacks = arrayValue(raw.behavior_packs ?? raw.behaviorPacks); + const resourcePacks = arrayValue(raw.resource_packs ?? raw.resourcePacks); + const issues = uniqueStrings([ + ...stringArray(raw.issues), + ...stringArray(raw.problems), + ...stringArray(raw.scan_issues ?? raw.scanIssues) + ]); + const warnings = uniqueStrings([ + ...stringArray(raw.warnings), + ...operationWarnings + ]); + const manifestVersions = uniqueStrings( + arrayValue(raw.manifests) + .filter(isRecord) + .map(manifest => normalizeVersion(manifest.version)) + .filter((version): version is string => version !== undefined) + ); + + return { + name: stringValue(raw.name ?? raw.project_name ?? raw.projectName) + || inferName(stringValue(raw.root ?? raw.project_root ?? raw.projectRoot)), + kind, + behaviorPackCount: numberValue( + raw.behavior_pack_count ?? raw.behaviorPackCount, + behaviorPacks.length + ), + resourcePackCount: numberValue( + raw.resource_pack_count ?? raw.resourcePackCount, + resourcePacks.length + ), + version: normalizeVersion(raw.version ?? raw.project_version ?? raw.projectVersion) + ?? (manifestVersions.length === 1 ? manifestVersions[0] : undefined), + issues: issues.length > 0 ? issues : undefined, + warnings: warnings.length > 0 ? warnings : undefined + }; +} + +function withTarget(prefix: string[], target: string | undefined, suffix: string[]): string[] { + if (target) { + prefix.push('--target', target); + } + prefix.push(...suffix); + return prefix; +} + +function parseMutationPreview( + value: unknown, + requestedOperation: ProjectOperation, + exitCode: number +): ProjectMutationPreview { + if (!isRecord(value)) { + throw protocolError('mcdk returned an invalid mutation preview', exitCode); + } + const id = stringValue(value.id); + const operation = normalizeMutationOperation(value.operation); + const root = stringValue(value.root); + const target = value.target === null || value.target === undefined + ? undefined + : stringValue(value.target); + const opaqueApproval = stringValue(value.opaque_approval); + if ( + !id + || !operation + || operation !== requestedOperation + || !root + || (value.target !== null && value.target !== undefined && !target) + || !opaqueApproval + || !Array.isArray(value.files) + ) { + throw protocolError('mcdk returned an invalid mutation preview', exitCode); + } + + const files = value.files.map(file => { + if (!isRecord(file)) { + throw protocolError('mcdk returned an invalid preview file', exitCode); + } + const filePath = stringValue(file.path); + if (!filePath || typeof file.before !== 'string' || typeof file.after !== 'string') { + throw protocolError('mcdk returned an invalid preview file', exitCode); + } + return { path: filePath, before: file.before, after: file.after }; + }); + + const versionPart = value.version_part === null || value.version_part === undefined + ? undefined + : normalizeVersionPart(value.version_part); + if ( + (operation === 'bump-version' && !versionPart) + || (value.version_part !== null && value.version_part !== undefined && !versionPart) + ) { + throw protocolError('mcdk returned an invalid preview version_part', exitCode); + } + + return { + id, + operation, + root, + target, + versionPart, + files, + opaqueApproval, + protocolValue: value + }; +} + +function protocolError(message: string, exitCode: number): ProjectOperationError { + return new ProjectOperationError('protocol_mismatch', message, { exitCode }); +} + +function normalizeOperation(value: unknown): ProjectOperation | undefined { + switch (value) { + case 'inspect': + case 'regenerate-uuids': + case 'bump-version': + case 'apply-preview': + return value; + default: + return undefined; + } +} + +function normalizeMutationOperation(value: unknown): MutationOperation | undefined { + return value === 'regenerate-uuids' || value === 'bump-version' ? value : undefined; +} + +function normalizeVersionPart(value: unknown): VersionPart | undefined { + return value === 'patch' || value === 'minor' || value === 'major' ? value : undefined; +} + +function normalizeKind(value: unknown): ProjectDisplaySummary['kind'] { + const normalized = stringValue(value)?.toLowerCase().replace(/[ _-]/g, ''); + if (!normalized) return 'unknown'; + if (normalized.includes('map') || normalized.includes('world')) return 'map'; + if (normalized === 'pack' || normalized === 'singlepack') return 'pack'; + if (normalized.includes('addon') || normalized.includes('multi')) return 'addon'; + return 'unknown'; +} + +function normalizeVersion(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + if (Array.isArray(value) && value.length === 3 && value.every(Number.isInteger)) { + return value.join('.'); + } + if (isRecord(value)) { + const major = value.major; + const minor = value.minor; + const patch = value.patch; + if ([major, minor, patch].every(Number.isInteger)) { + return `${major}.${minor}.${patch}`; + } + } + return undefined; +} + +function inferName(root: string | undefined): string | undefined { + return root ? path.basename(path.normalize(root)) : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function requiredStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) { + return undefined; + } + return value; +} + +function arrayValue(value: unknown): readonly unknown[] { + return Array.isArray(value) ? value : []; +} + +function numberValue(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 + ? value + : fallback; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/src/projectOperations/service.ts b/src/projectOperations/service.ts new file mode 100644 index 0000000..baed479 --- /dev/null +++ b/src/projectOperations/service.ts @@ -0,0 +1,346 @@ +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + applyPreviewArgs, + bumpVersionPreviewArgs, + inspectArgs, + parseProjectProtocol, + PreviewExpectation, + regenerateUuidsPreviewArgs +} from './protocol'; +import { ProjectPreviewSession, ProjectPreviewStore } from './previewStore'; +import { + DisposableLike, + ProjectBusyState, + ProjectOperation, + ProjectOperationError, + ProjectOperationResult, + ProjectPreviewFile, + VersionPart +} from './types'; + +export const MAX_PROJECT_PROTOCOL_BYTES = 16 * 1024 * 1024; +type SpawnFunction = typeof cp.spawn; +type StateListener = (state: ProjectBusyState) => void; + +export interface ProjectOperationsServiceOptions { + readonly executablePath: string; + readonly spawn?: SpawnFunction; + readonly exists?: (filePath: string) => boolean; +} + +/** Extension-owned, globally single-flight gateway to mcdk's project protocol v1. */ +export class ProjectOperationsService implements DisposableLike { + private readonly executablePath: string; + private readonly spawnProcess: SpawnFunction; + private readonly fileExists: (filePath: string) => boolean; + private readonly listeners = new Set(); + private readonly lastResults = new Map(); + private readonly dirtyConfigurationSources = new Set(); + private readonly previews = new ProjectPreviewStore(); + private activeOperation?: ProjectOperation; + private disposed = false; + + constructor(options: ProjectOperationsServiceOptions) { + this.executablePath = path.resolve(options.executablePath); + this.spawnProcess = options.spawn ?? cp.spawn; + this.fileExists = options.exists ?? fs.existsSync; + } + + public get busy(): boolean { + return this.activeOperation !== undefined; + } + + public get operation(): ProjectOperation | undefined { + return this.activeOperation; + } + + public get configurationDirty(): boolean { + return this.dirtyConfigurationSources.size > 0; + } + + public get available(): boolean { + return this.fileExists(this.executablePath); + } + + public get hasPreviewSessions(): boolean { + return this.previews.size > 0; + } + + public onDidChangeState(listener: StateListener): DisposableLike { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + } + + public getLastResult(root: string, target?: string): ProjectOperationResult | undefined { + return this.lastResults.get(projectKey(root, target)); + } + + public getPreviewSession(previewId: string): ProjectPreviewSession | undefined { + return this.previews.get(previewId); + } + + public getPreviewFileByDocumentToken( + documentToken: string, + fileIndex: number + ): ProjectPreviewFile | undefined { + return this.previews.getFileByDocumentToken(documentToken, fileIndex); + } + + public markPreviewViewed(previewId: string, fileIndex: number): boolean { + return this.previews.markViewed(previewId, fileIndex); + } + + public cancelPreview(previewId: string): boolean { + return this.previews.remove(previewId); + } + + public setConfigurationDirty(source: object, dirty: boolean): void { + const changed = dirty + ? !this.dirtyConfigurationSources.has(source) + : this.dirtyConfigurationSources.has(source); + if (!changed) { + return; + } + if (dirty) { + this.dirtyConfigurationSources.add(source); + } else { + this.dirtyConfigurationSources.delete(source); + } + this.emitState(); + } + + public inspect(root: string, target?: string): Promise { + return this.execute('inspect', root, target, inspectArgs(root, target), undefined, 'forbidden'); + } + + public async previewRegenerateUuids( + root: string, + target?: string + ): Promise { + const result = await this.execute( + 'regenerate-uuids', + root, + target, + regenerateUuidsPreviewArgs(root, target), + undefined, + 'required' + ); + return this.previews.add(result); + } + + public async previewBumpVersion( + root: string, + part: VersionPart, + target?: string + ): Promise { + const result = await this.execute( + 'bump-version', + root, + target, + bumpVersionPreviewArgs(root, part, target), + undefined, + 'required' + ); + return this.previews.add(result); + } + + public async applyPreview(root: string, previewId: string): Promise { + const session = this.previews.get(previewId); + if (!session || workspaceKey(session.preview.root) !== workspaceKey(root)) { + throw new ProjectOperationError('invalid_preview', 'The selected preview is no longer available'); + } + const input = `${JSON.stringify(session.preview.protocolValue)}\n`; + if (Buffer.byteLength(input, 'utf8') > MAX_PROJECT_PROTOCOL_BYTES) { + throw new ProjectOperationError( + 'preview_too_large', + 'The mutation preview exceeded the 16 MiB protocol limit' + ); + } + + const result = await this.execute( + 'apply-preview', + root, + session.preview.target, + applyPreviewArgs(root), + input, + 'forbidden' + ); + this.previews.remove(previewId); + return result; + } + + public dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.listeners.clear(); + this.dirtyConfigurationSources.clear(); + this.lastResults.clear(); + this.previews.clear(); + } + + private async execute( + operation: ProjectOperation, + root: string, + target: string | undefined, + args: readonly string[], + stdin: string | undefined, + previewExpectation: PreviewExpectation + ): Promise { + if (this.disposed) { + throw new ProjectOperationError('backend_unavailable', 'Project operations service is disposed'); + } + if (!this.available) { + throw new ProjectOperationError( + 'backend_unavailable', + `Bundled mcdk executable was not found: ${this.executablePath}`, + { path: this.executablePath } + ); + } + if (this.activeOperation) { + throw new ProjectOperationError( + 'backend_busy', + `Another project operation is already running: ${this.activeOperation}` + ); + } + + const key = projectKey(root, target); + this.activeOperation = operation; + this.emitState(); + try { + const processResult = await this.runProcess(root, args, stdin); + const result = parseProjectProtocol( + processResult.stdout, + operation, + processResult.exitCode, + processResult.stderr, + previewExpectation + ); + this.lastResults.set(key, result); + return result; + } catch (error) { + if (operation === 'inspect') { + this.lastResults.delete(key); + } + throw error; + } finally { + this.activeOperation = undefined; + this.emitState(); + } + } + + private runProcess( + root: string, + args: readonly string[], + stdinInput?: string + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve, reject) => { + let child: cp.ChildProcess; + try { + child = this.spawnProcess(this.executablePath, [...args], { + cwd: root, + shell: false, + windowsHide: true, + stdio: [stdinInput === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] + }); + } catch (error) { + reject(new ProjectOperationError( + 'backend_unavailable', + `Unable to start bundled mcdk: ${error instanceof Error ? error.message : String(error)}`, + { path: this.executablePath, cause: error } + )); + return; + } + + if (!child.stdout || !child.stderr || (stdinInput !== undefined && !child.stdin)) { + child.kill(); + reject(new ProjectOperationError( + 'backend_unavailable', + 'Bundled mcdk did not provide the required protocol pipes', + { path: this.executablePath } + )); + return; + } + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + + const finishReject = (error: ProjectOperationError): void => { + if (settled) return; + settled = true; + reject(error); + }; + + child.stdout.on('data', (chunk: Buffer | string) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stdoutBytes += bytes.length; + if (stdoutBytes > MAX_PROJECT_PROTOCOL_BYTES) { + child.kill(); + finishReject(new ProjectOperationError( + 'protocol_mismatch', + 'mcdk JSON response exceeded the 16 MiB limit' + )); + return; + } + stdout.push(bytes); + }); + child.stderr.on('data', (chunk: Buffer | string) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (stderrBytes < MAX_PROJECT_PROTOCOL_BYTES) { + const remaining = MAX_PROJECT_PROTOCOL_BYTES - stderrBytes; + stderr.push(bytes.subarray(0, remaining)); + stderrBytes += Math.min(bytes.length, remaining); + } + }); + child.once('error', error => { + finishReject(new ProjectOperationError( + 'backend_unavailable', + `Unable to run bundled mcdk: ${error.message}`, + { path: this.executablePath, cause: error } + )); + }); + child.once('close', code => { + if (settled) return; + settled = true; + resolve({ + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + exitCode: code ?? 1 + }); + }); + + if (stdinInput !== undefined && child.stdin) { + // Prevent an early CLI rejection from surfacing as an unhandled EPIPE. + child.stdin.on('error', () => undefined); + child.stdin.end(stdinInput, 'utf8'); + } + }); + } + + private emitState(): void { + const state: ProjectBusyState = { + busy: this.activeOperation !== undefined, + operation: this.activeOperation + }; + for (const listener of this.listeners) { + try { + listener(state); + } catch (error) { + console.error('Project operations state listener failed', error); + } + } + } +} + +function projectKey(workspaceRoot: string, target?: string): string { + return `${workspaceKey(workspaceRoot)}\0${workspaceKey(target ?? workspaceRoot)}`; +} + +function workspaceKey(workspacePath: string): string { + const normalized = path.normalize(path.resolve(workspacePath)); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} diff --git a/src/projectOperations/targetResolver.test.ts b/src/projectOperations/targetResolver.test.ts new file mode 100644 index 0000000..cbd980a --- /dev/null +++ b/src/projectOperations/targetResolver.test.ts @@ -0,0 +1,54 @@ +import * as assert from 'node:assert/strict'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import { resolveManagedModTarget } from './targetResolver'; +import { ProjectOperationError } from './types'; + +test('resolves the selected saved included_mod_dirs index relative to the workspace', () => { + const workspace = path.resolve('test-data', 'workspace'); + const target = resolveManagedModTarget(workspace, [ + 'Mods/First', + { path: 'Mods/Second', hot_reload: false } + ], 1); + + assert.equal(target.index, 1); + assert.equal(target.configuredPath, 'Mods/Second'); + assert.equal(target.targetRoot, path.resolve(workspace, 'Mods/Second')); + assert.equal(target.external, false); + assert.equal(target.label, 'Second'); +}); + +test('allows a saved external target but never accepts an invalid index or entry', () => { + const workspace = path.resolve('test-data', 'workspace'); + const external = path.resolve('test-data', 'external-mod'); + assert.equal(resolveManagedModTarget(workspace, [external], 0).external, true); + + for (const [entries, index] of [ + [undefined, 0], + [[], 0], + [[{ path: null }], 0], + [[{ path: 42 }], 0], + [[{ path: ' ' }], 0], + [[' '], 0], + [['valid'], 1] + ] as const) { + assert.throws( + () => resolveManagedModTarget(workspace, entries, index), + (error: unknown) => error instanceof ProjectOperationError && error.code === 'invalid_target' + ); + } +}); + +test('defaults an object with no path, or an explicit empty path, to the workspace root', () => { + const workspace = path.resolve('test-data', 'workspace'); + for (const entry of [ + { hot_reload: true }, + { path: '', enabled: true } + ]) { + const target = resolveManagedModTarget(workspace, [entry], 0); + assert.equal(target.configuredPath, './'); + assert.equal(target.targetRoot, workspace); + assert.equal(target.external, false); + assert.equal(target.label, path.basename(workspace)); + } +}); diff --git a/src/projectOperations/targetResolver.ts b/src/projectOperations/targetResolver.ts new file mode 100644 index 0000000..0620a36 --- /dev/null +++ b/src/projectOperations/targetResolver.ts @@ -0,0 +1,74 @@ +import * as path from 'path'; +import { ManagedModTarget, ProjectOperationError } from './types'; + +export interface IncludedModDirectoryObject { + readonly path?: unknown; +} + +/** Resolves a saved included_mod_dirs entry; no path supplied by the Webview is trusted. */ +export function resolveManagedModTarget( + workspaceRoot: string, + includedModDirs: readonly unknown[] | undefined, + index: number +): ManagedModTarget { + if (!Number.isInteger(index) || index < 0 || !includedModDirs || index >= includedModDirs.length) { + throw new ProjectOperationError( + 'invalid_target', + 'The selected Mod no longer exists in the saved included_mod_dirs configuration' + ); + } + + const entry = includedModDirs[index]; + const configuredPath = configuredPathFor(entry); + if (!configuredPath) { + throw new ProjectOperationError( + 'invalid_target', + `included_mod_dirs[${index}] does not contain a valid path` + ); + } + + const resolvedWorkspace = path.resolve(workspaceRoot); + const targetRoot = path.resolve(resolvedWorkspace, configuredPath); + return { + index, + configuredPath, + workspaceRoot: resolvedWorkspace, + targetRoot, + label: path.basename(targetRoot) || configuredPath, + external: !isSameOrContainedPath(resolvedWorkspace, targetRoot) + }; +} + +function configuredPathFor(entry: unknown): string { + if (typeof entry === 'string') { + return entry.trim(); + } + if (!isRecord(entry)) { + return ''; + } + if (!Object.prototype.hasOwnProperty.call(entry, 'path')) { + // Matches config.cpp item.value("path", "./") and the Webview default. + return './'; + } + if (typeof entry.path !== 'string') { + return ''; + } + if (entry.path.length === 0) { + // An explicit empty string resolves to the config root in native code; + // use its visible Webview spelling instead of retaining an empty label. + return './'; + } + return entry.path.trim(); +} + +export function isSameOrContainedPath(root: string, candidate: string): boolean { + const relative = path.relative(path.resolve(root), path.resolve(candidate)); + return relative === '' + || (relative !== '..' + && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/projectOperations/types.ts b/src/projectOperations/types.ts new file mode 100644 index 0000000..2fd7e90 --- /dev/null +++ b/src/projectOperations/types.ts @@ -0,0 +1,91 @@ +export const PROJECT_PROTOCOL_VERSION = 1 as const; + +export type MutationOperation = 'regenerate-uuids' | 'bump-version'; + +export type ProjectOperation = + | 'inspect' + | MutationOperation + | 'apply-preview'; + +export type ProjectKind = 'addon' | 'map' | 'pack' | 'unknown'; +export type VersionPart = 'patch' | 'minor' | 'major'; + +export interface ProjectDisplaySummary { + readonly name?: string; + readonly kind: ProjectKind; + readonly behaviorPackCount: number; + readonly resourcePackCount: number; + readonly version?: string; + readonly issues?: readonly string[]; + readonly warnings?: readonly string[]; +} + +/** The protocol object is intentionally retained byte-for-byte for apply-preview. */ +export interface ProjectPreviewFile { + readonly path: string; + readonly before: string; + readonly after: string; +} + +export interface ProjectMutationPreview { + readonly id: string; + readonly operation: MutationOperation; + readonly root: string; + readonly target?: string; + readonly versionPart?: VersionPart; + readonly files: readonly ProjectPreviewFile[]; + readonly opaqueApproval: string; + /** Exact snake_case value received from mcdk and written back to its stdin. */ + readonly protocolValue: Readonly>; +} + +export interface ProjectOperationResult { + readonly operation: ProjectOperation; + readonly project?: ProjectDisplaySummary; + readonly modifiedFiles: readonly string[]; + readonly warnings: readonly string[]; + readonly preview?: ProjectMutationPreview; + readonly rawProject?: Readonly>; +} + +export interface ProjectOperationErrorShape { + readonly code: string; + readonly message: string; + readonly path?: string; +} + +export class ProjectOperationError extends Error { + public readonly code: string; + public readonly path?: string; + public readonly exitCode?: number; + + constructor( + code: string, + message: string, + options: { path?: string; exitCode?: number; cause?: unknown } = {} + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'ProjectOperationError'; + this.code = code; + this.path = options.path; + this.exitCode = options.exitCode; + } +} + +export interface ProjectBusyState { + readonly busy: boolean; + readonly operation?: ProjectOperation; +} + +export interface ManagedModTarget { + readonly index: number; + readonly configuredPath: string; + readonly workspaceRoot: string; + readonly targetRoot: string; + readonly label: string; + readonly external: boolean; +} + +export interface DisposableLike { + dispose(): void; +} diff --git a/src/projectOperations/webviewProtocol.test.ts b/src/projectOperations/webviewProtocol.test.ts new file mode 100644 index 0000000..4a61fa2 --- /dev/null +++ b/src/projectOperations/webviewProtocol.test.ts @@ -0,0 +1,57 @@ +import * as assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + MANAGED_MOD_MESSAGE_TYPES, + parseManagedModWebviewMessage, + planStalePreviewRegeneration, + previewGenerationIsBlocked +} from './webviewProtocol'; + +test('routes every camelCase managed Mod Webview event', () => { + const payloads: Record> = { + manageMod: { index: 2 }, + leaveModManagement: {}, + refreshManagedMod: {}, + generateVersionPreview: { part: 'minor' }, + generateUuidPreview: {}, + openPreviewDiff: { previewId: 'preview', fileIndex: 1 }, + cancelPreview: { previewId: 'preview' }, + approvePreview: { previewId: 'preview' }, + projectConfigDirty: { dirty: true } + }; + + assert.deepEqual(Object.keys(payloads), [...MANAGED_MOD_MESSAGE_TYPES]); + for (const [type, payload] of Object.entries(payloads)) { + assert.equal(parseManagedModWebviewMessage({ type, ...payload })?.type, type); + } + assert.equal(parseManagedModWebviewMessage({ type: 'unrelatedMessage' }), undefined); + for (const removedType of [ + 'selectExportDirectory', + 'exportManagedMod', + 'projectExport', + 'resolveExportConflict' + ]) { + assert.equal(parseManagedModWebviewMessage({ type: removedType }), undefined); + } +}); + +test('an active preview blocks replacement from forged or racing messages', () => { + assert.equal(previewGenerationIsBlocked('preview-id', true), true); + assert.equal(previewGenerationIsBlocked('preview-id', false), true); + assert.equal(previewGenerationIsBlocked(undefined, true), true); + assert.equal(previewGenerationIsBlocked(undefined, false), false); +}); + +test('preview_stale recovery only plans regeneration and requires a new approval', () => { + const plan = planStalePreviewRegeneration({ + operation: 'bump-version', + versionPart: 'major' + }); + assert.deepEqual(plan, { + operation: 'bump-version', + versionPart: 'major', + requiresFreshApproval: true + }); + assert.equal('apply' in plan, false); + assert.equal('approved' in plan, false); +}); diff --git a/src/projectOperations/webviewProtocol.ts b/src/projectOperations/webviewProtocol.ts new file mode 100644 index 0000000..8a03c33 --- /dev/null +++ b/src/projectOperations/webviewProtocol.ts @@ -0,0 +1,84 @@ +import { MutationOperation, VersionPart } from './types'; + +export const MANAGED_MOD_MESSAGE_TYPES = [ + 'manageMod', + 'leaveModManagement', + 'refreshManagedMod', + 'generateVersionPreview', + 'generateUuidPreview', + 'openPreviewDiff', + 'cancelPreview', + 'approvePreview', + 'projectConfigDirty' +] as const; + +export type ManagedModWebviewMessage = + | { readonly type: 'manageMod'; readonly index: unknown } + | { readonly type: 'leaveModManagement' } + | { readonly type: 'refreshManagedMod' } + | { readonly type: 'generateVersionPreview'; readonly part: unknown; readonly dirty: boolean } + | { readonly type: 'generateUuidPreview'; readonly dirty: boolean } + | { readonly type: 'openPreviewDiff'; readonly previewId: unknown; readonly fileIndex: unknown } + | { readonly type: 'cancelPreview'; readonly previewId: unknown } + | { readonly type: 'approvePreview'; readonly previewId: unknown } + | { readonly type: 'projectConfigDirty'; readonly dirty: unknown }; + +/** Keeps snake_case CLI details out of the Webview and centralizes legacy aliases. */ +export function parseManagedModWebviewMessage(value: unknown): ManagedModWebviewMessage | undefined { + if (!isRecord(value) || typeof value.type !== 'string') return undefined; + switch (value.type) { + case 'manageMod': + return { type: 'manageMod', index: value.index }; + case 'leaveModManagement': + return { type: 'leaveModManagement' }; + case 'refreshManagedMod': + case 'projectRefresh': + return { type: 'refreshManagedMod' }; + case 'generateVersionPreview': + case 'projectBumpVersion': + return { type: 'generateVersionPreview', part: value.part, dirty: value.dirty === true }; + case 'generateUuidPreview': + case 'projectRegenerateUuids': + return { type: 'generateUuidPreview', dirty: value.dirty === true }; + case 'openPreviewDiff': + return { type: 'openPreviewDiff', previewId: value.previewId, fileIndex: value.fileIndex }; + case 'cancelPreview': + return { type: 'cancelPreview', previewId: value.previewId }; + case 'approvePreview': + return { type: 'approvePreview', previewId: value.previewId }; + case 'projectConfigDirty': + return { type: 'projectConfigDirty', dirty: value.dirty }; + default: + return undefined; + } +} + +export interface StalePreviewRegenerationPlan { + readonly operation: MutationOperation; + readonly versionPart?: VersionPart; + readonly requiresFreshApproval: true; +} + +/** Host-side defense; the UI's disabled state is not a trust boundary. */ +export function previewGenerationIsBlocked( + activePreviewId: string | undefined, + hasAnyPreviewSession: boolean +): boolean { + return activePreviewId !== undefined || hasAnyPreviewSession; +} + +/** A stale approval can only become a new preview, never an automatic second apply. */ +export function planStalePreviewRegeneration(preview: { + readonly operation: MutationOperation; + readonly versionPart?: VersionPart; +}): StalePreviewRegenerationPlan { + return { + operation: preview.operation, + versionPart: preview.operation === 'bump-version' ? preview.versionPart ?? 'patch' : undefined, + requiresFreshApproval: true + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/sidebar/provider.ts b/src/sidebar/provider.ts index 5afd553..c661b32 100644 --- a/src/sidebar/provider.ts +++ b/src/sidebar/provider.ts @@ -9,6 +9,67 @@ import { getGameExecutablePaths, isGameExecutableDiscoverySupported } from '../native/gameDiscovery'; +import { + customSkinLibraryStorageKey, + loadVanillaSkins, + parseCustomSkinLibrary, + readSkinPreview, + resolveGameExecutablePath +} from '../skins'; +import { + dirtyProjectDocumentPaths, + isContainedPath, + ManagedModTarget, + MutationOperation, + ProjectDisplaySummary, + ProjectOperation, + ProjectOperationError, + ProjectOperationsService, + ProjectPreviewSession, + parseManagedModWebviewMessage, + planStalePreviewRegeneration, + previewGenerationIsBlocked, + resolveManagedModTarget, + VersionPart +} from '../projectOperations'; +import { McdevPreviewDocumentProvider } from '../projectOperations/previewDocumentProvider'; +import { CustomSkin } from '../types'; + +interface ManagedOperationContext { + readonly workspace: vscode.WorkspaceFolder; + readonly target: ManagedModTarget; +} + +interface ManagedModWebviewState { + readonly status: 'idle' | 'loading' | 'ready' | 'busy' | 'unavailable' | 'error'; + readonly hasWorkspace: boolean; + readonly busy: boolean; + readonly operation?: ProjectOperation; + readonly managedIndex?: number; + readonly target?: { + readonly label: string; + readonly configuredPath: string; + readonly resolvedPath: string; + readonly external: boolean; + }; + readonly project?: ProjectDisplaySummary; + readonly preview?: { + readonly id: string; + readonly operation: MutationOperation; + readonly versionPart?: VersionPart; + readonly files: readonly { + readonly index: number; + readonly path: string; + readonly viewed: boolean; + }[]; + readonly viewedCount: number; + readonly unviewedCount: number; + }; + readonly message?: string; + readonly errorCode?: string; + readonly hasUnsavedDocuments?: boolean; + readonly unsavedDocumentPaths?: readonly string[]; +} /** * 侧边栏 Webview 提供者,用于可视化编辑 .mcdev.json @@ -18,10 +79,22 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs private _configSubscription?: vscode.Disposable; private _reviewProcess?: cp.ChildProcess; private _messageSubscription?: vscode.Disposable; + private _projectSubscription?: { dispose(): void }; + private readonly _configurationDirtySource = {}; + private _documentSubscriptions: vscode.Disposable[] = []; + private _managedTarget?: ManagedModTarget; + private _activePreviewId?: string; + private _saveQueue: Promise = Promise.resolve(); + private _customSkinLibraryQueue: Promise = Promise.resolve(); + private readonly _customSkinLibraries = new Map(); + private _configMessageRevision = 0; constructor( private readonly _extensionUri: vscode.Uri, - private readonly _configStore: McdevConfigStore + private readonly _configStore: McdevConfigStore, + private readonly _projectOperations: ProjectOperationsService, + private readonly _previewDocuments: McdevPreviewDocumentProvider, + private readonly _workspaceState: vscode.Memento ) {} public resolveWebviewView( @@ -47,6 +120,8 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs this.setupMessageHandler(webview); this.setupConfigSubscription(webview); + this.setupProjectSubscription(webview); + this.setupDocumentSubscriptions(webview); // Clean up watcher when view is disposed webviewView.onDidDispose(() => { @@ -63,6 +138,8 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs webview.html = this.getHtmlForWebview(webview); this.setupMessageHandler(webview); this.setupConfigSubscription(webview); + this.setupProjectSubscription(webview); + this.setupDocumentSubscriptions(webview); panel.onDidDispose(() => this.dispose()); } @@ -71,6 +148,17 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs this._messageSubscription = undefined; this._configSubscription?.dispose(); this._configSubscription = undefined; + this._projectSubscription?.dispose(); + this._projectSubscription = undefined; + for (const subscription of this._documentSubscriptions) { + subscription.dispose(); + } + this._documentSubscriptions = []; + this._projectOperations.setConfigurationDirty(this._configurationDirtySource, false); + if (this._activePreviewId) { + this._projectOperations.cancelPreview(this._activePreviewId); + this._activePreviewId = undefined; + } if (this._reviewProcess && !this._reviewProcess.killed) { this._reviewProcess.kill(); } @@ -100,16 +188,19 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs private setupMessageHandler(webview: vscode.Webview): void { this._messageSubscription?.dispose(); this._messageSubscription = webview.onDidReceiveMessage(async (msg) => { + if (await this.handleManagedModWebviewMessage(webview, msg)) { + return; + } if (msg?.type === 'ready') { await this.handleReady(webview); } else if (msg?.type === 'save') { - await this.handleSave(webview, msg.content); + await this.handleSave(webview, msg.content, msg.requestId); } else if (msg?.type === 'browseFolder') { await this.handleBrowseFolder(webview, msg.index); } else if (msg?.type === 'browseSkin') { await this.handleBrowseSkin(webview); } else if (msg?.type === 'updateSkinPreview') { - await this.handleUpdateSkinPreview(webview, msg.path); + await this.handleUpdateSkinPreview(webview, msg.path, msg.requestId); } else if (msg?.type === 'runGame') { await vscode.commands.executeCommand('mcdev-tools.runGame'); } else if (msg?.type === 'startDebug') { @@ -118,6 +209,14 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs await this.handleBrowseGameExecutable(webview, msg.currentPath); } else if (msg?.type === 'getGameExecutablePaths') { await this.handleGetGameExecutablePaths(webview); + } else if (msg?.type === 'getVanillaSkins') { + await this.handleGetVanillaSkins( + webview, + msg.requestId, + msg.gameExecutablePath + ); + } else if (msg?.type === 'setCustomSkins') { + await this.handleSetCustomSkins(msg.skins); } else if (msg?.type === 'openExternal') { await this.handleOpenExternal(msg.url); } else if (msg?.type === 'runCodeReview') { @@ -143,51 +242,96 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs * 处理 ready 消息 */ private async handleReady(webview: vscode.Webview): Promise { + const configRevision = ++this._configMessageRevision; const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const language = vscode.env.language; // 获取 VS Code 语言设置 if (!workspaceFolder) { - webview.postMessage({ + await webview.postMessage({ type: 'init', content: '{}', language, + configRevision, + customSkins: [], gameExecutableDiscoverySupported: isGameExecutableDiscoverySupported }); + await this.postManagedState(webview, { + status: 'unavailable', + message: projectText('请先打开工作区。', 'Open a workspace to use project operations.'), + errorCode: 'no_workspace' + }); return; } try { const snapshot = await this._configStore.getSnapshot(workspaceFolder.uri.fsPath); + this._projectOperations.setConfigurationDirty( + this._configurationDirtySource, + !snapshot.exists + ); await this.postConfig(webview, workspaceFolder, snapshot, { language, - needsInitialSave: !snapshot.exists + needsInitialSave: !snapshot.exists, + configRevision }); + await this.postManagedState(webview); } catch (e) { - webview.postMessage({ + if (configRevision !== this._configMessageRevision) { + return; + } + await webview.postMessage({ type: 'init', content: '{}', error: String(e), language, + configRevision, + customSkins: [], gameExecutableDiscoverySupported: isGameExecutableDiscoverySupported }); + await this.postManagedError(webview, toProjectOperationError(e)); } } /** * 处理 save 消息 */ - private async handleSave(webview: vscode.Webview, content: string): Promise { + private handleSave( + webview: vscode.Webview, + content: string, + requestId: unknown + ): Promise { + const operation = this._saveQueue.then(() => ( + this.performSave(webview, content, requestId) + )); + this._saveQueue = operation.catch(error => { + console.error('Unexpected queued .mcdev.json save failure:', error); + }); + return operation; + } + + private async performSave( + webview: vscode.Webview, + content: string, + requestId: unknown + ): Promise { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) { vscode.window.showErrorMessage('请先打开工作区以保存 .mcdev.json'); + await webview.postMessage({ type: 'saveFailed', requestId }); return; } try { await this._configStore.write(workspaceFolder.uri.fsPath, content); - await webview.postMessage({ type: 'saved' }); + await webview.postMessage({ type: 'saved', requestId }); + if (this._managedTarget) { + await this.handleManageMod(webview, this._managedTarget.index); + } else { + await this.postManagedState(webview); + } vscode.window.showInformationMessage('.mcdev.json 已保存'); } catch (e) { vscode.window.showErrorMessage(`保存 .mcdev.json 失败: ${e}`); + await webview.postMessage({ type: 'saveFailed', requestId }); } } @@ -229,12 +373,13 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs if (result && result.length > 0) { const fileUri = result[0]; - const webviewUri = webview.asWebviewUri(fileUri); + const preview = await readSkinPreview(fileUri.fsPath); - webview.postMessage({ + await webview.postMessage({ type: 'skinSelected', path: fileUri.fsPath, - previewUri: webviewUri.toString() + previewUri: preview.previewUri, + errorCode: preview.errorCode }); } } @@ -242,29 +387,34 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs /** * 根据给定路径更新皮肤预览(不修改配置文件) */ - private async handleUpdateSkinPreview(webview: vscode.Webview, skinPath: string | undefined): Promise { + private async handleUpdateSkinPreview( + webview: vscode.Webview, + skinPath: unknown, + requestId: unknown + ): Promise { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - return; - } - if (!skinPath || !skinPath.trim()) { - webview.postMessage({ type: 'skinPreview', previewUri: undefined }); + if (typeof skinPath !== 'string' || !skinPath.trim()) { + await webview.postMessage({ type: 'skinPreview', requestId, previewUri: undefined }); return; } - try { - let filePath = skinPath; - if (!path.isAbsolute(filePath)) { - filePath = path.join(workspaceFolder.uri.fsPath, filePath); + let filePath = skinPath.trim(); + if (!path.isAbsolute(filePath)) { + if (!workspaceFolder) { + await webview.postMessage({ + type: 'skinPreview', + requestId, + previewUri: undefined, + errorCode: 'skin_not_found' + }); + return; } - const fileUri = vscode.Uri.file(filePath); - const webviewUri = webview.asWebviewUri(fileUri); - webview.postMessage({ type: 'skinPreview', previewUri: webviewUri.toString() }); - } catch (e) { - console.error('Failed to build skin preview URI:', e); - webview.postMessage({ type: 'skinPreview', previewUri: undefined }); + filePath = path.resolve(workspaceFolder.uri.fsPath, filePath); } + + const preview = await readSkinPreview(filePath); + await webview.postMessage({ type: 'skinPreview', requestId, ...preview }); } /** @@ -318,6 +468,669 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs } } + private async handleGetVanillaSkins( + webview: vscode.Webview, + requestId: unknown, + requestedGameExecutablePath: unknown + ): Promise { + let resolvedGameExecutablePath = await resolveGameExecutablePath( + requestedGameExecutablePath + ); + + if (!resolvedGameExecutablePath && isGameExecutableDiscoverySupported) { + try { + const discoveredPaths = await getGameExecutablePaths(this._extensionUri.fsPath); + resolvedGameExecutablePath = await resolveGameExecutablePath( + undefined, + discoveredPaths + ); + } catch (error) { + console.error('Failed to discover a game executable for vanilla skins:', error); + } + } + + if (!resolvedGameExecutablePath) { + await webview.postMessage({ + type: 'vanillaSkins', + requestId, + skins: [], + errorCode: 'game_not_found' + }); + return; + } + + const result = await loadVanillaSkins(resolvedGameExecutablePath); + await webview.postMessage({ type: 'vanillaSkins', requestId, ...result }); + } + + private handleSetCustomSkins(value: unknown): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + return Promise.resolve(); + } + const workspacePath = workspaceFolder.uri.fsPath; + const storageKey = customSkinLibraryStorageKey(workspacePath); + const skins = parseCustomSkinLibrary(value); + this._customSkinLibraries.set(storageKey, skins); + + const operation = this._customSkinLibraryQueue.then(async () => { + await this._workspaceState.update(storageKey, skins); + }); + this._customSkinLibraryQueue = operation.catch(error => { + console.error('Failed to persist the custom skin library:', error); + }); + return operation; + } + + private getCustomSkins(workspacePath: string): CustomSkin[] { + const storageKey = customSkinLibraryStorageKey(workspacePath); + const cached = this._customSkinLibraries.get(storageKey); + if (cached) { + return cached; + } + const skins = parseCustomSkinLibrary(this._workspaceState.get(storageKey, [])); + this._customSkinLibraries.set(storageKey, skins); + return skins; + } + + private async handleProjectConfigDirty( + webview: vscode.Webview, + dirty: unknown + ): Promise { + if (typeof dirty !== 'boolean') return; + this._projectOperations.setConfigurationDirty(this._configurationDirtySource, dirty); + await this.postManagedState(webview, dirty ? { + status: 'error', + message: projectText( + '请先保存 .mcdev.json,再管理或修改 Mod。', + 'Save .mcdev.json before managing or modifying a Mod.' + ), + errorCode: 'configuration_dirty' + } : {}); + } + + private async handleManageMod(webview: vscode.Webview, indexValue: unknown): Promise { + const workspace = vscode.workspace.workspaceFolders?.[0]; + if (!workspace) { + await this.postNoWorkspace(webview); + return; + } + if (!Number.isInteger(indexValue) || (indexValue as number) < 0) { + await this.postManagedError(webview, new ProjectOperationError( + 'invalid_target', + projectText('Mod 索引无效。', 'Invalid Mod index.') + )); + return; + } + if (this._projectOperations.configurationDirty) { + await this.postManagedError(webview, new ProjectOperationError( + 'configuration_dirty', + projectText( + '请先保存 .mcdev.json;管理目标只按已保存的 included_mod_dirs 解析。', + 'Save .mcdev.json first; management targets are resolved only from saved included_mod_dirs.' + ) + )); + return; + } + + try { + const snapshot = await this._configStore.getSnapshot(workspace.uri.fsPath); + const target = resolveManagedModTarget( + workspace.uri.fsPath, + snapshot.config.included_mod_dirs, + indexValue as number + ); + assertDirectoryTarget(target.targetRoot); + this.cancelActivePreview(); + this._managedTarget = target; + await this.postManagedState(webview, { status: 'loading' }); + const context = await this.requireManagedContext(webview, true); + if (!context) return; + const result = await this._projectOperations.inspect( + context.workspace.uri.fsPath, + context.target.targetRoot + ); + await this.postManagedState(webview, { status: 'ready', project: result.project }); + } catch (error) { + await this.postManagedError(webview, toProjectOperationError(error)); + } + } + + private async handleLeaveModManagement(webview: vscode.Webview): Promise { + this.cancelActivePreview(); + this._managedTarget = undefined; + await this.postManagedState(webview, { status: 'idle' }); + } + + private async handleRefreshManagedMod(webview: vscode.Webview): Promise { + const context = await this.requireManagedContext(webview, true); + if (!context) return; + try { + const result = await this._projectOperations.inspect( + context.workspace.uri.fsPath, + context.target.targetRoot + ); + await this.postManagedState(webview, { status: 'ready', project: result.project }); + } catch (error) { + await this.postManagedError(webview, toProjectOperationError(error)); + } + } + + private async handleGenerateUuidPreview( + webview: vscode.Webview, + messageDirty: boolean + ): Promise { + if (messageDirty) { + this._projectOperations.setConfigurationDirty(this._configurationDirtySource, true); + } + if (previewGenerationIsBlocked( + this._activePreviewId, + this._projectOperations.hasPreviewSessions + )) { + await this.postManagedError(webview, new ProjectOperationError( + 'preview_active', + projectText( + '已有更改预览;请先应用或取消,不能静默替换。', + 'A change preview is already active; apply or cancel it before generating another.' + ) + )); + return; + } + const context = await this.requireManagedContext(webview, true); + if (!context) return; + try { + const session = await this._projectOperations.previewRegenerateUuids( + context.workspace.uri.fsPath, + context.target.targetRoot + ); + if (!this.isCurrentTarget(context.target)) { + this._projectOperations.cancelPreview(session.preview.id); + return; + } + this._activePreviewId = session.preview.id; + await this.postManagedState(webview, { + status: 'ready', + message: projectText('UUID 更改预览已生成。', 'UUID change preview generated.') + }); + } catch (error) { + await this.postManagedError(webview, toProjectOperationError(error)); + } + } + + private async handleGenerateVersionPreview( + webview: vscode.Webview, + partValue: unknown, + messageDirty: boolean + ): Promise { + if (messageDirty) { + this._projectOperations.setConfigurationDirty(this._configurationDirtySource, true); + } + if (!isVersionPart(partValue)) { + await this.postManagedError(webview, new ProjectOperationError( + 'invalid_arguments', + projectText('版本提升类型无效。', 'Invalid version bump part.') + )); + return; + } + if (previewGenerationIsBlocked( + this._activePreviewId, + this._projectOperations.hasPreviewSessions + )) { + await this.postManagedError(webview, new ProjectOperationError( + 'preview_active', + projectText( + '已有更改预览;请先应用或取消,不能静默替换。', + 'A change preview is already active; apply or cancel it before generating another.' + ) + )); + return; + } + const context = await this.requireManagedContext(webview, true); + if (!context) return; + try { + const session = await this._projectOperations.previewBumpVersion( + context.workspace.uri.fsPath, + partValue, + context.target.targetRoot + ); + if (!this.isCurrentTarget(context.target)) { + this._projectOperations.cancelPreview(session.preview.id); + return; + } + this._activePreviewId = session.preview.id; + await this.postManagedState(webview, { + status: 'ready', + message: projectText('版本更改预览已生成。', 'Version change preview generated.') + }); + } catch (error) { + await this.postManagedError(webview, toProjectOperationError(error)); + } + } + + private async handleOpenPreviewDiff( + webview: vscode.Webview, + previewIdValue: unknown, + fileIndexValue: unknown + ): Promise { + if ( + typeof previewIdValue !== 'string' + || !Number.isInteger(fileIndexValue) + || fileIndexValue as number < 0 + || previewIdValue !== this._activePreviewId + ) { + await this.postManagedError(webview, new ProjectOperationError( + 'invalid_preview', + projectText('预览文件已失效。', 'The preview file is no longer available.') + )); + return; + } + const session = this._projectOperations.getPreviewSession(previewIdValue); + const fileIndex = fileIndexValue as number; + const file = session?.preview.files[fileIndex]; + if (!session || !file) { + await this.postManagedError(webview, new ProjectOperationError( + 'invalid_preview', + projectText('预览文件已失效。', 'The preview file is no longer available.') + )); + return; + } + + try { + const title = projectText( + `${displayPreviewPath(file.path, this._managedTarget)}(预览)`, + `${displayPreviewPath(file.path, this._managedTarget)} (Preview)` + ); + await vscode.commands.executeCommand( + 'vscode.diff', + this._previewDocuments.uriFor(session, fileIndex, 'before'), + this._previewDocuments.uriFor(session, fileIndex, 'after'), + title, + { preview: true } + ); + this._projectOperations.markPreviewViewed(previewIdValue, fileIndex); + await this.postManagedState(webview); + } catch (error) { + await this.postManagedError(webview, toProjectOperationError(error)); + } + } + + private async handleCancelPreview( + webview: vscode.Webview, + previewIdValue: unknown + ): Promise { + if ( + previewIdValue !== undefined + && (typeof previewIdValue !== 'string' || previewIdValue !== this._activePreviewId) + ) { + await this.postManagedError(webview, new ProjectOperationError( + 'invalid_preview', + projectText('预览已失效。', 'The preview is no longer available.') + )); + return; + } + this.cancelActivePreview(); + await this.postManagedState(webview, { + status: this._managedTarget ? 'ready' : 'idle', + message: projectText('已取消变更。', 'Changes cancelled.') + }); + } + + private async handleManagedModWebviewMessage( + webview: vscode.Webview, + value: unknown + ): Promise { + const message = parseManagedModWebviewMessage(value); + if (!message) return false; + switch (message.type) { + case 'manageMod': + await this.handleManageMod(webview, message.index); + break; + case 'leaveModManagement': + await this.handleLeaveModManagement(webview); + break; + case 'refreshManagedMod': + await this.handleRefreshManagedMod(webview); + break; + case 'projectConfigDirty': + await this.handleProjectConfigDirty(webview, message.dirty); + break; + case 'generateUuidPreview': + await this.handleGenerateUuidPreview(webview, message.dirty); + break; + case 'generateVersionPreview': + await this.handleGenerateVersionPreview(webview, message.part, message.dirty); + break; + case 'openPreviewDiff': + await this.handleOpenPreviewDiff(webview, message.previewId, message.fileIndex); + break; + case 'cancelPreview': + await this.handleCancelPreview(webview, message.previewId); + break; + case 'approvePreview': + await this.handleApprovePreview(webview, message.previewId); + break; + } + return true; + } + + private async handleApprovePreview( + webview: vscode.Webview, + previewIdValue: unknown + ): Promise { + if (typeof previewIdValue !== 'string' || previewIdValue !== this._activePreviewId) { + await this.postManagedError(webview, new ProjectOperationError( + 'invalid_preview', + projectText('预览已失效,请重新生成。', 'The preview expired; generate it again.') + )); + return; + } + if (!this._projectOperations.getPreviewSession(previewIdValue)) { + await this.postManagedError(webview, new ProjectOperationError( + 'invalid_preview', + projectText('预览已失效,请重新生成。', 'The preview expired; generate it again.') + )); + return; + } + + const context = await this.requireManagedContext(webview, true); + if (!context) return; + const session = this._projectOperations.getPreviewSession(previewIdValue); + if (!session || previewIdValue !== this._activePreviewId) { + await this.postManagedError(webview, new ProjectOperationError( + 'invalid_preview', + projectText('确认期间预览已失效,请重新生成。', 'The preview expired during confirmation; generate it again.') + )); + return; + } + + try { + const result = await this._projectOperations.applyPreview( + context.workspace.uri.fsPath, + previewIdValue + ); + this._activePreviewId = undefined; + await this.postManagedState(webview, { + status: 'ready', + project: result.project, + message: projectText( + `已应用全部更改(${result.modifiedFiles.length} 个文件)。`, + `Applied the complete change set (${result.modifiedFiles.length} files).` + ) + }); + } catch (error) { + const projectError = toProjectOperationError(error); + if (projectError.code === 'preview_stale') { + await this.regenerateStalePreview(webview, session); + return; + } + await this.postManagedError(webview, projectError); + } + } + + private async regenerateStalePreview( + webview: vscode.Webview, + staleSession: ProjectPreviewSession + ): Promise { + this._projectOperations.cancelPreview(staleSession.preview.id); + this._activePreviewId = undefined; + const freshContext = await this.requireManagedContext(webview, true); + if (!freshContext) return; + const plan = planStalePreviewRegeneration(staleSession.preview); + try { + const replacement = plan.operation === 'bump-version' + ? await this._projectOperations.previewBumpVersion( + freshContext.workspace.uri.fsPath, + plan.versionPart ?? 'patch', + freshContext.target.targetRoot + ) + : await this._projectOperations.previewRegenerateUuids( + freshContext.workspace.uri.fsPath, + freshContext.target.targetRoot + ); + if (!this.isCurrentTarget(freshContext.target)) { + this._projectOperations.cancelPreview(replacement.preview.id); + return; + } + this._activePreviewId = replacement.preview.id; + await this.postManagedState(webview, { + status: 'error', + message: projectText( + '源文件已变化,旧预览未应用。已自动生成新预览;请重新查看并再次批准。', + 'Source files changed, so the old preview was not applied. A new preview was generated; review and approve it again.' + ), + errorCode: 'preview_stale' + }); + } catch (error) { + await this.postManagedError(webview, toProjectOperationError(error)); + } + } + + private async requireManagedContext( + webview: vscode.Webview, + requireSavedDocuments: boolean + ): Promise { + const workspace = vscode.workspace.workspaceFolders?.[0]; + if (!workspace) { + await this.postNoWorkspace(webview); + return undefined; + } + if (!this._managedTarget) { + await this.postManagedError(webview, new ProjectOperationError( + 'no_managed_mod', + projectText('请先从 included_mod_dirs 选择一个 Mod。', 'Select a Mod from included_mod_dirs first.') + )); + return undefined; + } + if (this._projectOperations.configurationDirty) { + await this.postManagedError(webview, new ProjectOperationError( + 'configuration_dirty', + projectText('请先保存 .mcdev.json。', 'Save .mcdev.json first.') + )); + return undefined; + } + if (!this._projectOperations.available) { + await this.postManagedError(webview, new ProjectOperationError( + 'backend_unavailable', + projectText( + '内置 mcdk 不可用,请重新安装或更新扩展。', + 'The bundled mcdk is unavailable. Reinstall or update the extension.' + ) + )); + return undefined; + } + if (this._projectOperations.busy) { + await this.postManagedState(webview, { status: 'busy' }); + return undefined; + } + + try { + const snapshot = await this._configStore.getSnapshot(workspace.uri.fsPath); + const resolved = resolveManagedModTarget( + workspace.uri.fsPath, + snapshot.config.included_mod_dirs, + this._managedTarget.index + ); + assertDirectoryTarget(resolved.targetRoot); + if (!this.isCurrentTarget(resolved)) { + this.cancelActivePreview(); + this._managedTarget = resolved; + await this.postManagedState(webview, { + status: 'error', + message: projectText( + '已保存的 Mod 路径发生变化,请刷新后继续。', + 'The saved Mod path changed; refresh before continuing.' + ), + errorCode: 'target_changed' + }); + return undefined; + } + } catch (error) { + await this.postManagedError(webview, toProjectOperationError(error)); + return undefined; + } + + const unsavedDocumentPaths = this.currentUnsavedDocumentPaths( + workspace.uri.fsPath, + this._managedTarget.targetRoot + ); + if (requireSavedDocuments && unsavedDocumentPaths.length > 0) { + await this.postManagedState(webview, { + status: 'error', + message: projectText( + '请先保存工作区和当前 Mod 目录中的所有文件。', + 'Save every file in the workspace and the current Mod directory first.' + ), + errorCode: 'unsaved_documents' + }); + return undefined; + } + return { workspace, target: this._managedTarget }; + } + + private async postManagedError( + webview: vscode.Webview, + error: ProjectOperationError + ): Promise { + const unavailable = error.code === 'backend_unavailable' || error.code === 'protocol_mismatch'; + await this.postManagedState(webview, { + status: unavailable ? 'unavailable' : (error.code === 'backend_busy' ? 'busy' : 'error'), + message: error.message, + errorCode: error.code + }); + } + + private async postNoWorkspace(webview: vscode.Webview): Promise { + await this.postManagedState(webview, { + status: 'unavailable', + message: projectText('请先打开工作区。', 'Open a workspace to manage Mods.'), + errorCode: 'no_workspace' + }); + } + + private async postManagedState( + webview: vscode.Webview, + overrides: Partial = {} + ): Promise { + const workspace = vscode.workspace.workspaceFolders?.[0]; + const target = this._managedTarget; + let session = this._activePreviewId + ? this._projectOperations.getPreviewSession(this._activePreviewId) + : undefined; + if (this._activePreviewId && !session) { + this._activePreviewId = undefined; + session = undefined; + } + const unsavedDocumentPaths = workspace + ? this.currentUnsavedDocumentPaths(workspace.uri.fsPath, target?.targetRoot) + : []; + const project = workspace && target + ? this.currentProject(workspace.uri.fsPath, target.targetRoot) + : undefined; + const defaultStatus: ManagedModWebviewState['status'] = this._projectOperations.busy + ? 'busy' + : (!workspace + ? 'unavailable' + : (!target + ? 'idle' + : (!this._projectOperations.available + ? 'unavailable' + : (project ? 'ready' : 'loading')))); + const state: ManagedModWebviewState = { + status: defaultStatus, + hasWorkspace: workspace !== undefined, + busy: this._projectOperations.busy, + operation: this._projectOperations.operation, + managedIndex: target?.index, + target: target ? { + label: target.label, + configuredPath: target.configuredPath, + resolvedPath: target.targetRoot, + external: target.external + } : undefined, + project, + preview: session ? previewState(session, target) : undefined, + hasUnsavedDocuments: unsavedDocumentPaths.length > 0, + unsavedDocumentPaths, + ...overrides + }; + await webview.postMessage({ type: 'managedModState', state }); + } + + private currentProject( + workspacePath: string, + targetPath: string + ): ProjectDisplaySummary | undefined { + return this._projectOperations.getLastResult(workspacePath, targetPath)?.project; + } + + private currentUnsavedDocumentPaths( + workspacePath: string, + targetPath: string = workspacePath + ): string[] { + const paths = dirtyProjectDocumentPaths( + workspacePath, + targetPath, + vscode.workspace.textDocuments.map(document => ({ + scheme: document.uri.scheme, + fsPath: document.uri.fsPath, + isDirty: document.isDirty + })) + ); + if (this._projectOperations.configurationDirty) { + paths.push(path.join(workspacePath, '.mcdev.json')); + } + return [...new Set(paths)].sort((left, right) => left.localeCompare(right)); + } + + private cancelActivePreview(): void { + if (this._activePreviewId) { + this._projectOperations.cancelPreview(this._activePreviewId); + this._activePreviewId = undefined; + } + } + + private isCurrentTarget(target: ManagedModTarget): boolean { + return this._managedTarget?.index === target.index + && normalizeWorkspacePath(this._managedTarget.targetRoot) === normalizeWorkspacePath(target.targetRoot) + && normalizeWorkspacePath(this._managedTarget.workspaceRoot) === normalizeWorkspacePath(target.workspaceRoot); + } + + private setupProjectSubscription(webview: vscode.Webview): void { + this._projectSubscription?.dispose(); + this._projectSubscription = this._projectOperations.onDidChangeState(() => { + void this.postManagedState(webview).catch(error => { + console.error('Unable to post shared managed Mod operation state', error); + }); + }); + } + + private setupDocumentSubscriptions(webview: vscode.Webview): void { + for (const subscription of this._documentSubscriptions) subscription.dispose(); + this._documentSubscriptions = []; + + const refreshIfRelevantDocument = (document: vscode.TextDocument): void => { + const workspace = vscode.workspace.workspaceFolders?.[0]; + if (!workspace || document.uri.scheme !== 'file') return; + const targetPath = this._managedTarget?.targetRoot; + if ( + !isContainedPath(workspace.uri.fsPath, document.uri.fsPath) + && (!targetPath || !isContainedPath(targetPath, document.uri.fsPath)) + ) { + return; + } + void this.postManagedState(webview).catch(error => { + console.error('Unable to refresh unsaved managed Mod document state', error); + }); + }; + + this._documentSubscriptions.push( + vscode.workspace.onDidOpenTextDocument(refreshIfRelevantDocument), + vscode.workspace.onDidChangeTextDocument(event => refreshIfRelevantDocument(event.document)), + vscode.workspace.onDidSaveTextDocument(refreshIfRelevantDocument), + vscode.workspace.onDidCloseTextDocument(refreshIfRelevantDocument) + ); + } + /** * 打开外部链接 */ @@ -492,9 +1305,12 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs if (normalizeWorkspacePath(workspacePath) !== currentWorkspacePath) { return; } + const configRevision = ++this._configMessageRevision; try { const snapshot = await this._configStore.getSnapshot(workspaceFolder.uri.fsPath); - await this.postConfig(webview, workspaceFolder, snapshot); + this.cancelActivePreview(); + await this.postConfig(webview, workspaceFolder, snapshot, { configRevision }); + await this.postManagedState(webview); } catch (error) { console.error('Error refreshing shared .mcdev.json configuration:', error); } @@ -505,23 +1321,38 @@ export class McDevToolsSidebarProvider implements vscode.WebviewViewProvider, vs webview: vscode.Webview, workspaceFolder: vscode.WorkspaceFolder, snapshot: McdevConfigSnapshot, - options: { language?: string; needsInitialSave?: boolean } = {} + options: { + language?: string; + needsInitialSave?: boolean; + configRevision?: number; + } = {} ): Promise { + const configRevision = options.configRevision ?? ++this._configMessageRevision; const skinPath = snapshot.config.skin_info?.skin; let skinPreviewUri: string | undefined; + let skinPreviewErrorCode: string | undefined; if (typeof skinPath === 'string' && skinPath.trim()) { const filePath = path.isAbsolute(skinPath) ? skinPath : path.join(workspaceFolder.uri.fsPath, skinPath); - skinPreviewUri = webview.asWebviewUri(vscode.Uri.file(filePath)).toString(); + const preview = await readSkinPreview(filePath); + skinPreviewUri = preview.previewUri; + skinPreviewErrorCode = preview.errorCode; + } + + if (configRevision !== this._configMessageRevision) { + return; } await webview.postMessage({ type: 'init', content: JSON.stringify(snapshot.config), + configRevision, language: options.language, needsInitialSave: options.needsInitialSave, skinPreviewUri, + skinPreviewErrorCode, + customSkins: this.getCustomSkins(workspaceFolder.uri.fsPath), gameExecutableDiscoverySupported: isGameExecutableDiscoverySupported }); } @@ -562,3 +1393,68 @@ function normalizeWorkspacePath(workspacePath: string): string { const normalized = path.normalize(workspacePath); return process.platform === 'win32' ? normalized.toLowerCase() : normalized; } + +function projectText(chinese: string, english: string): string { + return vscode.env.language.toLowerCase().startsWith('zh') ? chinese : english; +} + +function isVersionPart(value: unknown): value is VersionPart { + return value === 'patch' || value === 'minor' || value === 'major'; +} + +function toProjectOperationError(error: unknown): ProjectOperationError { + return error instanceof ProjectOperationError + ? error + : new ProjectOperationError( + 'project_operation_failed', + error instanceof Error ? error.message : String(error), + { cause: error } + ); +} + +function assertDirectoryTarget(targetPath: string): void { + try { + if (fs.statSync(targetPath).isDirectory()) return; + } catch (error) { + throw new ProjectOperationError( + 'invalid_target', + `The saved Mod target does not exist or is not a directory: ${targetPath}`, + { path: targetPath, cause: error } + ); + } + throw new ProjectOperationError( + 'invalid_target', + `The saved Mod target is not a directory: ${targetPath}`, + { path: targetPath } + ); +} + +function previewState( + session: ProjectPreviewSession, + target: ManagedModTarget | undefined +): NonNullable { + const files = session.preview.files.map((file, index) => ({ + index, + path: displayPreviewPath(file.path, target), + viewed: session.viewedIndices.has(index) + })); + const viewedCount = files.filter(file => file.viewed).length; + return { + id: session.preview.id, + operation: session.preview.operation, + versionPart: session.preview.versionPart, + files, + viewedCount, + unviewedCount: files.length - viewedCount + }; +} + +function displayPreviewPath(filePath: string, target: ManagedModTarget | undefined): string { + if (target && isContainedPath(target.targetRoot, filePath)) { + return path.relative(target.targetRoot, filePath).split(path.sep).join('/'); + } + if (target && isContainedPath(target.workspaceRoot, filePath)) { + return `workspace/${path.relative(target.workspaceRoot, filePath).split(path.sep).join('/')}`; + } + return path.basename(filePath); +} diff --git a/src/skins/customSkinLibrary.test.ts b/src/skins/customSkinLibrary.test.ts new file mode 100644 index 0000000..02ee7ec --- /dev/null +++ b/src/skins/customSkinLibrary.test.ts @@ -0,0 +1,47 @@ +import * as assert from 'node:assert/strict'; +import * as path from 'path'; +import { test } from 'node:test'; +import { + customSkinLibraryStorageKey, + MAX_CUSTOM_SKINS, + parseCustomSkinLibrary +} from './customSkinLibrary'; + +test('parses, trims, and deduplicates custom skin entries', () => { + assert.deepEqual(parseCustomSkinLibrary([ + { path: ' skins/first.png ', slim: false }, + { path: 'skins/./first.png', slim: true }, + null, + { path: 'skins/second.png', slim: true }, + { path: '', slim: false }, + { path: 'skins/missing-model.png' } + ]), [ + { path: 'skins/first.png', slim: false }, + { path: 'skins/second.png', slim: true } + ]); +}); + +test('rejects invalid state and caps the custom skin library', () => { + assert.deepEqual(parseCustomSkinLibrary({}), []); + const entries = Array.from({ length: MAX_CUSTOM_SKINS + 5 }, (_, index) => ({ + path: `skin-${index}.png`, + slim: index % 2 === 0 + })); + const parsed = parseCustomSkinLibrary(entries); + assert.equal(parsed.length, MAX_CUSTOM_SKINS); + assert.deepEqual(parsed.at(-1), { + path: `skin-${MAX_CUSTOM_SKINS - 1}.png`, + slim: (MAX_CUSTOM_SKINS - 1) % 2 === 0 + }); +}); + +test('builds a stable workspace-specific storage key', () => { + const workspace = path.join('D:', 'Projects', 'Example'); + const expectedPath = process.platform === 'win32' + ? path.normalize(workspace).toLowerCase() + : path.normalize(workspace); + assert.equal( + customSkinLibraryStorageKey(workspace), + `customSkinLibrary:${expectedPath}` + ); +}); diff --git a/src/skins/customSkinLibrary.ts b/src/skins/customSkinLibrary.ts new file mode 100644 index 0000000..1317e06 --- /dev/null +++ b/src/skins/customSkinLibrary.ts @@ -0,0 +1,52 @@ +import * as path from 'path'; +import { CustomSkin } from '../types'; + +export const MAX_CUSTOM_SKINS = 64; +const MAX_CUSTOM_SKIN_PATH_LENGTH = 32_768; + +export function customSkinLibraryStorageKey(workspacePath: string): string { + const normalized = path.normalize(workspacePath); + const key = process.platform === 'win32' ? normalized.toLowerCase() : normalized; + return `customSkinLibrary:${key}`; +} + +export function parseCustomSkinLibrary(value: unknown): CustomSkin[] { + if (!Array.isArray(value)) { + return []; + } + + const skins: CustomSkin[] = []; + const seenPaths = new Set(); + for (const candidate of value) { + if (skins.length >= MAX_CUSTOM_SKINS) { + break; + } + if (!isRecord(candidate)) { + continue; + } + if (typeof candidate.path !== 'string' || typeof candidate.slim !== 'boolean') { + continue; + } + + const skinPath = candidate.path.trim(); + if (!skinPath || skinPath.length > MAX_CUSTOM_SKIN_PATH_LENGTH) { + continue; + } + const comparisonPath = comparablePath(skinPath); + if (seenPaths.has(comparisonPath)) { + continue; + } + seenPaths.add(comparisonPath); + skins.push({ path: skinPath, slim: candidate.slim }); + } + return skins; +} + +function comparablePath(value: string): string { + const normalized = path.normalize(value).replace(/\\/g, '/'); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/skins/index.ts b/src/skins/index.ts new file mode 100644 index 0000000..47a585b --- /dev/null +++ b/src/skins/index.ts @@ -0,0 +1,2 @@ +export * from './customSkinLibrary'; +export * from './vanillaSkins'; diff --git a/src/skins/vanillaSkins.test.ts b/src/skins/vanillaSkins.test.ts new file mode 100644 index 0000000..2278770 --- /dev/null +++ b/src/skins/vanillaSkins.test.ts @@ -0,0 +1,458 @@ +import * as assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { zipSync } from 'fflate'; +import { after, test } from 'node:test'; +import { + loadVanillaSkins, + MAX_NETEASE_4D_ARCHIVE_BYTES, + MAX_SKIN_CATALOG_BYTES, + MAX_SKIN_CATALOG_ENTRIES, + MAX_SKIN_PREVIEW_BYTES, + MAX_VANILLA_TEXTURE_READ_BYTES, + NETEASE_4D_SKINS, + Netease4DSkinDescriptor, + readSkinPreview, + resolveGameExecutablePath +} from './vanillaSkins'; + +const temporaryRoots: string[] = []; +const validPngBytes = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64' +); +const validPngDataUri = `data:image/png;base64,${validPngBytes.toString('base64')}`; + +after(async () => { + for (const root of temporaryRoots) { + await fs.promises.rm(root, { recursive: true, force: true }); + } +}); + +test('loads only free PNG skins and recognizes the customSlim geometry', async () => { + const fixture = await createFixture(); + await writeTexture(fixture, 'classic/Steve.PNG', validPngBytes); + await writeTexture(fixture, 'Alex.png', validPngBytes); + await writeTexture(fixture, 'Dummy.png', validPngBytes); + await writeTexture(fixture, 'Locked.png', validPngBytes); + await writeCatalog(fixture, { + skins: [ + skin('Steve', 'classic/Steve.PNG', 'geometry.humanoid.custom', 'free'), + skin('Alex', 'Alex.png', 'geometry.humanoid.customSlim', 'free'), + skin('Dummy', 'Dummy.png', 'geometry.humanoid.custom', 'custom'), + skin('Locked', 'Locked.png', 'geometry.humanoid.custom', 'paid') + ] + }); + + const result = await loadVanillaSkins(fixture.executablePath); + + assert.equal(result.errorCode, undefined); + assert.equal(result.resolvedGameExecutablePath, path.resolve(fixture.executablePath)); + assert.deepEqual(result.skins.map(value => ({ + id: value.id, + name: value.name, + texture: value.texture, + slim: value.slim, + previewUri: value.previewUri, + kind: value.kind + })), [ + { + id: 'classic/Steve.PNG', + name: 'Steve', + texture: 'classic/Steve.PNG', + slim: false, + previewUri: validPngDataUri, + kind: 'vanilla' + }, + { + id: 'Alex.png', + name: 'Alex', + texture: 'Alex.png', + slim: true, + previewUri: validPngDataUri, + kind: 'vanilla' + } + ]); +}); + +test('loads the four official NetEase 4D skin archives with fixed previews', async () => { + const fixture = await createFixture(); + await writeTexture(fixture, 'Steve.png', validPngBytes); + await writeCatalog(fixture, { + skins: [skin('Steve', 'Steve.png', 'geometry.humanoid.custom', 'free')] + }); + for (const descriptor of NETEASE_4D_SKINS) { + await writeNetease4DArchive(fixture, descriptor, validPngBytes); + } + + const result = await loadVanillaSkins(fixture.executablePath); + const fourDSkins = result.skins.filter(value => value.kind === 'netease4d'); + + assert.equal(result.errorCode, undefined); + assert.deepEqual(fourDSkins.map(value => ({ + id: value.id, + name: value.name, + texture: value.texture, + path: value.path, + slim: value.slim, + previewUri: value.previewUri + })), NETEASE_4D_SKINS.map(descriptor => ({ + id: descriptor.id, + name: descriptor.name, + texture: `${descriptor.directory}/${descriptor.archive}`, + path: path.join(fixture.supportDirectory, descriptor.directory, descriptor.archive), + slim: false, + previewUri: validPngDataUri + }))); +}); + +test('skips missing, corrupt, unexpected, and oversized NetEase 4D archives', async () => { + const fixture = await createFixture(); + await writeTexture(fixture, 'Steve.png', validPngBytes); + await writeCatalog(fixture, { + skins: [skin('Steve', 'Steve.png', 'geometry.humanoid.custom', 'free')] + }); + + await writeNetease4DArchive(fixture, NETEASE_4D_SKINS[0], validPngBytes); + const corruptPath = archivePath(fixture, NETEASE_4D_SKINS[1]); + await fs.promises.mkdir(path.dirname(corruptPath), { recursive: true }); + await fs.promises.writeFile(corruptPath, 'not a zip'); + const unexpectedPath = archivePath(fixture, NETEASE_4D_SKINS[2]); + await fs.promises.mkdir(path.dirname(unexpectedPath), { recursive: true }); + await fs.promises.writeFile(unexpectedPath, zipSync({ + [`../${NETEASE_4D_SKINS[2].previewEntry}`]: validPngBytes + })); + const oversizedPath = await writeNetease4DArchive( + fixture, + NETEASE_4D_SKINS[3], + validPngBytes + ); + await fs.promises.truncate(oversizedPath, MAX_NETEASE_4D_ARCHIVE_BYTES + 1); + + const result = await loadVanillaSkins(fixture.executablePath); + + assert.equal(result.errorCode, undefined); + assert.deepEqual( + result.skins.filter(value => value.kind === 'netease4d').map(value => value.id), + [NETEASE_4D_SKINS[0].id] + ); + assert.deepEqual(result.skins.filter(value => value.kind === 'vanilla').map(value => value.id), [ + 'Steve.png' + ]); +}); + +test('rejects traversal, non-PNG, invalid, oversized, and missing texture files', async () => { + const fixture = await createFixture(); + await writeTexture(fixture, 'Steve.png', validPngBytes); + await writeTexture(fixture, 'Broken.png', Buffer.from('not a png')); + await writeTexture(fixture, 'Huge.png', validPngBytes); + await writeTexture(fixture, 'UnknownGeometry.png', validPngBytes); + await fs.promises.truncate( + path.join(fixture.vanillaDirectory, 'Huge.png'), + MAX_SKIN_PREVIEW_BYTES + 1 + ); + await fs.promises.writeFile(path.join(fixture.vanillaDirectory, '..', 'escaped.png'), 'escaped'); + await fs.promises.writeFile(path.join(fixture.vanillaDirectory, 'notes.jpg'), 'jpg'); + await writeCatalog(fixture, { + skins: [ + skin('Escaped', '../escaped.png', 'geometry.humanoid.custom', 'free'), + skin('Missing', 'missing.png', 'geometry.humanoid.custom', 'free'), + skin('Not PNG', 'notes.jpg', 'geometry.humanoid.custom', 'free'), + skin('Broken', 'Broken.png', 'geometry.humanoid.custom', 'free'), + skin('Huge', 'Huge.png', 'geometry.humanoid.custom', 'free'), + skin('Unknown Geometry', 'UnknownGeometry.png', 'geometry.unknown', 'free'), + skin('Steve', 'Steve.png', 'geometry.humanoid.custom', 'free') + ] + }); + + const result = await loadVanillaSkins(fixture.executablePath); + + assert.equal(result.errorCode, undefined); + assert.deepEqual(result.skins.map(value => value.id), ['Steve.png']); +}); + +test('returns catalog_not_found when skins.json is missing', async () => { + const fixture = await createFixture(); + const result = await loadVanillaSkins(fixture.executablePath); + assert.equal(result.errorCode, 'catalog_not_found'); + assert.deepEqual(result.skins, []); +}); + +test('returns catalog_invalid for malformed JSON or an invalid catalog shape', async () => { + const malformedFixture = await createFixture(); + await fs.promises.writeFile(malformedFixture.catalogPath, '{not json', 'utf8'); + assert.equal( + (await loadVanillaSkins(malformedFixture.executablePath)).errorCode, + 'catalog_invalid' + ); + + const invalidShapeFixture = await createFixture(); + await writeCatalog(invalidShapeFixture, { skins: {} }); + assert.equal( + (await loadVanillaSkins(invalidShapeFixture.executablePath)).errorCode, + 'catalog_invalid' + ); +}); + +test('rejects oversized catalogs, excessive entries, and cumulative texture reads', async () => { + const oversizedCatalogFixture = await createFixture(); + await fs.promises.writeFile(oversizedCatalogFixture.catalogPath, '{}', 'utf8'); + await fs.promises.truncate( + oversizedCatalogFixture.catalogPath, + MAX_SKIN_CATALOG_BYTES + 1 + ); + assert.equal( + (await loadVanillaSkins(oversizedCatalogFixture.executablePath)).errorCode, + 'catalog_invalid' + ); + + const excessiveEntriesFixture = await createFixture(); + await writeCatalog(excessiveEntriesFixture, { + skins: Array.from({ length: MAX_SKIN_CATALOG_ENTRIES + 1 }, (_, index) => ( + skin(`Skin ${index}`, `skin-${index}.png`, 'geometry.humanoid.custom', 'free') + )) + }); + assert.equal( + (await loadVanillaSkins(excessiveEntriesFixture.executablePath)).errorCode, + 'catalog_invalid' + ); + + const textureBudgetFixture = await createFixture(); + const textureBytes = pngWithTextPayload(Math.floor(MAX_VANILLA_TEXTURE_READ_BYTES / 3)); + await writeTexture(textureBudgetFixture, 'one.png', textureBytes); + await writeTexture(textureBudgetFixture, 'two.png', textureBytes); + await writeTexture(textureBudgetFixture, 'three.png', textureBytes); + await writeTexture(textureBudgetFixture, 'four.png', textureBytes); + await writeCatalog(textureBudgetFixture, { + skins: ['one', 'two', 'three', 'four'].map(name => ( + skin(name, `${name}.png`, 'geometry.humanoid.custom', 'free') + )) + }); + assert.equal( + (await loadVanillaSkins(textureBudgetFixture.executablePath)).errorCode, + 'catalog_invalid' + ); +}); + +test('skips an oversized texture when the remaining catalog budget is smaller', async () => { + const fixture = await createFixture(); + const textureBytes = pngWithTextPayload(Math.floor(MAX_VANILLA_TEXTURE_READ_BYTES / 3)); + await writeTexture(fixture, 'one.png', textureBytes); + await writeTexture(fixture, 'two.png', textureBytes); + await writeTexture(fixture, 'Huge.png', validPngBytes); + await fs.promises.truncate( + path.join(fixture.vanillaDirectory, 'Huge.png'), + MAX_SKIN_PREVIEW_BYTES + 1 + ); + await writeCatalog(fixture, { + skins: [ + skin('one', 'one.png', 'geometry.humanoid.custom', 'free'), + skin('two', 'two.png', 'geometry.humanoid.custom', 'free'), + skin('Huge', 'Huge.png', 'geometry.humanoid.custom', 'free') + ] + }); + + const result = await loadVanillaSkins(fixture.executablePath); + + assert.equal(result.errorCode, undefined); + assert.deepEqual(result.skins.map(value => value.id), ['one.png', 'two.png']); +}); + +test('returns no_skins when every catalog entry is filtered out', async () => { + const fixture = await createFixture(); + await writeCatalog(fixture, { + skins: [skin('Missing', 'missing.png', 'geometry.humanoid.custom', 'free')] + }); + + const result = await loadVanillaSkins(fixture.executablePath); + assert.equal(result.errorCode, 'no_skins'); + assert.deepEqual(result.skins, []); +}); + +test('prefers a valid requested executable and otherwise uses the first valid discovery', async () => { + const requested = await createFixture(); + const newest = await createFixture(); + const older = await createFixture(); + + assert.equal( + await resolveGameExecutablePath(requested.executablePath, [newest.executablePath]), + path.resolve(requested.executablePath) + ); + assert.equal( + await resolveGameExecutablePath(path.join(requested.root, 'missing.exe'), [ + newest.executablePath, + older.executablePath + ]), + path.resolve(newest.executablePath) + ); + assert.equal( + await resolveGameExecutablePath(undefined, [ + path.join(newest.root, 'stale.exe'), + older.executablePath + ]), + path.resolve(older.executablePath) + ); + assert.equal(await resolveGameExecutablePath(undefined, []), undefined); +}); + +test('builds custom PNG previews and reports invalid or missing paths', async () => { + const fixture = await createFixture(); + const pngPath = path.join(fixture.root, 'custom.PNG'); + const invalidPngPath = path.join(fixture.root, 'invalid.png'); + const invalidColorTypePath = path.join(fixture.root, 'invalid-color-type.png'); + const truncatedPngPath = path.join(fixture.root, 'truncated.png'); + const corruptPngPath = path.join(fixture.root, 'corrupt.png'); + const oversizedPngPath = path.join(fixture.root, 'oversized.png'); + const textPath = path.join(fixture.root, 'custom.txt'); + await fs.promises.writeFile(pngPath, validPngBytes); + await fs.promises.writeFile(invalidPngPath, 'custom'); + const invalidColorTypeBytes = Buffer.from(validPngBytes); + invalidColorTypeBytes[25] = 1; + invalidColorTypeBytes.writeUInt32BE( + testCrc32(invalidColorTypeBytes, 12, 29), + 29 + ); + await fs.promises.writeFile(invalidColorTypePath, invalidColorTypeBytes); + await fs.promises.writeFile(truncatedPngPath, validPngBytes.subarray(0, 33)); + const corruptPngBytes = Buffer.from(validPngBytes); + corruptPngBytes[corruptPngBytes.length - 1] ^= 0xff; + await fs.promises.writeFile(corruptPngPath, corruptPngBytes); + await fs.promises.writeFile(oversizedPngPath, validPngBytes); + await fs.promises.truncate(oversizedPngPath, MAX_SKIN_PREVIEW_BYTES + 1); + await fs.promises.writeFile(textPath, 'custom'); + + assert.deepEqual(await readSkinPreview(pngPath), { + previewUri: validPngDataUri + }); + assert.deepEqual(await readSkinPreview(invalidPngPath), { errorCode: 'skin_not_png' }); + assert.deepEqual(await readSkinPreview(invalidColorTypePath), { errorCode: 'skin_not_png' }); + assert.deepEqual(await readSkinPreview(truncatedPngPath), { errorCode: 'skin_not_png' }); + assert.deepEqual(await readSkinPreview(corruptPngPath), { errorCode: 'skin_not_png' }); + assert.deepEqual(await readSkinPreview(oversizedPngPath), { errorCode: 'skin_too_large' }); + assert.deepEqual(await readSkinPreview(textPath), { errorCode: 'skin_not_png' }); + assert.deepEqual( + await readSkinPreview(path.join(fixture.root, 'missing.png')), + { errorCode: 'skin_not_found' } + ); +}); + +test('builds previews for known NetEase 4D archives and rejects other ZIP files', async () => { + const fixture = await createFixture(); + const archive = await writeNetease4DArchive( + fixture, + NETEASE_4D_SKINS[0], + validPngBytes + ); + const unknownArchive = path.join(fixture.root, 'unknown.zip'); + await fs.promises.writeFile(unknownArchive, zipSync({ + 'preview.png': validPngBytes + })); + + assert.deepEqual(await readSkinPreview(archive), { + previewUri: validPngDataUri + }); + assert.deepEqual(await readSkinPreview(unknownArchive), { + errorCode: 'skin_not_png' + }); + assert.deepEqual( + await readSkinPreview(archivePath(fixture, NETEASE_4D_SKINS[1])), + { errorCode: 'skin_not_found' } + ); +}); + +interface Fixture { + readonly root: string; + readonly executablePath: string; + readonly vanillaDirectory: string; + readonly catalogPath: string; + readonly supportDirectory: string; +} + +async function createFixture(): Promise { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mcdev-skins-')); + temporaryRoots.push(root); + const versionDirectory = path.join( + root, + 'game', + 'MinecraftPE_Netease', + '3.9.0.0' + ); + const executablePath = path.join(versionDirectory, 'Minecraft.Windows.exe'); + const vanillaDirectory = path.join(versionDirectory, 'data', 'skin_packs', 'vanilla'); + const supportDirectory = path.join(root, 'componentcache', 'support'); + await fs.promises.mkdir(vanillaDirectory, { recursive: true }); + await fs.promises.writeFile(executablePath, ''); + return { + root, + executablePath, + vanillaDirectory, + catalogPath: path.join(vanillaDirectory, 'skins.json'), + supportDirectory + }; +} + +async function writeCatalog(fixture: Fixture, catalog: unknown): Promise { + await fs.promises.writeFile(fixture.catalogPath, JSON.stringify(catalog), 'utf8'); +} + +async function writeTexture(fixture: Fixture, relativePath: string, bytes: Buffer): Promise { + const texturePath = path.join(fixture.vanillaDirectory, ...relativePath.split('/')); + await fs.promises.mkdir(path.dirname(texturePath), { recursive: true }); + await fs.promises.writeFile(texturePath, bytes); +} + +async function writeNetease4DArchive( + fixture: Fixture, + descriptor: Netease4DSkinDescriptor, + previewBytes: Buffer +): Promise { + const targetPath = archivePath(fixture, descriptor); + await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.promises.writeFile(targetPath, zipSync({ + [descriptor.previewEntry]: previewBytes + })); + return targetPath; +} + +function archivePath( + fixture: Fixture, + descriptor: Netease4DSkinDescriptor +): string { + return path.join(fixture.supportDirectory, descriptor.directory, descriptor.archive); +} + +function skin(name: string, texture: string, geometry: string, type: string): object { + return { + localization_name: name, + texture, + geometry, + type + }; +} + +function pngWithTextPayload(payloadLength: number): Buffer { + const iendOffset = validPngBytes.length - 12; + const chunk = Buffer.alloc(payloadLength + 12, 0x61); + chunk.writeUInt32BE(payloadLength, 0); + chunk.write('tEXt', 4, 'ascii'); + chunk[8] = 0x6b; + chunk[9] = 0; + chunk.writeUInt32BE(testCrc32(chunk, 4, payloadLength + 8), payloadLength + 8); + return Buffer.concat([ + validPngBytes.subarray(0, iendOffset), + chunk, + validPngBytes.subarray(iendOffset) + ]); +} + +function testCrc32(bytes: Buffer, start: number, end: number): number { + let crc = 0xffffffff; + for (let index = start; index < end; index += 1) { + crc ^= bytes[index]; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; + } + } + return (crc ^ 0xffffffff) >>> 0; +} diff --git a/src/skins/vanillaSkins.ts b/src/skins/vanillaSkins.ts new file mode 100644 index 0000000..b505c0c --- /dev/null +++ b/src/skins/vanillaSkins.ts @@ -0,0 +1,649 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { unzipSync } from 'fflate'; +import { + SkinPreviewErrorCode, + VanillaSkin, + VanillaSkinsErrorCode +} from '../types'; + +export interface VanillaSkinsResult { + readonly resolvedGameExecutablePath?: string; + readonly skins: readonly VanillaSkin[]; + readonly errorCode?: VanillaSkinsErrorCode; +} + +export interface SkinPreviewResult { + readonly previewUri?: string; + readonly errorCode?: SkinPreviewErrorCode; +} + +interface SkinCatalogEntry { + readonly localization_name?: unknown; + readonly geometry?: unknown; + readonly texture?: unknown; + readonly type?: unknown; +} + +export const MAX_SKIN_PREVIEW_BYTES = 8 * 1024 * 1024; +export const MAX_SKIN_CATALOG_BYTES = 1024 * 1024; +export const MAX_SKIN_CATALOG_ENTRIES = 256; +export const MAX_VANILLA_TEXTURE_READ_BYTES = 16 * 1024 * 1024; +export const MAX_NETEASE_4D_ARCHIVE_BYTES = 2 * 1024 * 1024; + +export interface Netease4DSkinDescriptor { + readonly id: string; + readonly name: string; + readonly directory: string; + readonly archive: string; + readonly previewEntry: string; +} + +export const NETEASE_4D_SKINS: readonly Netease4DSkinDescriptor[] = [ + { + id: 'netease4d:4680777111329095206', + name: '4D测试皮肤', + directory: 'v2025s5_ceshi', + archive: '4680777111329095206.zip', + previewEntry: 'v2025s5_ceshi/v2025s5_ceshi.png' + }, + { + id: 'netease4d:4674169033125275215', + name: '黑洞之心', + directory: 'v2024s4_cs_s2blackhole_skin', + archive: '4674169033125275215.zip', + previewEntry: 'v2024s4_cs_s2blackhole_skin/v2024s4_cs_s2blackhole_skin.png' + }, + { + id: 'netease4d:4682037015921590802', + name: '4D云霄战姬', + directory: 'xy_s6airplane_skin', + archive: '4682037015921590802.zip', + previewEntry: 'xy_s6airplane_skin/xy_s6airplane_skin.png' + }, + { + id: 'netease4d:4673037477981775941', + name: '4D学生晴音', + directory: 'xvniouxianga', + archive: '4673037477981775941.zip', + previewEntry: 'xvniouxianga/xvniouxianga.png' + } +]; + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const PNG_CHUNK_NAME = /^[A-Za-z]{4}$/; +const CATALOG_READ_BUDGET_EXCEEDED = 'catalog_read_budget_exceeded'; +const BOUNDED_READ_CHUNK_BYTES = 64 * 1024; +const CRC32_TABLE = Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; + } + return crc >>> 0; +}); + +interface CatalogEntryReadResult { + readonly bytesRead: number; + readonly skin?: VanillaSkin; +} + +type CatalogEntryResult = + | CatalogEntryReadResult + | typeof CATALOG_READ_BUDGET_EXCEEDED + | undefined; + +type BoundedFileReadResult = + | { readonly status: 'ready'; readonly bytes: Buffer } + | { readonly status: 'not_file' } + | { readonly status: 'too_large'; readonly observedBytes: number }; + +/** + * Resolves the requested executable when it is valid, otherwise the newest + * discovered executable (the discovery API returns newest first). + */ +export async function resolveGameExecutablePath( + requestedPath: unknown, + discoveredPaths: readonly string[] = [] +): Promise { + const requested = await validExecutablePath(requestedPath); + if (requested) { + return requested; + } + for (const discoveredPath of discoveredPaths) { + const discovered = await validExecutablePath(discoveredPath); + if (discovered) { + return discovered; + } + } + return undefined; +} + +export async function loadVanillaSkins( + gameExecutablePath: string +): Promise { + const resolvedGameExecutablePath = await validExecutablePath(gameExecutablePath); + if (!resolvedGameExecutablePath) { + return failure('game_not_found'); + } + + const vanillaDirectory = path.join( + path.dirname(resolvedGameExecutablePath), + 'data', + 'skin_packs', + 'vanilla' + ); + const catalogPath = path.join(vanillaDirectory, 'skins.json'); + + let catalogBytes: Buffer; + try { + const catalogRead = await readBoundedFile(catalogPath, MAX_SKIN_CATALOG_BYTES); + if (catalogRead.status !== 'ready') { + return failure('catalog_invalid', resolvedGameExecutablePath); + } + catalogBytes = catalogRead.bytes; + } catch (error) { + return failure(isMissingFileError(error) ? 'catalog_not_found' : 'catalog_invalid', resolvedGameExecutablePath); + } + + let entries: SkinCatalogEntry[]; + try { + const catalogText = catalogBytes.toString('utf8'); + const textWithoutBom = catalogText.charCodeAt(0) === 0xfeff + ? catalogText.slice(1) + : catalogText; + const parsed: unknown = JSON.parse(textWithoutBom); + if ( + !isObject(parsed) + || !Array.isArray(parsed.skins) + || parsed.skins.length > MAX_SKIN_CATALOG_ENTRIES + ) { + return failure('catalog_invalid', resolvedGameExecutablePath); + } + entries = parsed.skins as SkinCatalogEntry[]; + } catch { + return failure('catalog_invalid', resolvedGameExecutablePath); + } + + let realVanillaDirectory: string; + try { + realVanillaDirectory = await fs.promises.realpath(vanillaDirectory); + } catch { + return failure('catalog_invalid', resolvedGameExecutablePath); + } + + const skins: VanillaSkin[] = []; + const seenIds = new Set(); + let remainingTextureReadBytes = MAX_VANILLA_TEXTURE_READ_BYTES; + for (const entry of entries) { + const loaded = await loadCatalogEntry( + entry, + vanillaDirectory, + realVanillaDirectory, + seenIds, + remainingTextureReadBytes + ); + if (loaded === CATALOG_READ_BUDGET_EXCEEDED) { + return failure('catalog_invalid', resolvedGameExecutablePath); + } + if (!loaded) { + continue; + } + remainingTextureReadBytes -= loaded.bytesRead; + if (!loaded.skin) { + continue; + } + skins.push(loaded.skin); + } + + if (skins.length === 0) { + return failure('no_skins', resolvedGameExecutablePath); + } + + const netease4DSkins = await loadNetease4DSkins(resolvedGameExecutablePath); + return { + resolvedGameExecutablePath, + skins: [...skins, ...netease4DSkins] + }; +} + +export async function readSkinPreview(filePath: string): Promise { + const extension = path.extname(filePath).toLocaleLowerCase('en-US'); + if (extension === '.zip') { + return readNetease4DPreview(filePath); + } + if (extension !== '.png') { + return { errorCode: 'skin_not_png' }; + } + + try { + const fileRead = await readBoundedFile(filePath, MAX_SKIN_PREVIEW_BYTES); + if (fileRead.status === 'not_file') { + return { errorCode: 'skin_not_found' }; + } + if (fileRead.status === 'too_large') { + return { errorCode: 'skin_too_large' }; + } + if (!isPng(fileRead.bytes)) { + return { errorCode: 'skin_not_png' }; + } + return { previewUri: pngDataUri(fileRead.bytes) }; + } catch (error) { + return { + errorCode: isMissingFileError(error) ? 'skin_not_found' : 'skin_unreadable' + }; + } +} + +async function loadCatalogEntry( + value: SkinCatalogEntry, + vanillaDirectory: string, + realVanillaDirectory: string, + seenIds: Set, + remainingTextureReadBytes: number +): Promise { + if ( + !isObject(value) + || value.type !== 'free' + || typeof value.localization_name !== 'string' + || value.localization_name.trim().length === 0 + || typeof value.texture !== 'string' + || value.texture.trim().length === 0 + || ( + value.geometry !== 'geometry.humanoid.custom' + && value.geometry !== 'geometry.humanoid.customSlim' + ) + ) { + return undefined; + } + + let candidatePath: string; + try { + candidatePath = path.resolve(vanillaDirectory, value.texture); + } catch { + return undefined; + } + if ( + path.extname(candidatePath).toLocaleLowerCase('en-US') !== '.png' + || !isContainedPath(vanillaDirectory, candidatePath) + ) { + return undefined; + } + + const texture = path.relative(vanillaDirectory, candidatePath).split(path.sep).join('/'); + const comparisonId = texture.toLocaleLowerCase('en-US'); + if (seenIds.has(comparisonId)) { + return undefined; + } + + let realCandidatePath: string; + try { + realCandidatePath = await fs.promises.realpath(candidatePath); + } catch { + return undefined; + } + if (!isContainedPath(realVanillaDirectory, realCandidatePath)) { + return undefined; + } + seenIds.add(comparisonId); + + const readLimit = Math.min(MAX_SKIN_PREVIEW_BYTES, remainingTextureReadBytes); + let fileRead: BoundedFileReadResult; + try { + fileRead = await readBoundedFile(realCandidatePath, readLimit); + } catch { + return undefined; + } + if (fileRead.status === 'not_file') { + return undefined; + } + if (fileRead.status === 'too_large') { + if (fileRead.observedBytes > MAX_SKIN_PREVIEW_BYTES) { + return undefined; + } + return CATALOG_READ_BUDGET_EXCEEDED; + } + if (!isPng(fileRead.bytes)) { + return { bytesRead: fileRead.bytes.length }; + } + + return { + bytesRead: fileRead.bytes.length, + skin: { + id: texture, + name: value.localization_name, + texture, + path: candidatePath, + slim: value.geometry === 'geometry.humanoid.customSlim', + previewUri: pngDataUri(fileRead.bytes), + kind: 'vanilla' + } + }; +} + +async function loadNetease4DSkins( + gameExecutablePath: string +): Promise { + const supportDirectory = path.resolve( + path.dirname(gameExecutablePath), + '..', + '..', + '..', + 'componentcache', + 'support' + ); + + let realSupportDirectory: string; + try { + realSupportDirectory = await fs.promises.realpath(supportDirectory); + } catch { + return []; + } + + const skins: VanillaSkin[] = []; + for (const descriptor of NETEASE_4D_SKINS) { + const archivePath = path.join( + supportDirectory, + descriptor.directory, + descriptor.archive + ); + if (!isContainedPath(supportDirectory, archivePath)) { + continue; + } + + let realArchivePath: string; + try { + realArchivePath = await fs.promises.realpath(archivePath); + } catch { + continue; + } + if (!isContainedPath(realSupportDirectory, realArchivePath)) { + continue; + } + + try { + const archiveRead = await readBoundedFile( + realArchivePath, + MAX_NETEASE_4D_ARCHIVE_BYTES + ); + if (archiveRead.status !== 'ready') { + continue; + } + const preview = extractNetease4DPreview(archiveRead.bytes, descriptor); + if (!preview) { + continue; + } + skins.push({ + id: descriptor.id, + name: descriptor.name, + texture: `${descriptor.directory}/${descriptor.archive}`, + path: archivePath, + slim: false, + previewUri: pngDataUri(preview), + kind: 'netease4d' + }); + } catch { + continue; + } + } + return skins; +} + +async function readNetease4DPreview(filePath: string): Promise { + const descriptor = matchingNetease4DDescriptor(filePath); + if (!descriptor) { + return { errorCode: 'skin_not_png' }; + } + + try { + const archiveRead = await readBoundedFile( + filePath, + MAX_NETEASE_4D_ARCHIVE_BYTES + ); + if (archiveRead.status === 'not_file') { + return { errorCode: 'skin_not_found' }; + } + if (archiveRead.status === 'too_large') { + return { errorCode: 'skin_too_large' }; + } + const preview = extractNetease4DPreview(archiveRead.bytes, descriptor); + return preview + ? { previewUri: pngDataUri(preview) } + : { errorCode: 'skin_unreadable' }; + } catch (error) { + return { + errorCode: isMissingFileError(error) ? 'skin_not_found' : 'skin_unreadable' + }; + } +} + +function matchingNetease4DDescriptor( + filePath: string +): Netease4DSkinDescriptor | undefined { + const archive = path.basename(filePath).toLocaleLowerCase('en-US'); + const directory = path.basename(path.dirname(filePath)).toLocaleLowerCase('en-US'); + return NETEASE_4D_SKINS.find(descriptor => ( + descriptor.archive.toLocaleLowerCase('en-US') === archive + && descriptor.directory.toLocaleLowerCase('en-US') === directory + )); +} + +function extractNetease4DPreview( + archiveBytes: Buffer, + descriptor: Netease4DSkinDescriptor +): Buffer | undefined { + const expectedEntry = normalizedZipEntry(descriptor.previewEntry); + let matchingEntries = 0; + let files: Record; + try { + files = unzipSync(archiveBytes, { + filter: file => { + if (normalizedZipEntry(file.name) !== expectedEntry) { + return false; + } + matchingEntries += 1; + return matchingEntries === 1 + && file.originalSize <= MAX_SKIN_PREVIEW_BYTES; + } + }); + } catch { + return undefined; + } + if (matchingEntries !== 1) { + return undefined; + } + + const entry = Object.entries(files).find(([name]) => ( + normalizedZipEntry(name) === expectedEntry + )); + if (!entry) { + return undefined; + } + const preview = Buffer.from(entry[1]); + return preview.length <= MAX_SKIN_PREVIEW_BYTES && isPng(preview) + ? preview + : undefined; +} + +function normalizedZipEntry(value: string): string { + return value.replace(/\\/g, '/').replace(/^\.\/+/, ''); +} + +async function readBoundedFile( + filePath: string, + maxBytes: number +): Promise { + const handle = await fs.promises.open(filePath, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + return { status: 'not_file' }; + } + if (stat.size > maxBytes) { + return { status: 'too_large', observedBytes: stat.size }; + } + + const chunks: Buffer[] = []; + let totalBytes = 0; + while (totalBytes <= maxBytes) { + const readSize = Math.min( + BOUNDED_READ_CHUNK_BYTES, + maxBytes + 1 - totalBytes + ); + const chunk = Buffer.allocUnsafe(readSize); + const { bytesRead } = await handle.read( + chunk, + 0, + readSize, + totalBytes + ); + if (bytesRead === 0) { + break; + } + totalBytes += bytesRead; + if (totalBytes > maxBytes) { + return { status: 'too_large', observedBytes: totalBytes }; + } + chunks.push(bytesRead === readSize ? chunk : chunk.subarray(0, bytesRead)); + } + return { + status: 'ready', + bytes: Buffer.concat(chunks, totalBytes) + }; + } finally { + await handle.close(); + } +} + +async function validExecutablePath(value: unknown): Promise { + if (typeof value !== 'string' || value.trim().length === 0) { + return undefined; + } + const resolved = path.resolve(value.trim()); + if (path.extname(resolved).toLocaleLowerCase('en-US') !== '.exe') { + return undefined; + } + try { + return (await fs.promises.stat(resolved)).isFile() ? resolved : undefined; + } catch { + return undefined; + } +} + +function isContainedPath(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative.length > 0 + && relative !== '..' + && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative); +} + +function pngDataUri(bytes: Buffer): string { + return `data:image/png;base64,${bytes.toString('base64')}`; +} + +function isPng(bytes: Buffer): boolean { + if (bytes.length < 33 || !bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { + return false; + } + + let offset = PNG_SIGNATURE.length; + let chunkIndex = 0; + let sawIdat = false; + while (offset < bytes.length) { + if (bytes.length - offset < 12) { + return false; + } + + const dataLength = bytes.readUInt32BE(offset); + const typeOffset = offset + 4; + const dataOffset = typeOffset + 4; + const crcOffset = dataOffset + dataLength; + const nextOffset = crcOffset + 4; + if (nextOffset > bytes.length) { + return false; + } + + const chunkType = bytes.toString('ascii', typeOffset, dataOffset); + if ( + !PNG_CHUNK_NAME.test(chunkType) + || crc32(bytes, typeOffset, crcOffset) !== bytes.readUInt32BE(crcOffset) + ) { + return false; + } + + if (chunkIndex === 0) { + if ( + chunkType !== 'IHDR' + || dataLength !== 13 + || bytes.readUInt32BE(dataOffset) === 0 + || bytes.readUInt32BE(dataOffset + 4) === 0 + || !isValidPngColorFormat( + bytes[dataOffset + 8], + bytes[dataOffset + 9] + ) + || bytes[dataOffset + 10] !== 0 + || bytes[dataOffset + 11] !== 0 + || bytes[dataOffset + 12] > 1 + ) { + return false; + } + } else if (chunkType === 'IHDR') { + return false; + } + + if (chunkType === 'IDAT') { + sawIdat = true; + } else if (chunkType === 'IEND') { + return dataLength === 0 && sawIdat && nextOffset === bytes.length; + } + + offset = nextOffset; + chunkIndex += 1; + } + return false; +} + +function isValidPngColorFormat(bitDepth: number, colorType: number): boolean { + switch (colorType) { + case 0: + return bitDepth === 1 + || bitDepth === 2 + || bitDepth === 4 + || bitDepth === 8 + || bitDepth === 16; + case 2: + case 4: + case 6: + return bitDepth === 8 || bitDepth === 16; + case 3: + return bitDepth === 1 || bitDepth === 2 || bitDepth === 4 || bitDepth === 8; + default: + return false; + } +} + +function crc32(bytes: Buffer, start: number, end: number): number { + let crc = 0xffffffff; + for (let index = start; index < end; index += 1) { + crc = CRC32_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function failure( + errorCode: VanillaSkinsErrorCode, + resolvedGameExecutablePath?: string +): VanillaSkinsResult { + return { resolvedGameExecutablePath, skins: [], errorCode }; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isMissingFileError(error: unknown): boolean { + if (!isObject(error) || typeof error.code !== 'string') { + return false; + } + return error.code === 'ENOENT' || error.code === 'ENOTDIR'; +} diff --git a/src/types/index.ts b/src/types/index.ts index 13522d9..822aa78 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -30,6 +30,7 @@ export interface ModDirConfig { /** .mcdev.json 配置结构 */ export interface McdevConfig { + game_executable_path?: string; included_mod_dirs?: (string | ModDirConfig)[]; log_protocol?: 0 | 1; world_name?: string; @@ -80,3 +81,30 @@ export interface McdevConfig { }; [key: string]: unknown; } + +export interface VanillaSkin { + readonly id: string; + readonly name: string; + readonly texture: string; + readonly path: string; + readonly slim: boolean; + readonly previewUri: string; + readonly kind: 'vanilla' | 'netease4d'; +} + +export interface CustomSkin { + readonly path: string; + readonly slim: boolean; +} + +export type VanillaSkinsErrorCode = + | 'game_not_found' + | 'catalog_not_found' + | 'catalog_invalid' + | 'no_skins'; + +export type SkinPreviewErrorCode = + | 'skin_not_found' + | 'skin_not_png' + | 'skin_too_large' + | 'skin_unreadable'; diff --git a/webview/src/App.css b/webview/src/App.css index bcbdf09..7885d95 100644 --- a/webview/src/App.css +++ b/webview/src/App.css @@ -831,11 +831,16 @@ body.vscode-high-contrast .control-group input[type="number"] { transition: color 120ms ease, transform 150ms ease; } -.number-select-trigger:hover { +.number-select-trigger:not(:disabled):hover { border-color: color-mix(in srgb, var(--vscode-focusBorder) 58%, var(--vscode-dropdown-border)); background-color: color-mix(in srgb, var(--vscode-dropdown-background) 88%, var(--vscode-list-hoverBackground)); } +.number-select-trigger:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .number-select.open .number-select-trigger, .number-select-trigger:focus-visible { outline: none; @@ -2915,86 +2920,926 @@ button.mod-review-status:disabled { object-fit: contain; } -@media (max-width: 420px) { - :root { - --container-padding: 12px; - } +.skin-options { + --skin-accent: var(--vscode-charts-blue, var(--vscode-textLink-foreground)); +} - .section { - padding-top: 18px; - } +.skin-picker input[readonly] { + cursor: pointer; +} - .checkbox-grid, - .mcp-config-grid { - grid-template-columns: minmax(0, 1fr); - } +.skin-picker-menu { + max-height: 320px; +} - .section-header-plain { - align-items: flex-start; - } +.skin-picker-group-label { + min-height: 25px; + padding: 6px 8px 4px; + display: flex; + align-items: center; + color: var(--vscode-descriptionForeground); + font-size: 9px; + font-weight: 600; + letter-spacing: 0; +} - .mcp-bridge-btn { - max-width: 44%; - } +.skin-picker-group-label.netease4d, +.skin-picker-group-label.custom { + margin-top: 4px; + border-top: 1px solid var(--vscode-dropdown-border, var(--panel-border)); +} - .mod-options { - gap: 8px; - } +.skin-picker-option-row { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 30px; + align-items: stretch; +} - .review-output-path { - padding-left: 0; - border-left: 0; - } +.skin-picker-option-row:not(:has(.skin-picker-remove)) .game-path-option { + grid-column: 1 / -1; +} - .mod-options { - flex-wrap: wrap; - } +.skin-picker-remove { + width: 28px; + min-height: 32px; + margin: 3px 1px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + align-self: center; + color: var(--vscode-descriptionForeground); + background: transparent; + border: 0; + border-radius: 4px; + cursor: pointer; +} - .review-results-header { - align-items: flex-start; - flex-direction: column; - gap: 4px; - } +.skin-picker-remove:hover { + color: var(--vscode-errorForeground); + background-color: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground)); +} - .review-result-row { - grid-template-columns: minmax(0, 1fr) 30px; - } +.skin-picker-remove .codicon { + font-size: 13px; +} - .review-result-row .mod-review-status { - grid-column: 1; - width: max-content; - } +.skin-picker-empty { + min-height: 42px; +} - .review-result-row .btn-icon { - grid-column: 2; - grid-row: 1 / span 2; - } +.skin-catalog-error { + display: flex; + align-items: flex-start; + gap: 6px; + margin-top: 5px; + color: var(--vscode-editorWarning-foreground, #cca700); + font-size: 10px; + line-height: 1.4; +} - .review-launcher-footer { - align-items: stretch; - flex-direction: column; - } +.skin-catalog-error .codicon { + margin-top: 1px; + font-size: 12px; +} - .review-launcher-footer .btn-primary { - width: 100%; - } +.skin-model-control { + margin-bottom: 12px; } -@media (max-width: 300px) { - .section-header-plain { - flex-wrap: wrap; - } +.skin-warning, +.skin-preview-error { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + align-items: start; + gap: 7px; + padding: 8px 9px; + color: var(--vscode-editorWarning-foreground, #cca700); + font-size: 10px; + line-height: 1.45; +} - .mcp-bridge-btn { - max-width: 100%; - } +.skin-warning { + margin-bottom: 10px; + border: 1px solid color-mix(in srgb, currentColor 38%, var(--panel-border)); + border-radius: var(--panel-radius); + background-color: color-mix(in srgb, currentColor 6%, transparent); +} - .mod-options { - flex-wrap: wrap; - } +.skin-warning .codicon, +.skin-preview-error .codicon { + margin-top: 1px; + font-size: 13px; +} - .mod-options .btn-icon { - margin-left: 0; +.skin-preview-card { + overflow: hidden; + border: 1px solid var(--panel-border); + border-radius: var(--panel-radius); + background-color: color-mix(in srgb, var(--panel-surface) 64%, transparent); +} + +.skin-preview-heading { + min-height: 36px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 9px; + border-bottom: 1px solid var(--panel-border); + color: var(--vscode-foreground); + font-size: 10px; + font-weight: 600; +} + +.skin-source-badge { + min-width: 0; + max-width: 70%; + flex: 0 1 auto; + padding: 2px 6px; + overflow: hidden; + border: 1px solid color-mix(in srgb, currentColor 38%, var(--panel-border)); + border-radius: 999px; + color: var(--skin-accent); + background-color: color-mix(in srgb, currentColor 7%, transparent); + font-size: 9px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skin-source-badge.custom { + color: var(--vscode-charts-purple, #a78bfa); +} + +.skin-source-badge.netease4d { + color: var(--vscode-charts-green, #4ec9b0); +} + +.skin-source-badge.legacy { + color: var(--vscode-editorWarning-foreground, #cca700); +} + +.skin-preview-error { + border-bottom: 1px solid color-mix(in srgb, var(--vscode-errorForeground, #f48771) 35%, var(--panel-border)); + color: var(--vscode-errorForeground, #f48771); + background-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 6%, transparent); +} + +.skin-preview-card .skin-preview-container { + min-height: 132px; + margin-top: 0; + border: 0; + border-radius: 0; + background-color: transparent; +} + +.skin-preview-placeholder { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 7px; + padding: 12px; + color: var(--vscode-descriptionForeground); + font-size: 10px; + text-align: center; +} + +.skin-preview-placeholder .codicon { + font-size: 20px; + opacity: 0.76; +} + +body.vscode-high-contrast .skin-preview-card, +body.vscode-high-contrast .skin-warning { + border-color: var(--vscode-contrastBorder); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.project-kind-badge { + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--managed-accent, var(--vscode-charts-purple, #a78bfa)) 54%, var(--panel-border)); + border-radius: 4px; + color: color-mix(in srgb, var(--managed-accent, var(--vscode-charts-purple, #a78bfa)) 70%, var(--vscode-foreground)); + background-color: color-mix(in srgb, var(--managed-accent, var(--vscode-charts-purple, #a78bfa)) 10%, transparent); + font-size: 9px; + font-weight: 700; + line-height: 1.35; + white-space: nowrap; +} + +.btn-icon.manage { + color: var(--vscode-textLink-foreground); +} + +.btn-icon.manage:hover { + border-color: color-mix(in srgb, var(--vscode-textLink-foreground) 38%, var(--panel-border)); + background-color: color-mix(in srgb, var(--vscode-textLink-foreground) 9%, transparent); +} + +.mod-options > .btn-icon.manage { + margin-left: auto; +} + +.mod-options > .btn-icon.manage + .btn-icon.delete { + margin-left: 0; +} + +.managed-mod-page { + --managed-accent: var(--vscode-charts-purple, #a78bfa); + min-width: 0; +} + +.managed-mod-page button:disabled, +.managed-mod-page select:disabled, +.managed-mod-page textarea:disabled { + cursor: default; + opacity: 0.48; + box-shadow: none; + transform: none; +} + +.managed-mod-toolbar { + position: sticky; + top: 0; + z-index: 90; + min-height: 50px; + display: grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr) 30px; + align-items: center; + gap: 8px; + margin: 0 calc(-1 * var(--container-padding)); + padding: 9px var(--container-padding); + border-bottom: 1px solid var(--panel-border); + background-color: var(--vscode-sideBar-background); + background-color: color-mix(in srgb, var(--vscode-sideBar-background) 94%, transparent); + backdrop-filter: blur(14px); +} + +.managed-mod-toolbar h1 { + min-width: 0; + margin: 0; + overflow: hidden; + font-size: 13px; + font-weight: 650; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.managed-mod-toolbar h1:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 3px; +} + +.managed-mod-back { + min-width: 0; + min-height: 30px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 7px; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--panel-radius); + color: var(--vscode-foreground); + background: transparent; + font: inherit; + font-size: 11px; + cursor: pointer; +} + +.managed-mod-back:hover { + border-color: var(--panel-border); + background-color: var(--vscode-list-hoverBackground); +} + +.managed-mod-back > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.managed-mod-content { + display: flex; + flex-direction: column; + gap: 14px; + padding-top: 14px; +} + +.managed-mod-summary, +.managed-mod-section, +.managed-mod-preview { + overflow: hidden; + border: 1px solid var(--panel-border); + border-radius: var(--panel-radius); + background-color: color-mix(in srgb, var(--panel-surface) 68%, transparent); +} + +.managed-mod-summary { + border-color: color-mix(in srgb, var(--managed-accent) 34%, var(--panel-border)); + background-color: color-mix(in srgb, var(--managed-accent) 5%, var(--panel-surface)); +} + +.managed-mod-summary-heading { + min-height: 44px; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--panel-border); +} + +.managed-mod-summary-heading h2 { + min-width: 0; + flex: 1 1 auto; + margin: 0; + overflow: hidden; + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.managed-mod-external-badge { + flex: 0 0 auto; + padding: 2px 5px; + border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 48%, var(--panel-border)); + border-radius: 4px; + color: var(--vscode-editorWarning-foreground, #cca700); + font-size: 9px; + font-weight: 650; + white-space: nowrap; +} + +.managed-mod-details { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 0; +} + +.managed-mod-details > div { + min-width: 0; + min-height: 48px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 3px; + padding: 7px 10px; + border-top: 1px solid var(--panel-border); +} + +.managed-mod-details > div:first-child { + border-top: 0; +} + +.managed-mod-details > div:nth-child(n + 4) { + border-left: 1px solid var(--panel-border); +} + +.managed-mod-details .managed-mod-path-detail { + grid-column: 1 / -1; + min-height: 42px; +} + +.managed-mod-details dt { + color: var(--vscode-descriptionForeground); + font-size: 9px; + line-height: 1.3; +} + +.managed-mod-details dd { + min-width: 0; + margin: 0; + overflow: hidden; + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.managed-mod-details code { + color: var(--vscode-foreground); + font-family: var(--vscode-editor-font-family, monospace); + font-size: 10px; + font-weight: 400; +} + +.managed-mod-warning-panel { + padding: 9px; + border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 40%, var(--panel-border)); + border-radius: var(--panel-radius); + background-color: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 6%, transparent); +} + +.managed-mod-warning-panel h2 { + margin: 0 0 6px; + color: var(--vscode-editorWarning-foreground, #cca700); + font-size: 10px; + font-weight: 650; +} + +.managed-mod-warning-panel ul { + display: flex; + flex-direction: column; + gap: 5px; + margin: 0; + padding: 0; + list-style: none; +} + +.managed-mod-warning-panel li { + display: grid; + grid-template-columns: 14px minmax(0, 1fr); + align-items: start; + gap: 5px; + color: var(--vscode-editorWarning-foreground, #cca700); + font-size: 10px; + line-height: 1.45; +} + +.managed-mod-warning-panel li.error { + color: var(--vscode-errorForeground, #f48771); +} + +.managed-mod-warning-panel .codicon { + margin-top: 1px; + font-size: 12px; +} + +.managed-mod-status { + min-height: 38px; + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + align-items: start; + gap: 7px; + padding: 8px 9px; + border-left: 2px solid var(--vscode-textLink-foreground); + border-radius: 0 var(--panel-radius) var(--panel-radius) 0; + color: var(--vscode-descriptionForeground); + background-color: color-mix(in srgb, var(--panel-surface) 72%, transparent); + font-size: 10px; + line-height: 1.45; +} + +.managed-mod-status.error { + border-left-color: var(--vscode-errorForeground, #f48771); + color: var(--vscode-errorForeground, #f48771); +} + +.managed-mod-status > .codicon { + margin-top: 1px; + font-size: 13px; +} + +.managed-mod-status > span:last-child { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.managed-mod-status strong { + font-weight: 600; +} + +.managed-mod-status small { + overflow-wrap: anywhere; + color: var(--vscode-descriptionForeground); + font-size: 9px; +} + +.managed-mod-status:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.managed-mod-section-heading, +.managed-mod-preview-heading { + min-height: 42px; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--panel-border); +} + +.managed-mod-section-heading > .codicon, +.managed-mod-preview-heading > div:first-child > .codicon { + color: var(--managed-accent); + font-size: 14px; +} + +.managed-mod-section-heading h2, +.managed-mod-preview-heading h2 { + margin: 0; + color: var(--vscode-foreground); + font-size: 11px; + font-weight: 650; +} + +.managed-mod-section-heading > span:last-child { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.managed-mod-section-heading small { + color: var(--vscode-descriptionForeground); + font-size: 9px; + line-height: 1.4; +} + +.managed-mod-action-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + padding: 9px; +} + +.managed-mod-quick-changes { + position: relative; + overflow: visible; +} + +.managed-mod-quick-changes:has(.number-select.open) { + z-index: 80; +} + +.managed-mod-action-card { + min-width: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 10px; + padding: 9px; + border: 1px solid var(--panel-border); + border-radius: var(--panel-radius); + background-color: color-mix(in srgb, var(--panel-surface) 72%, transparent); +} + +.managed-mod-action-card h3 { + margin: 0 0 3px; + font-size: 10px; + font-weight: 650; +} + +.managed-mod-action-card > .btn-secondary { + width: 100%; +} + +.managed-mod-version-controls { + display: grid; + grid-template-columns: minmax(72px, 0.65fr) minmax(0, 1.35fr); + gap: 5px; +} + +.managed-mod-version-controls .number-select { + min-width: 0; +} + +.managed-mod-version-controls .btn-secondary { + min-width: 0; + padding-inline: 7px; +} + +.managed-mod-preview { + border-color: color-mix(in srgb, var(--managed-accent) 42%, var(--panel-border)); +} + +.managed-mod-preview-heading { + justify-content: space-between; +} + +.managed-mod-preview-heading > div:first-child { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +.managed-mod-preview-counts { + display: flex; + align-items: center; + gap: 5px; + font-size: 9px; +} + +.managed-mod-preview-counts span { + padding: 2px 5px; + border-radius: 4px; + white-space: nowrap; +} + +.managed-mod-preview-counts .viewed { + color: var(--vscode-testing-iconPassed, #73c991); + background-color: color-mix(in srgb, currentColor 10%, transparent); +} + +.managed-mod-preview-counts .unviewed { + color: var(--vscode-editorWarning-foreground, #cca700); + background-color: color-mix(in srgb, currentColor 10%, transparent); +} + +.managed-mod-preview-files { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; +} + +.managed-mod-preview-file { + width: 100%; + min-width: 0; + min-height: 34px; + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 6px 7px; + border: 1px solid var(--panel-border); + border-radius: var(--panel-radius); + color: var(--vscode-foreground); + background-color: color-mix(in srgb, var(--panel-surface) 65%, transparent); + text-align: left; + cursor: pointer; +} + +.managed-mod-preview-file:hover { + border-color: var(--vscode-focusBorder); + background-color: var(--vscode-list-hoverBackground); +} + +.managed-mod-preview-file > .codicon { + color: var(--vscode-editorWarning-foreground, #cca700); + font-size: 13px; +} + +.managed-mod-preview-file.viewed > .codicon { + color: var(--vscode-testing-iconPassed, #73c991); +} + +.managed-mod-preview-file code { + min-width: 0; + overflow: hidden; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.managed-mod-preview-file > span:last-child { + color: var(--vscode-descriptionForeground); + font-size: 9px; + white-space: nowrap; +} + +.managed-mod-preview-actions, +.managed-mod-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.managed-mod-preview-actions { + padding: 0 8px 8px; +} + +.managed-mod-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background-color: rgba(0, 0, 0, 0.46); + backdrop-filter: blur(2px); +} + +.managed-mod-dialog { + width: min(100%, 420px); + max-height: calc(100vh - 32px); + overflow: auto; + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + gap: 7px 8px; + padding: 13px; + border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 54%, var(--panel-border)); + border-radius: var(--panel-radius); + color: var(--vscode-foreground); + background-color: var(--vscode-sideBar-background); + box-shadow: 0 16px 44px rgba(0, 0, 0, 0.42); +} + +.managed-mod-dialog > .codicon { + grid-row: 1 / span 2; + margin-top: 1px; + color: var(--vscode-editorWarning-foreground, #cca700); + font-size: 16px; +} + +.managed-mod-dialog h2 { + margin: 0; + font-size: 12px; + font-weight: 650; +} + +.managed-mod-dialog p { + grid-column: 2; + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 10px; + line-height: 1.5; +} + +.managed-mod-dialog p.managed-mod-dialog-blocker { + padding: 5px 6px; + border-left: 2px solid var(--vscode-errorForeground, #f48771); + color: var(--vscode-errorForeground, #f48771); + background-color: color-mix(in srgb, currentColor 6%, transparent); +} + +.managed-mod-dialog > code { + grid-column: 2; + overflow: hidden; + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-editor-font-family, monospace); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.managed-mod-dialog-actions { + grid-column: 1 / -1; + margin-top: 5px; +} + +@media (max-width: 420px) { + :root { + --container-padding: 12px; + } + + .section { + padding-top: 18px; + } + + .checkbox-grid, + .mcp-config-grid { + grid-template-columns: minmax(0, 1fr); + } + + .section-header-plain { + align-items: flex-start; + } + + .mcp-bridge-btn { + max-width: 44%; + } + + .mod-options { + gap: 8px; + } + + .review-output-path { + padding-left: 0; + border-left: 0; + } + + .mod-options { + flex-wrap: wrap; + } + + .review-results-header { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + + .review-result-row { + grid-template-columns: minmax(0, 1fr) 30px; + } + + .review-result-row .mod-review-status { + grid-column: 1; + width: max-content; + } + + .review-result-row .btn-icon { + grid-column: 2; + grid-row: 1 / span 2; + } + + .review-launcher-footer { + align-items: stretch; + flex-direction: column; + } + + .review-launcher-footer .btn-primary { + width: 100%; + } + + .managed-mod-action-grid { + grid-template-columns: minmax(0, 1fr); + } + + .managed-mod-summary-heading { + flex-wrap: wrap; + } + + .managed-mod-summary-heading h2 { + flex-basis: calc(100% - 78px); + } + + .managed-mod-external-badge { + margin-left: 38px; + } +} + +@media (max-width: 300px) { + .section-header-plain { + flex-wrap: wrap; + } + + .mcp-bridge-btn { + max-width: 100%; + } + + .mod-options { + flex-wrap: wrap; + } + + .mod-options .btn-icon { + margin-left: 0; + } + + .project-kind-badge { + width: max-content; + } + + .managed-mod-toolbar { + grid-template-columns: 30px minmax(0, 1fr) 30px; + } + + .managed-mod-back { + width: 30px; + padding: 0; + justify-content: center; + } + + .managed-mod-back > span:last-child { + display: none; + } + + .managed-mod-details { + grid-template-columns: minmax(0, 1fr); + } + + .managed-mod-details > div, + .managed-mod-details > div:nth-child(-n + 2) { + grid-column: 1; + border-top: 1px solid var(--panel-border); + border-left: 0; + } + + .managed-mod-details > div:first-child { + border-top: 0; + } + + .managed-mod-version-controls { + grid-template-columns: minmax(0, 1fr); + } + + .managed-mod-preview-heading { + align-items: flex-start; + flex-direction: column; + } + + .managed-mod-preview-file { + grid-template-columns: 16px minmax(0, 1fr); + } + + .managed-mod-preview-file > span:last-child { + grid-column: 2; + } + + .managed-mod-preview-actions, + .managed-mod-dialog-actions { + flex-direction: column; } } diff --git a/webview/src/App.tsx b/webview/src/App.tsx index a66bf10..51cd342 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -1,42 +1,230 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { vscode } from './vscode'; import { i18n } from './i18n'; -import { ModDir, McdevData } from './types'; +import { + CustomSkin, + ModDir, + McdevData, + SkinPreviewErrorCode, + SkinPreviewState, + VanillaSkin, + VanillaSkinCatalogErrorCode, + VanillaSkinCatalogState, +} from './types'; import { ModDirectories } from './components/ModDirectories'; +import { ManagedMod } from './components/ManagedMod'; import { WorldSettings } from './components/WorldSettings'; import { GameOptions } from './components/GameOptions'; import { UserSettings } from './components/UserSettings'; import { WindowStyle } from './components/WindowStyle'; -import { SkinOptions } from './components/SkinOptions'; +import { matchingLegacyVanillaSkin, SkinOptions } from './components/SkinOptions'; import { DebugKeybindings } from './components/DebugKeybindings'; import { LauncherSettings } from './components/LauncherSettings'; import { McpServerConfig } from './components/McpServerConfig'; import { AssistantMcpLink } from './components/AssistantMcpLink'; import './App.css'; +const VANILLA_CATALOG_DEBOUNCE_MS = 300; +const CUSTOM_PREVIEW_DEBOUNCE_MS = 180; +const MAX_CUSTOM_SKINS = 64; + +const isVanillaCatalogErrorCode = (value: unknown): value is VanillaSkinCatalogErrorCode => ( + value === 'game_not_found' + || value === 'catalog_not_found' + || value === 'catalog_invalid' + || value === 'no_skins' +); + +const isSkinPreviewErrorCode = (value: unknown): value is SkinPreviewErrorCode => ( + value === 'skin_not_found' + || value === 'skin_not_png' + || value === 'skin_too_large' + || value === 'skin_unreadable' +); + +const normalizedSkinPath = (value: string): string => value + .trim() + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .toLocaleLowerCase(); + +const skinPathsEqual = (left: string, right: string): boolean => ( + normalizedSkinPath(left) === normalizedSkinPath(right) +); + +const parseVanillaSkins = (value: unknown): VanillaSkin[] => { + if (!Array.isArray(value)) return []; + const skins: VanillaSkin[] = []; + for (const candidate of value) { + if (!candidate || typeof candidate !== 'object') continue; + const skin = candidate as Partial; + if (!(typeof skin.id === 'string' + && typeof skin.name === 'string' + && typeof skin.texture === 'string' + && typeof skin.path === 'string' + && typeof skin.slim === 'boolean' + && typeof skin.previewUri === 'string')) { + continue; + } + skins.push({ + id: skin.id, + name: skin.name, + texture: skin.texture, + path: skin.path, + slim: skin.slim, + previewUri: skin.previewUri, + kind: skin.kind === 'netease4d' ? 'netease4d' : 'vanilla', + }); + } + return skins; +}; + +const parseCustomSkins = (value: unknown): CustomSkin[] => { + if (!Array.isArray(value)) return []; + const skins: CustomSkin[] = []; + const seenPaths = new Set(); + for (const candidate of value) { + if (skins.length >= MAX_CUSTOM_SKINS) break; + if (!candidate || typeof candidate !== 'object') continue; + const skin = candidate as Partial; + if (typeof skin.path !== 'string' || typeof skin.slim !== 'boolean') continue; + const skinPath = skin.path.trim(); + const comparisonPath = normalizedSkinPath(skinPath); + if (!skinPath || seenPaths.has(comparisonPath)) continue; + seenPaths.add(comparisonPath); + skins.push({ path: skinPath, slim: skin.slim }); + } + return skins; +}; + +const withCustomSkin = (skins: CustomSkin[], skin: CustomSkin): CustomSkin[] => { + const existingIndex = skins.findIndex(candidate => skinPathsEqual(candidate.path, skin.path)); + if (existingIndex >= 0) { + return skins.map((candidate, index) => index === existingIndex ? skin : candidate); + } + return skins.length < MAX_CUSTOM_SKINS ? [...skins, skin] : skins; +}; + function App() { const [lang, setLang] = useState('en'); const t = i18n[lang] || i18n.en; const [data, setData] = useState({}); const [modDirs, setModDirs] = useState([{ path: './', hot_reload: true, enabled: true }]); const [hasChanges, setHasChanges] = useState(false); + const [savePending, setSavePending] = useState(false); const [debugExpanded, setDebugExpanded] = useState(false); const [activeKeyListener, setActiveKeyListener] = useState(null); const [needsAutoSave, setNeedsAutoSave] = useState(false); - const [skinPreviewUrl, setSkinPreviewUrl] = useState(null); + const [configurationLoaded, setConfigurationLoaded] = useState(false); + const [configurationRevision, setConfigurationRevision] = useState(0); + const [vanillaSkinCatalog, setVanillaSkinCatalog] = useState({ + status: 'idle', + skins: [], + }); + const [customSkins, setCustomSkins] = useState([]); + const [customSkinPreview, setCustomSkinPreview] = useState({ status: 'idle' }); const [gameExecutableDiscoverySupported, setGameExecutableDiscoverySupported] = useState(false); const [gameExecutableDiscoveryLoaded, setGameExecutableDiscoveryLoaded] = useState(false); const [gameExecutablePaths, setGameExecutablePaths] = useState([]); + const [managedModIndex, setManagedModIndex] = useState(null); + const [managedModStatePayload, setManagedModStatePayload] = useState(undefined); + const [manageReturnFocusIndex, setManageReturnFocusIndex] = useState(null); const initializedComponentsRef = useRef>(new Set()); const initTimerRef = useRef(null); + const leavingManagedModRef = useRef(false); + const skinRequestSequenceRef = useRef(0); + const latestVanillaCatalogRequestIdRef = useRef(); + const latestSkinPreviewRequestIdRef = useRef(); + const customSkinsRef = useRef([]); + const localEditRevisionRef = useRef(0); + const configurationLoadedRef = useRef(false); + const configurationDirtyRef = useRef(false); + const lastConfigMessageRevisionRef = useRef(-1); + const saveRequestSequenceRef = useRef(0); + const pendingSaveRef = useRef<{ requestId: string; editRevision: number }>(); + + const markChanged = useCallback(() => { + localEditRevisionRef.current += 1; + configurationDirtyRef.current = true; + setHasChanges(true); + }, []); + + const nextSkinRequestId = useCallback((kind: 'catalog' | 'preview'): string => { + skinRequestSequenceRef.current += 1; + return `${kind}-${skinRequestSequenceRef.current}`; + }, []); + + const commitCustomSkins = useCallback((skins: CustomSkin[]) => { + customSkinsRef.current = skins; + setCustomSkins(skins); + vscode.postMessage({ type: 'setCustomSkins', skins }); + }, []); + + const selectCustomSkin = useCallback(( + skin: CustomSkin, + preview?: SkinPreviewState, + ) => { + latestSkinPreviewRequestIdRef.current = undefined; + setData(prev => ({ + ...prev, + skin_info: { + skin: skin.path, + slim: skin.slim, + }, + })); + if (preview) { + setCustomSkinPreview(preview); + } + markChanged(); + }, [markChanged]); + + const addCustomSkin = useCallback(( + skinPath: string, + previewUri?: string, + previewErrorCode?: unknown, + ) => { + const trimmedPath = skinPath.trim(); + if (!trimmedPath) return; + const existing = customSkinsRef.current.find(candidate => ( + skinPathsEqual(candidate.path, trimmedPath) + )); + const skin = existing ?? { path: trimmedPath, slim: false }; + const nextSkins = withCustomSkin(customSkinsRef.current, skin); + commitCustomSkins(nextSkins); + selectCustomSkin( + skin, + isSkinPreviewErrorCode(previewErrorCode) + ? { status: 'error', errorCode: previewErrorCode } + : previewUri + ? { status: 'ready', previewUri } + : { status: 'loading' }, + ); + }, [commitCustomSkins, selectCustomSkin]); // Handle messages from extension useEffect(() => { const handleMessage = (event: MessageEvent) => { const msg = event.data; switch (msg.type) { - case 'init': + case 'init': { + if (Number.isSafeInteger(msg.configRevision)) { + if (msg.configRevision <= lastConfigMessageRevisionRef.current) { + break; + } + lastConfigMessageRevisionRef.current = msg.configRevision; + } + if (configurationLoadedRef.current && configurationDirtyRef.current) { + break; + } + + latestVanillaCatalogRequestIdRef.current = undefined; + latestSkinPreviewRequestIdRef.current = undefined; + setVanillaSkinCatalog({ status: 'idle', skins: [] }); + const parsedCustomSkins = parseCustomSkins(msg.customSkins); + customSkinsRef.current = parsedCustomSkins; + setCustomSkins(parsedCustomSkins); + setConfigurationRevision(current => current + 1); // 设置语言 if (msg.language) { setLang(msg.language.startsWith('zh') ? 'zh' : 'en'); @@ -53,38 +241,110 @@ function App() { if (msg.needsInitialSave) { setNeedsAutoSave(true); + setCustomSkinPreview({ status: 'idle' }); } else { - loadData(parsedData, msg.skinPreviewUri); + loadData(parsedData, msg.skinPreviewUri, msg.skinPreviewErrorCode); } - + + setConfigurationLoaded(true); + configurationLoadedRef.current = true; + configurationDirtyRef.current = false; setHasChanges(false); + setSavePending(false); break; - case 'saved': - setHasChanges(false); + } + case 'saved': { + const pendingSave = pendingSaveRef.current; + if (!pendingSave || msg.requestId !== pendingSave.requestId) { + break; + } + pendingSaveRef.current = undefined; + setSavePending(false); + if (localEditRevisionRef.current === pendingSave.editRevision) { + configurationDirtyRef.current = false; + setHasChanges(false); + } break; + } + case 'saveFailed': { + const pendingSave = pendingSaveRef.current; + if (!pendingSave || msg.requestId !== pendingSave.requestId) { + break; + } + pendingSaveRef.current = undefined; + setSavePending(false); + break; + } case 'folderSelected': handleFolderSelected(msg.index, msg.path); break; - case 'skinSelected': - setData(prev => ({ - ...prev, - skin_info: { - slim: prev.skin_info?.slim ?? false, - skin: msg.path, - }, - })); - setSkinPreviewUrl(msg.previewUri || null); - setHasChanges(true); + case 'skinSelected': { + if (typeof msg.path !== 'string') break; + addCustomSkin( + msg.path, + typeof msg.previewUri === 'string' ? msg.previewUri : undefined, + msg.errorCode, + ); break; - case 'skinPreview': - setSkinPreviewUrl(msg.previewUri || null); + } + case 'skinPreview': { + if ( + typeof msg.requestId !== 'string' + || msg.requestId !== latestSkinPreviewRequestIdRef.current + ) { + break; + } + if (isSkinPreviewErrorCode(msg.errorCode)) { + setCustomSkinPreview({ status: 'error', errorCode: msg.errorCode }); + } else if (typeof msg.previewUri === 'string' && msg.previewUri) { + setCustomSkinPreview({ status: 'ready', previewUri: msg.previewUri }); + } else { + setCustomSkinPreview({ status: 'error', errorCode: 'skin_unreadable' }); + } break; + } + case 'vanillaSkins': { + if ( + typeof msg.requestId !== 'string' + || msg.requestId !== latestVanillaCatalogRequestIdRef.current + ) { + break; + } + + const skins = parseVanillaSkins(msg.skins); + const resolvedGameExecutablePath = typeof msg.resolvedGameExecutablePath === 'string' + ? msg.resolvedGameExecutablePath + : undefined; + if (isVanillaCatalogErrorCode(msg.errorCode)) { + setVanillaSkinCatalog({ + status: 'error', + skins: [], + resolvedGameExecutablePath, + errorCode: msg.errorCode, + }); + } else if (skins.length === 0) { + setVanillaSkinCatalog({ + status: 'error', + skins: [], + resolvedGameExecutablePath, + errorCode: 'no_skins', + }); + } else { + setVanillaSkinCatalog({ + status: 'ready', + skins, + resolvedGameExecutablePath, + }); + } + break; + } case 'gameExecutableSelected': + latestVanillaCatalogRequestIdRef.current = undefined; setData(prev => ({ ...prev, game_executable_path: msg.path })); - setHasChanges(true); + markChanged(); break; case 'gameExecutablePaths': setGameExecutablePaths(Array.isArray(msg.paths) @@ -92,6 +352,19 @@ function App() { : []); setGameExecutableDiscoveryLoaded(true); break; + case 'managedModState': + const managedState = msg.state ?? msg; + setManagedModStatePayload(managedState); + if ( + managedState && typeof managedState === 'object' && + typeof managedState.managedIndex === 'number' && + !leavingManagedModRef.current + ) { + setManagedModIndex((current) => current ?? managedState.managedIndex); + } else if (managedState?.status === 'idle') { + leavingManagedModRef.current = false; + } + break; } }; @@ -101,11 +374,21 @@ function App() { return () => window.removeEventListener('message', handleMessage); }, []); - const loadData = (newData: McdevData, skinPreviewUri?: string) => { + const loadData = ( + newData: McdevData, + skinPreviewUri?: string, + skinPreviewErrorCode?: unknown, + ) => { setData(newData); const dirs = parseModDirs(newData.included_mod_dirs); setModDirs(dirs); - setSkinPreviewUrl(skinPreviewUri || null); + setCustomSkinPreview( + isSkinPreviewErrorCode(skinPreviewErrorCode) + ? { status: 'error', errorCode: skinPreviewErrorCode } + : skinPreviewUri + ? { status: 'ready', previewUri: skinPreviewUri } + : { status: 'idle' }, + ); }; const parseModDirs = (dirs?: (string | ModDir)[]): ModDir[] => { @@ -117,6 +400,166 @@ function App() { }); }; + useEffect(() => { + if ( + !configurationLoaded + || (data.skin_info?.skin ?? '').trim() !== '' + || data.skin_info?.slim !== true + ) { + return; + } + + setData(prev => { + if ((prev.skin_info?.skin ?? '').trim() !== '' || prev.skin_info?.slim !== true) { + return prev; + } + return { + ...prev, + skin_info: { + skin: prev.skin_info?.skin ?? '', + slim: false, + }, + }; + }); + markChanged(); + }, [configurationLoaded, data.skin_info?.skin, data.skin_info?.slim, markChanged]); + + useEffect(() => { + if (!configurationLoaded) return; + + const requestId = nextSkinRequestId('catalog'); + latestVanillaCatalogRequestIdRef.current = requestId; + setVanillaSkinCatalog({ status: 'loading', skins: [] }); + const timer = window.setTimeout(() => { + vscode.postMessage({ + type: 'getVanillaSkins', + requestId, + gameExecutablePath: data.game_executable_path?.trim() ?? '', + }); + }, VANILLA_CATALOG_DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [configurationLoaded, configurationRevision, data.game_executable_path, nextSkinRequestId]); + + useEffect(() => { + if (!configurationLoaded) return; + + const skinPath = data.skin_info?.skin ?? ''; + const isEmptyDefault = skinPath.trim() === ''; + const isExactVanilla = vanillaSkinCatalog.skins.some(skin => skinPathsEqual(skin.path, skinPath)); + if (isEmptyDefault || isExactVanilla) { + latestSkinPreviewRequestIdRef.current = undefined; + setCustomSkinPreview({ status: 'idle' }); + return; + } + + const requestId = nextSkinRequestId('preview'); + latestSkinPreviewRequestIdRef.current = requestId; + setCustomSkinPreview({ status: 'loading' }); + const timer = window.setTimeout(() => { + vscode.postMessage({ + type: 'updateSkinPreview', + requestId, + path: skinPath, + }); + }, CUSTOM_PREVIEW_DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [ + configurationLoaded, + data.skin_info?.skin, + nextSkinRequestId, + vanillaSkinCatalog.skins, + ]); + + useEffect(() => { + if (vanillaSkinCatalog.status !== 'ready') return; + + const configuredPath = data.skin_info?.skin ?? ''; + if (configuredPath.trim() === '') return; + + const exactSkin = vanillaSkinCatalog.skins.find(skin => ( + skinPathsEqual(skin.path, configuredPath) + )); + if (exactSkin) { + if (configuredPath === exactSkin.path && data.skin_info?.slim === exactSkin.slim) { + return; + } + setData(prev => { + const currentPath = prev.skin_info?.skin ?? ''; + if (!skinPathsEqual(currentPath, configuredPath)) return prev; + return { + ...prev, + skin_info: { + skin: exactSkin.path, + slim: exactSkin.slim, + }, + }; + }); + markChanged(); + return; + } + + const migratedSkin = matchingLegacyVanillaSkin( + configuredPath, + vanillaSkinCatalog.skins, + vanillaSkinCatalog.resolvedGameExecutablePath, + ); + if (!migratedSkin) return; + + setData(prev => { + if ((prev.skin_info?.skin ?? '') !== configuredPath) return prev; + return { + ...prev, + skin_info: { + skin: migratedSkin.path, + slim: migratedSkin.slim, + }, + }; + }); + markChanged(); + }, [data.skin_info?.skin, data.skin_info?.slim, markChanged, vanillaSkinCatalog]); + + useEffect(() => { + if ( + vanillaSkinCatalog.status === 'idle' + || vanillaSkinCatalog.status === 'loading' + ) { + return; + } + + const configuredPath = (data.skin_info?.skin ?? '').trim(); + if (!configuredPath) return; + if (vanillaSkinCatalog.skins.some(skin => skinPathsEqual(skin.path, configuredPath))) { + return; + } + if ( + vanillaSkinCatalog.status === 'ready' + && matchingLegacyVanillaSkin( + configuredPath, + vanillaSkinCatalog.skins, + vanillaSkinCatalog.resolvedGameExecutablePath, + ) + ) { + return; + } + + const slim = data.skin_info?.slim === true; + const existing = customSkinsRef.current.find(skin => ( + skinPathsEqual(skin.path, configuredPath) + )); + if (existing?.slim === slim) return; + commitCustomSkins(withCustomSkin(customSkinsRef.current, { + path: existing?.path ?? configuredPath, + slim, + })); + }, [ + commitCustomSkins, + data.skin_info?.skin, + data.skin_info?.slim, + vanillaSkinCatalog, + ]); + const collectData = useCallback((): McdevData => { const allDefault = modDirs.every(d => d.hot_reload && d.enabled); const includedModDirs = allDefault @@ -141,12 +584,27 @@ function App() { }; }, [data, modDirs]); + const requestSave = useCallback((saveData: McdevData) => { + saveRequestSequenceRef.current += 1; + const requestId = `save-${saveRequestSequenceRef.current}`; + pendingSaveRef.current = { + requestId, + editRevision: localEditRevisionRef.current, + }; + setSavePending(true); + vscode.postMessage({ + type: 'save', + requestId, + content: JSON.stringify(saveData, null, 4), + }); + }, [addCustomSkin]); + const performAutoSave = useCallback(() => { const saveData = collectData(); - vscode.postMessage({ type: 'save', content: JSON.stringify(saveData, null, 4) }); + requestSave(saveData); setNeedsAutoSave(false); initializedComponentsRef.current.clear(); - }, [collectData]); + }, [collectData, requestSave]); const markInitialized = useCallback((componentId: string) => { initializedComponentsRef.current.add(componentId); @@ -164,8 +622,8 @@ function App() { const handleSave = useCallback(() => { const saveData = collectData(); - vscode.postMessage({ type: 'save', content: JSON.stringify(saveData, null, 4) }); - }, [collectData]); + requestSave(saveData); + }, [collectData, requestSave]); const handleFolderSelected = (index: number, path: string) => { if (index === -1) { @@ -180,7 +638,7 @@ function App() { return prev; }); } - setHasChanges(true); + markChanged(); }; const handleKeyCapture = useCallback((key: string, keyCode: string) => { @@ -192,8 +650,8 @@ function App() { }, })); setActiveKeyListener(null); - setHasChanges(true); - }, []); + markChanged(); + }, [markChanged]); useEffect(() => { if (!needsAutoSave || initializedComponentsRef.current.size === 0) { @@ -252,7 +710,7 @@ function App() { const handleDataChange = (field: string, value: any) => { setData(prev => ({ ...prev, [field]: value })); - setHasChanges(true); + markChanged(); }; const handleWindowStyleChange = (field: string, value: any) => { @@ -263,27 +721,73 @@ function App() { [field]: value, }, })); - setHasChanges(true); + markChanged(); + }; + + const handleSkinInfoChange = (field: 'skin' | 'slim', value: string | boolean) => { + if (field === 'skin') { + latestSkinPreviewRequestIdRef.current = undefined; + } + setData(prev => { + const currentSkin = prev.skin_info?.skin ?? ''; + const currentSlim = prev.skin_info?.slim ?? false; + if (field === 'skin') { + const nextSkin = typeof value === 'string' ? value : String(value); + return { + ...prev, + skin_info: { + skin: nextSkin, + slim: nextSkin.trim() === '' ? false : currentSlim, + }, + }; + } + return { + ...prev, + skin_info: { + skin: currentSkin, + slim: Boolean(value), + }, + }; + }); + if (field === 'slim') { + const configuredPath = data.skin_info?.skin ?? ''; + const existing = customSkinsRef.current.find(skin => ( + skinPathsEqual(skin.path, configuredPath) + )); + if (existing) { + commitCustomSkins(withCustomSkin(customSkinsRef.current, { + ...existing, + slim: Boolean(value), + })); + } + } + markChanged(); }; - const handleSkinInfoChange = (field: string, value: any) => { + const handleSkinSelection = (skinPath: string, slim: boolean) => { + latestSkinPreviewRequestIdRef.current = undefined; setData(prev => ({ ...prev, skin_info: { - slim: prev.skin_info?.slim ?? false, - skin: prev.skin_info?.skin ?? '', - [field]: value, + skin: skinPath, + slim: skinPath.trim() === '' ? false : slim, }, })); - if (field === 'skin') { - const text = (value || '').trim(); - if (!text) { - setSkinPreviewUrl(null); - } else { - vscode.postMessage({ type: 'updateSkinPreview', path: value }); - } + const customSkin = customSkinsRef.current.find(skin => ( + skinPathsEqual(skin.path, skinPath) + )); + setCustomSkinPreview(customSkin ? { status: 'loading' } : { status: 'idle' }); + markChanged(); + }; + + const handleRemoveCustomSkin = (skinPath: string) => { + const nextSkins = customSkinsRef.current.filter(skin => ( + !skinPathsEqual(skin.path, skinPath) + )); + commitCustomSkins(nextSkins); + if (skinPathsEqual(data.skin_info?.skin ?? '', skinPath)) { + handleSkinSelection('', false); } - setHasChanges(true); }; const handleExperimentChange = (field: string, checked: boolean) => { @@ -294,7 +798,7 @@ function App() { [field]: checked, }, })); - setHasChanges(true); + markChanged(); }; const handleDebugOptionChange = (field: string, value: any) => { @@ -305,7 +809,7 @@ function App() { [field]: value, }, })); - setHasChanges(true); + markChanged(); }; const handleMcpServerConfigChange = (field: string, value: any) => { @@ -316,11 +820,45 @@ function App() { [field]: value, }, })); - setHasChanges(true); + markChanged(); }; + const configurationUnsaved = hasChanges || needsAutoSave || savePending; + + useEffect(() => { + vscode.postMessage({ type: 'projectConfigDirty', dirty: configurationUnsaved }); + }, [configurationUnsaved]); + + const handleManageMod = useCallback((index: number) => { + if (configurationUnsaved) return; + leavingManagedModRef.current = false; + setManageReturnFocusIndex(null); + setManagedModStatePayload(undefined); + setManagedModIndex(index); + vscode.postMessage({ type: 'manageMod', index }); + }, [configurationUnsaved]); + + const handleLeaveModManagement = useCallback(() => { + const returnIndex = managedModIndex; + leavingManagedModRef.current = true; + setManagedModIndex(null); + setManagedModStatePayload(undefined); + setManageReturnFocusIndex(returnIndex); + vscode.postMessage({ type: 'leaveModManagement' }); + }, [managedModIndex]); + return (
+ {managedModIndex !== null ? ( + + ) : ( + <> {/* Toolbar */}
+

{t.managedModTitle}

+ + + +
+ {(state.target || state.project) && ( +
+
+ {projectKindLabel} +

+ {state.project?.name || state.target?.label || t.projectCurrentWorkspace} +

+ {state.target?.external && ( + {t.managedModExternalPath} + )} +
+
+
+
{t.managedModConfiguredPath}
+
{state.target?.configuredPath || '—'}
+
+
+
{t.managedModResolvedPath}
+
{state.target?.resolvedPath || '—'}
+
+
+
{t.projectBehaviorPacks}
+
{state.project?.behaviorPackCount ?? '—'}
+
+
+
{t.projectResourcePacks}
+
{state.project?.resourcePackCount ?? '—'}
+
+
+
{t.projectVersionTitle}
+
{formatVersion(state.project?.version, t.projectVersionUnknown)}
+
+
+
+ )} + + {issues.length > 0 && ( +
issue.error) ? 'alert' : 'status'} + aria-live={issues.some((issue) => issue.error) ? 'assertive' : 'polite'} + aria-labelledby="managed-mod-warnings-title" + > +

{t.managedModTargetWarnings}

+
    + {issues.map(({ text, error }, index) => ( +
  • + + {text} +
  • + ))} +
+
+ )} + + {statusMessage && ( +
+ + + {statusMessage} + {statusDetail && {statusDetail}} + {documentsDirty && (state.unsavedDocumentPaths?.length ?? 0) > 0 && ( + {state.unsavedDocumentPaths?.join(', ')} + )} + +
+ )} + +
+
+ +

{t.managedModPreviewTitle}

+
+
+
+
+

{t.managedModVersionPreviewTitle}

+
+ +
+ + +
+
+ +
+
+

{t.managedModUuidPreviewTitle}

+
+ +
+
+
+ + {state.preview && ( +
+
+
+ +

+ {state.preview.operation === 'bump-version' + ? t.managedModVersionPreview + : t.managedModUuidPreview} +

+
+
+ {state.preview.viewedCount} {t.managedModViewed} + {state.preview.unviewedCount} {t.managedModUnviewed} +
+
+
+ {state.preview.files.map((file) => ( + + ))} +
+
+ + +
+
+ )} + +
+ + {staleDialogOpen && state.preview && ( +
+
trapDialogFocus( + event, + staleCancelRef.current, + previewApprovalDisabledReason ? staleReviewRef.current : staleApproveRef.current, + )} + > + +

{t.managedModStaleTitle}

+

{t.managedModStaleDescription}

+ {previewApprovalDisabledReason && ( +

+ {previewApprovalDisabledReason} +

+ )} +
+ + + +
+
+
+ )} + +
+ ); +}; diff --git a/webview/src/components/ModDirectories.tsx b/webview/src/components/ModDirectories.tsx index aaac2cb..fdaf061 100644 --- a/webview/src/components/ModDirectories.tsx +++ b/webview/src/components/ModDirectories.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { vscode } from '../vscode'; import { I18nText } from '../i18n'; @@ -13,6 +13,10 @@ interface Props { modDirs: ModDir[]; setModDirs: (dirs: ModDir[]) => void; setHasChanges: (changed: boolean) => void; + hasChanges: boolean; + onManageMod: (index: number) => void; + focusManageIndex: number | null; + onManageFocusReturned: () => void; } type ReviewStatus = 'idle' | 'queued' | 'running' | 'clean' | 'issues' | 'error'; @@ -46,12 +50,22 @@ const createReportName = (path: string, index: number) => { return `${String(index + 1).padStart(2, '0')}-${safeName}.md`; }; -export const ModDirectories: React.FC = ({ t, modDirs, setModDirs, setHasChanges }) => { +export const ModDirectories: React.FC = ({ + t, + modDirs, + setModDirs, + setHasChanges, + hasChanges, + onManageMod, + focusManageIndex, + onManageFocusReturned, +}) => { const [reviewStates, setReviewStates] = useState>({}); const [reviewLauncherOpen, setReviewLauncherOpen] = useState(false); const [selectedReviewTarget, setSelectedReviewTarget] = useState(null); const [listExpanded, setListExpanded] = useState(true); const [showAllModDirs, setShowAllModDirs] = useState(false); + const manageButtonRefs = useRef>({}); const availableReviewTargets: ReviewTarget[] = modDirs.map((dir, index) => ({ targetId: `mod:${dir.path}`, @@ -73,6 +87,22 @@ export const ModDirectories: React.FC = ({ t, modDirs, setModDirs, setHas ? modDirs.slice(0, COLLAPSED_MOD_LIMIT) : modDirs; + useEffect(() => { + if (focusManageIndex === null) return; + setListExpanded(true); + if (focusManageIndex >= COLLAPSED_MOD_LIMIT) setShowAllModDirs(true); + const frame = window.requestAnimationFrame(() => { + const button = manageButtonRefs.current[focusManageIndex]; + if (!button?.isConnected) return; + const focusTarget = button.disabled + ? button.closest('.mod-item')?.querySelector('.mod-path') + : button; + focusTarget?.focus({ preventScroll: true }); + onManageFocusReturned(); + }); + return () => window.cancelAnimationFrame(frame); + }, [focusManageIndex, onManageFocusReturned]); + useEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data; @@ -350,6 +380,17 @@ export const ModDirectories: React.FC = ({ t, modDirs, setModDirs, setHas /> {t.hotReload} + + {removable && ( + + )} +
+ ); }; return ( -
+
- - + + {t.skinOptions}
-
- onSkinInfoChange('slim', e.target.checked)} - /> - +
+ +
+
+ + + + {isOpen && ( +
+
{t.skinVanillaCatalog}
+ {renderChoice(defaultChoice, 0)} + {catalog.status === 'loading' ? ( +
+ + {t.skinCatalogLoading} +
+ ) : vanillaChoices.map((choice, index) => renderChoice(choice, index + 1))} + + {netease4DChoices.length > 0 && ( + <> +
+ {t.skinNetease4DCatalog} +
+ {netease4DChoices.map((choice, index) => ( + renderChoice(choice, vanillaChoices.length + index + 1) + ))} + + )} + +
{t.skinCustomReference}
+ {customChoices.length === 0 ? ( +
+ + {t.skinNoCustomSkins} +
+ ) : customChoices.map((choice, index) => ( + renderChoice( + choice, + vanillaChoices.length + netease4DChoices.length + index + 1, + ) + ))} +
+ )} +
+
+ {catalog.status === 'error' && ( + + )}
- +
onSkinInfoChange('skin', e.target.value)} - placeholder="default" + id="skin_custom_path" + value={customPathDraft} + onChange={(event) => setCustomPathDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + addDraftCustomSkin(); + } + }} + placeholder={t.skinCustomPathPlaceholder} /> +
- {previewUrl && ( -
- -
+ {!usesVanillaModel && ( +
+ onSkinInfoChange('slim', event.target.checked)} + /> + +
+ )} + + {unmatchedLegacy && ( +
+ + {t.skinLegacyUnmatched} +
+ )} + +
+
+ {t.currentSkin} + + {t.skinSource}: + {sourceText} + +
+ {previewError && ( +
+ + {previewError} +
+ )} +
+ {previewLoading ? ( +
+ + {t.skinPreviewLoading} +
+ ) : previewUrl ? ( {t.skinPath} -
+ ) : ( +
+ + {t.skinPreviewEmpty} +
+ )}
- )} -
+
+
); }; diff --git a/webview/src/i18n.ts b/webview/src/i18n.ts index 49c852b..c29baa7 100644 --- a/webview/src/i18n.ts +++ b/webview/src/i18n.ts @@ -75,6 +75,40 @@ export interface I18nText { skinPath: string; browseSkin: string; currentSkin: string; + skinSelect: string; + skinVanillaCatalog: string; + skinNetease4DCatalog: string; + skinNetease4DDetail: string; + skinVanillaSelect: string; + skinVanillaSelectPlaceholder: string; + skinUseDefaultSteve: string; + skinCustomReference: string; + skinAddCustom: string; + skinAddCustomAction: string; + skinRemoveCustom: string; + skinNoCustomSkins: string; + skinCustomPathPlaceholder: string; + skinCatalogLoading: string; + skinCatalogGameNotFound: string; + skinCatalogNotFound: string; + skinCatalogInvalid: string; + skinCatalogEmpty: string; + skinSource: string; + skinSourceDefault: string; + skinSourceVanilla: string; + skinSourceNetease4D: string; + skinSourceCustom: string; + skinSourceLegacy: string; + skinLegacyUnmatched: string; + skinPreviewLoading: string; + skinPreviewEmpty: string; + skinPreviewNotFound: string; + skinPreviewNotPng: string; + skinPreviewTooLarge: string; + skinPreviewUnreadable: string; + skinPreviewAlt: string; + skinClassicModel: string; + skinSlimModel: string; userSettings: string; userName: string; windowStyle: string; @@ -331,6 +365,70 @@ export interface I18nText { nativeProfilerReveal: string; nativeProfilerSaved: string; nativeProfilerTruncated: string; + projectRefresh: string; + projectSummary: string; + projectCurrentWorkspace: string; + projectVersionUnknown: string; + projectBehaviorPacks: string; + projectResourcePacks: string; + projectScanIssues: string; + projectNoWorkspace: string; + projectSaveBeforeOperations: string; + projectSaveDirtyDocuments: string; + projectBusyHint: string; + projectScanning: string; + projectBackendUnavailable: string; + projectProtocolMismatch: string; + projectInvalidManifest: string; + projectInspectFailed: string; + projectNotDetected: string; + projectRegeneratingUuids: string; + projectBumpingVersion: string; + projectTypeAddon: string; + projectTypeMap: string; + projectTypePack: string; + projectTypeUnknown: string; + projectUuidTitle: string; + projectUuidDescription: string; + projectRegenerateUuids: string; + projectUuidConfirmTitle: string; + projectUuidConfirmDescription: string; + projectConfirmRegenerate: string; + projectVersionTitle: string; + projectVersionDescription: string; + projectVersionPart: string; + projectPatch: string; + projectMinor: string; + projectMajor: string; + projectBumpVersion: string; + manageMod: string; + manageModSaveFirst: string; + managedModTitle: string; + managedModBack: string; + managedModConfiguredPath: string; + managedModResolvedPath: string; + managedModExternalPath: string; + managedModVersionPreviewTitle: string; + managedModGenerateVersionDiff: string; + managedModUuidPreviewTitle: string; + managedModGenerateUuidDiff: string; + managedModPreviewTitle: string; + managedModVersionPreview: string; + managedModUuidPreview: string; + managedModPreviewFiles: string; + managedModViewed: string; + managedModUnviewed: string; + managedModOpenDiff: string; + managedModCancelPreview: string; + managedModApproveAll: string; + managedModApplyingPreview: string; + managedModPreviewActive: string; + managedModStaleTitle: string; + managedModStaleDescription: string; + managedModReviewNewPreview: string; + managedModApproveAgain: string; + managedModInvalidTarget: string; + managedModTargetWarnings: string; } export const i18n: Record = { @@ -411,6 +509,40 @@ export const i18n: Record = { skinPath: 'Skin Texture File', browseSkin: 'Browse Skin PNG', currentSkin: 'Current Skin', + skinSelect: 'Skin', + skinVanillaCatalog: 'Vanilla skins', + skinNetease4DCatalog: 'NetEase 4D test skins', + skinNetease4DDetail: 'Official 4D test skin', + skinVanillaSelect: 'Vanilla skin', + skinVanillaSelectPlaceholder: 'Show skin choices', + skinUseDefaultSteve: 'Use default Steve', + skinCustomReference: 'Custom skins', + skinAddCustom: 'Add custom skin', + skinAddCustomAction: 'Add PNG path', + skinRemoveCustom: 'Remove custom skin', + skinNoCustomSkins: 'No custom skins added', + skinCustomPathPlaceholder: 'Enter a PNG path', + skinCatalogLoading: 'Loading the vanilla skin catalog…', + skinCatalogGameNotFound: 'The selected game executable could not be found.', + skinCatalogNotFound: 'The vanilla skin catalog was not found in this game installation.', + skinCatalogInvalid: 'The vanilla skin catalog is invalid or could not be read.', + skinCatalogEmpty: 'The vanilla skin catalog does not contain any usable skins.', + skinSource: 'Source', + skinSourceDefault: 'Vanilla · Steve (default)', + skinSourceVanilla: 'Vanilla', + skinSourceNetease4D: 'NetEase 4D', + skinSourceCustom: 'Custom reference', + skinSourceLegacy: 'Legacy vanilla path', + skinLegacyUnmatched: 'This legacy vanilla path no longer matches the installed catalog. It is preserved as a custom reference; choose a vanilla skin to update it.', + skinPreviewLoading: 'Loading skin preview…', + skinPreviewEmpty: 'No 2D preview is available.', + skinPreviewNotFound: 'The referenced skin file was not found.', + skinPreviewNotPng: 'The referenced skin must be a PNG file.', + skinPreviewTooLarge: 'The referenced skin PNG is too large to preview (maximum 8 MiB).', + skinPreviewUnreadable: 'The referenced skin PNG could not be read.', + skinPreviewAlt: '2D skin preview', + skinClassicModel: 'Classic model', + skinSlimModel: 'Slim model', userSettings: 'User Settings', userName: 'User Name', windowStyle: 'Window Style', @@ -667,6 +799,70 @@ export const i18n: Record = { nativeProfilerReveal: 'Show saved capture in folder', nativeProfilerSaved: 'Capture saved', nativeProfilerTruncated: 'Largest zones shown', + projectRefresh: 'Refresh project scan', + projectSummary: 'Project summary', + projectCurrentWorkspace: 'Current workspace', + projectVersionUnknown: 'Version unknown', + projectBehaviorPacks: 'Behavior packs', + projectResourcePacks: 'Resource packs', + projectScanIssues: 'Project scan issues', + projectNoWorkspace: 'Open a workspace folder to use project operations.', + projectSaveBeforeOperations: 'Save the configuration before running a project operation.', + projectSaveDirtyDocuments: 'Save every unsaved file in the workspace before running a project operation.', + projectBusyHint: 'Another project operation is still running.', + projectScanning: 'Scanning the current project...', + projectBackendUnavailable: 'The bundled project backend is unavailable. Reinstall or update the extension.', + projectProtocolMismatch: 'The bundled mcdk protocol is incompatible. Update the extension before continuing.', + projectInvalidManifest: 'Fix the manifest problems shown above, then refresh the project scan.', + projectInspectFailed: 'The project could not be inspected. Check the project files and refresh.', + projectNotDetected: 'No supported add-on or map project was found in this workspace.', + projectRegeneratingUuids: 'Regenerating project UUIDs...', + projectBumpingVersion: 'Updating project versions...', + projectTypeAddon: 'Add-on', + projectTypeMap: 'Map', + projectTypePack: 'Single pack', + projectTypeUnknown: 'Project', + projectUuidTitle: 'Project UUIDs', + projectUuidDescription: 'Generate new UUIDs and update internal package references.', + projectRegenerateUuids: 'Regenerate UUIDs', + projectUuidConfirmTitle: 'Regenerate every project UUID?', + projectUuidConfirmDescription: 'This changes manifest and world package references. The operation cannot be undone automatically.', + projectConfirmRegenerate: 'Regenerate', + projectVersionTitle: 'Project version', + projectVersionDescription: 'Increase all linked package versions together.', + projectVersionPart: 'Version part', + projectPatch: 'Patch', + projectMinor: 'Minor', + projectMajor: 'Major', + projectBumpVersion: 'Increase version', + manageMod: 'Manage mod', + manageModSaveFirst: 'Save the configuration before managing this mod', + managedModTitle: 'Mod management', + managedModBack: 'Back to settings', + managedModConfiguredPath: 'Configured path', + managedModResolvedPath: 'Resolved path', + managedModExternalPath: 'Outside workspace', + managedModVersionPreviewTitle: 'Version number', + managedModGenerateVersionDiff: 'Bump version', + managedModUuidPreviewTitle: 'UUID', + managedModGenerateUuidDiff: 'Random UUID', + managedModPreviewTitle: 'Quick changes', + managedModVersionPreview: 'Version changes', + managedModUuidPreview: 'UUID changes', + managedModPreviewFiles: 'Changed files', + managedModViewed: 'Viewed', + managedModUnviewed: 'Not viewed', + managedModOpenDiff: 'Open native diff', + managedModCancelPreview: 'Cancel changes', + managedModApproveAll: 'Approve all changes', + managedModApplyingPreview: 'Applying the approved changes...', + managedModPreviewActive: 'Finish or cancel the current preview before starting another operation.', + managedModStaleTitle: 'The preview changed', + managedModStaleDescription: 'Files changed after approval. A fresh preview was generated; review it and approve the new diff again.', + managedModReviewNewPreview: 'Review new diff', + managedModApproveAgain: 'Approve new preview', + managedModInvalidTarget: 'This mod directory could not be inspected. Check its path and manifests, then refresh.', + managedModTargetWarnings: 'Mod warnings', }, zh: { @@ -746,6 +942,40 @@ export const i18n: Record = { skinPath: '皮肤贴图文件', browseSkin: '选择皮肤 PNG 文件', currentSkin: '当前皮肤', + skinSelect: '皮肤选择', + skinVanillaCatalog: '原版皮肤目录', + skinNetease4DCatalog: '网易 4D 测试皮肤', + skinNetease4DDetail: '官方 4D 测试皮肤', + skinVanillaSelect: '原版皮肤', + skinVanillaSelectPlaceholder: '展开皮肤选项', + skinUseDefaultSteve: '使用默认 Steve', + skinCustomReference: '自定义皮肤', + skinAddCustom: '添加自定义皮肤', + skinAddCustomAction: '添加 PNG 路径', + skinRemoveCustom: '移除自定义皮肤', + skinNoCustomSkins: '尚未添加自定义皮肤', + skinCustomPathPlaceholder: '输入 PNG 路径', + skinCatalogLoading: '正在加载原版皮肤目录…', + skinCatalogGameNotFound: '找不到所选游戏可执行文件。', + skinCatalogNotFound: '此游戏安装目录中没有原版皮肤目录。', + skinCatalogInvalid: '原版皮肤目录格式无效或无法读取。', + skinCatalogEmpty: '原版皮肤目录中没有可用皮肤。', + skinSource: '来源', + skinSourceDefault: '原版 · Steve(默认)', + skinSourceVanilla: '原版皮肤', + skinSourceNetease4D: '网易 4D', + skinSourceCustom: '自定义引用', + skinSourceLegacy: '旧版原版路径', + skinLegacyUnmatched: '此旧版原版路径已无法匹配当前目录,现已按自定义引用保留;请选择一款原版皮肤来更新它。', + skinPreviewLoading: '正在加载皮肤预览…', + skinPreviewEmpty: '暂无可用的二维预览。', + skinPreviewNotFound: '找不到所引用的皮肤文件。', + skinPreviewNotPng: '所引用的皮肤必须是 PNG 文件。', + skinPreviewTooLarge: '所引用的皮肤 PNG 过大,无法预览(最大 8 MiB)。', + skinPreviewUnreadable: '无法读取所引用的皮肤 PNG。', + skinPreviewAlt: '皮肤二维预览', + skinClassicModel: '经典模型', + skinSlimModel: '纤细模型', userSettings: '用户设置', userName: '用户名', windowStyle: '窗口样式', @@ -1002,5 +1232,69 @@ export const i18n: Record = { nativeProfilerReveal: '在文件夹中显示采样', nativeProfilerSaved: '采样已保存', nativeProfilerTruncated: '仅显示耗时最高的区间', + projectRefresh: '刷新项目扫描', + projectSummary: '项目摘要', + projectCurrentWorkspace: '当前工作区', + projectVersionUnknown: '版本未知', + projectBehaviorPacks: '行为包', + projectResourcePacks: '资源包', + projectScanIssues: '项目扫描问题', + projectNoWorkspace: '请先打开一个工作区文件夹,再使用项目操作。', + projectSaveBeforeOperations: '请先保存配置,再执行项目操作。', + projectSaveDirtyDocuments: '请先保存工作区内所有未保存的文件,再执行项目操作。', + projectBusyHint: '另一个项目操作仍在执行,请稍候。', + projectScanning: '正在扫描当前项目…', + projectBackendUnavailable: '内置项目后端不可用,请重新安装或更新扩展。', + projectProtocolMismatch: '内置 mcdk 协议不兼容,请更新扩展后再继续。', + projectInvalidManifest: '请修复上方列出的清单问题,然后刷新项目扫描。', + projectInspectFailed: '无法检查项目,请确认项目文件后重新刷新。', + projectNotDetected: '当前工作区内未找到支持的 Add-on 或玩法地图项目。', + projectRegeneratingUuids: '正在刷新项目 UUID…', + projectBumpingVersion: '正在提升项目版本…', + projectTypeAddon: 'Add-on', + projectTypeMap: '玩法地图', + projectTypePack: '单个包', + projectTypeUnknown: '项目', + projectUuidTitle: '项目 UUID', + projectUuidDescription: '生成全新 UUID,并同步项目内部的包引用。', + projectRegenerateUuids: '刷新 UUID', + projectUuidConfirmTitle: '确定刷新项目中的全部 UUID?', + projectUuidConfirmDescription: '这会修改清单和地图包引用,操作完成后无法自动撤销。', + projectConfirmRegenerate: '确认刷新', + projectVersionTitle: '项目版本', + projectVersionDescription: '同时提升全部关联包及引用的版本。', + projectVersionPart: '版本级别', + projectPatch: '修订版(Patch)', + projectMinor: '次版本(Minor)', + projectMajor: '主版本(Major)', + projectBumpVersion: '提升版本', + manageMod: '管理 Mod', + manageModSaveFirst: '请先保存配置,再管理此 Mod', + managedModTitle: 'Mod 管理', + managedModBack: '返回设置', + managedModConfiguredPath: '配置路径', + managedModResolvedPath: '解析路径', + managedModExternalPath: '工作区外部目录', + managedModVersionPreviewTitle: '版本号', + managedModGenerateVersionDiff: '提升版本', + managedModUuidPreviewTitle: 'UUID', + managedModGenerateUuidDiff: '随机 UUID', + managedModPreviewTitle: '快速更改', + managedModVersionPreview: '版本变更', + managedModUuidPreview: 'UUID 变更', + managedModPreviewFiles: '变更文件', + managedModViewed: '已查看', + managedModUnviewed: '未查看', + managedModOpenDiff: '打开原生 Diff', + managedModCancelPreview: '取消变更', + managedModApproveAll: '整体确认变更', + managedModApplyingPreview: '正在应用已确认的变更…', + managedModPreviewActive: '请先确认或取消当前变更,再执行其他操作。', + managedModStaleTitle: '预览内容已变化', + managedModStaleDescription: '批准后文件又发生了变化,系统已生成全新预览;请重新检查并再次批准。', + managedModReviewNewPreview: '检查新 Diff', + managedModApproveAgain: '批准新预览', + managedModInvalidTarget: '无法检查此 Mod 目录,请确认路径和清单后刷新。', + managedModTargetWarnings: 'Mod 警告', }, }; diff --git a/webview/src/types.ts b/webview/src/types.ts index c3982af..16b9d82 100644 --- a/webview/src/types.ts +++ b/webview/src/types.ts @@ -68,6 +68,102 @@ export interface McdevData { }; } +export interface VanillaSkin { + id: string; + name: string; + texture: string; + path: string; + slim: boolean; + previewUri: string; + kind: 'vanilla' | 'netease4d'; +} + +export interface CustomSkin { + path: string; + slim: boolean; +} + +export type VanillaSkinCatalogErrorCode = + | 'game_not_found' + | 'catalog_not_found' + | 'catalog_invalid' + | 'no_skins'; + +export type SkinPreviewErrorCode = + | 'skin_not_found' + | 'skin_not_png' + | 'skin_too_large' + | 'skin_unreadable'; + +export interface VanillaSkinCatalogState { + status: 'idle' | 'loading' | 'ready' | 'error'; + skins: VanillaSkin[]; + resolvedGameExecutablePath?: string; + errorCode?: VanillaSkinCatalogErrorCode; +} + +export interface SkinPreviewState { + status: 'idle' | 'loading' | 'ready' | 'error'; + previewUri?: string; + errorCode?: SkinPreviewErrorCode; +} + +export type ProjectKind = 'addon' | 'map' | 'pack' | 'unknown'; +export type VersionPart = 'patch' | 'minor' | 'major'; +export type ManagedModStatus = 'idle' | 'loading' | 'ready' | 'busy' | 'unavailable' | 'error'; +export type ManagedModOperation = + | 'inspect' + | 'bump-version' + | 'regenerate-uuids' + | 'apply-preview'; + +export interface ProjectSummary { + name?: string; + kind: ProjectKind; + behaviorPackCount: number; + resourcePackCount: number; + version?: string | number[]; + issues: string[]; + warnings: string[]; +} + +export interface ManagedModTarget { + label: string; + configuredPath: string; + resolvedPath: string; + external: boolean; +} + +export interface ManagedModPreviewFile { + index: number; + path: string; + viewed: boolean; +} + +export interface ManagedModPreview { + id: string; + operation: 'bump-version' | 'regenerate-uuids'; + versionPart?: VersionPart; + files: ManagedModPreviewFile[]; + viewedCount: number; + unviewedCount: number; +} + +export interface ManagedModState { + status: ManagedModStatus; + hasWorkspace: boolean; + busy: boolean; + operation?: ManagedModOperation; + managedIndex?: number; + target?: ManagedModTarget; + project?: ProjectSummary; + preview?: ManagedModPreview; + message?: string; + errorCode?: string; + hasUnsavedDocuments?: boolean; + unsavedDocumentPaths?: string[]; +} + export interface HostBridgeMethodDescriptor { name: string; modes: string[];