diff --git a/src/main/index.ts b/src/main/index.ts index 23302755ff9..655a1fae811 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1963,13 +1963,16 @@ app.whenReady().then(async () => { .catch((error) => { console.warn('[plugins] failed to initialize plugin service:', error) }) - pluginService.onChanged(() => { - if (setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? [])) { + pluginService.onChanged((event) => { + if ( + event.contentPacksChanged && + setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? []) + ) { void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu()) } for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) { - window.webContents.send('plugins:changed') + window.webContents.send('plugins:changed', event) } } }) diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts index 6b01deaed90..3ca30c68f1f 100644 --- a/src/main/ipc/plugins.ts +++ b/src/main/ipc/plugins.ts @@ -122,6 +122,20 @@ export function registerPluginHandlers( await pluginService.whenReady() return pluginService.contentPacks.languagePacks.list() }) + ipcMain.handle('plugins:listIconThemes', async () => { + await pluginService.whenReady() + return pluginService.contentPacks.iconThemes.list() + }) + ipcMain.handle('plugins:loadIconTheme', async (_event, id: unknown) => { + await pluginService.whenReady() + return pluginService.contentPacks.iconThemes.load( + z.string().startsWith('plugin:').max(256).parse(id) + ) + }) + ipcMain.handle('plugins:listTerminalThemes', async () => { + await pluginService.whenReady() + return pluginService.contentPacks.terminalThemes.list() + }) ipcMain.handle('plugins:listSkillStore', async () => { await pluginService.whenReady() return pluginSkillStoreSnapshot(pluginService) diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts index 9270a7ce1f4..8c8f8595308 100644 --- a/src/main/plugins/plugin-artifact-validation.ts +++ b/src/main/plugins/plugin-artifact-validation.ts @@ -2,6 +2,10 @@ import { createReadStream } from 'node:fs' import { realpath, stat } from 'node:fs/promises' import { isAbsolute, relative, resolve, sep } from 'node:path' import type { PluginManifest } from '../../shared/plugins/plugin-manifest' +import { + parsePluginIconThemeArtifact, + sanitizePluginIconSvg +} from '../../shared/plugins/plugin-icon-theme-artifact' export type PluginArtifactValidationResult = { ok: true } | { ok: false; error: string } @@ -9,7 +13,10 @@ export const PLUGIN_PANEL_ENTRY_MAX_BYTES = 10 * 1024 * 1024 export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024 const PLUGIN_ICON_MAX_BYTES = 2 * 1024 * 1024 export const PLUGIN_THEME_MAX_BYTES = 256 * 1024 -const PLUGIN_ICON_THEME_MAX_BYTES = 512 * 1024 +export const PLUGIN_ICON_THEME_MAX_BYTES = 512 * 1024 +export const PLUGIN_ICON_SVG_MAX_BYTES = 64 * 1024 +export const PLUGIN_ICON_TOTAL_MAX_BYTES = 8 * 1024 * 1024 +export const PLUGIN_TERMINAL_THEME_MAX_BYTES = 256 * 1024 export const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024 const PLUGIN_RECIPE_MAX_BYTES = 1024 * 1024 const PLUGIN_AGENT_PROFILE_MAX_BYTES = 1024 * 1024 @@ -58,6 +65,12 @@ function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] { kind: 'file' as const, maxBytes: PLUGIN_ICON_THEME_MAX_BYTES })), + ...manifest.contributes.terminalThemes.map((theme) => ({ + label: `terminal theme "${theme.id}"`, + path: theme.path, + kind: 'file' as const, + maxBytes: PLUGIN_TERMINAL_THEME_MAX_BYTES + })), ...manifest.contributes.languagePacks.map((languagePack) => ({ label: `language pack "${languagePack.locale}"`, path: languagePack.path, @@ -188,3 +201,41 @@ export async function validateDeclaredPluginArtifacts( } return { ok: true } } + +/** Parses and sanitizes icon-theme references at the immutable install boundary. */ +export async function validatePluginInstallContent( + rootDir: string, + manifest: PluginManifest +): Promise { + let iconBytes = 0 + for (const contribution of manifest.contributes.iconThemes) { + try { + const artifact = parsePluginIconThemeArtifact( + await readContainedPluginArtifactText( + rootDir, + contribution.path, + PLUGIN_ICON_THEME_MAX_BYTES + ) + ) + const paths = new Set([ + ...Object.values(artifact.icons), + ...Object.values(artifact.fileNames), + ...Object.values(artifact.fileExtensions) + ]) + for (const path of paths) { + const svg = await readContainedPluginArtifactText(rootDir, path, PLUGIN_ICON_SVG_MAX_BYTES) + iconBytes += Buffer.byteLength(svg, 'utf8') + if (iconBytes > PLUGIN_ICON_TOTAL_MAX_BYTES) { + throw new Error(`icon SVGs exceed ${PLUGIN_ICON_TOTAL_MAX_BYTES} bytes in total`) + } + sanitizePluginIconSvg(svg) + } + } catch (error) { + return { + ok: false, + error: `icon theme "${contribution.id}": ${error instanceof Error ? error.message : String(error)}` + } + } + } + return { ok: true } +} diff --git a/src/main/plugins/plugin-content-pack-registry.test.ts b/src/main/plugins/plugin-content-pack-registry.test.ts new file mode 100644 index 00000000000..b4ae53b65b5 --- /dev/null +++ b/src/main/plugins/plugin-content-pack-registry.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { PluginContentVerifier } from './plugin-content-integrity' +import { PluginContentPackRegistry } from './plugin-content-pack-registry' +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginContentPackRegistry', () => { + it('activates all contributions from a plugin atomically', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-registry-')) + roots.push(rootDir) + await Promise.all([mkdir(join(rootDir, 'icons')), mkdir(join(rootDir, 'terminal'))]) + await Promise.all([ + writeFile( + join(rootDir, 'icons', 'invalid.json'), + JSON.stringify({ schemaVersion: 1, icons: { file: 42 } }) + ), + writeFile( + join(rootDir, 'terminal', 'valid.json'), + JSON.stringify({ + schemaVersion: 1, + mode: 'dark', + terminal: { + background: '#101010', + foreground: '#f0f0f0', + black: '#000000' + } + }) + ) + ]) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'mixed-content', + publisher: 'orca-samples', + name: 'Mixed Content', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + iconThemes: [{ id: 'broken', label: 'Broken', path: 'icons/invalid.json' }], + terminalThemes: [{ id: 'valid', label: 'Valid', path: 'terminal/valid.json' }] + }, + capabilities: [] + }) + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.mixed-content', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: true + } + const registry = new PluginContentPackRegistry(new PluginContentVerifier(), { + pluginsDataDir: join(rootDir, 'plugin-data'), + homeDirectory: join(rootDir, 'home') + }) + + await registry.reconcile([plugin], () => true) + + expect(registry.error(plugin.pluginKey)).toContain('number') + expect(registry.iconThemes.list()).toEqual([]) + expect(registry.terminalThemes.list()).toEqual([]) + }) +}) diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts index 0b41b22c2d3..efe357e245d 100644 --- a/src/main/plugins/plugin-content-pack-registry.ts +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -1,13 +1,22 @@ import type { PluginContentVerifier } from './plugin-content-integrity' -import type { DiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' import { PluginLanguagePackRegistry } from './plugin-language-pack-registry' +import { PluginIconThemeRegistry } from './plugin-icon-theme-registry' import { PluginSkillRegistry } from './plugin-skill-registry' import { PluginThemeRegistry } from './plugin-theme-registry' +import { PluginTerminalThemeRegistry } from './plugin-terminal-theme-registry' export class PluginContentPackRegistry { readonly themes: PluginThemeRegistry readonly languagePacks: PluginLanguagePackRegistry + readonly iconThemes: PluginIconThemeRegistry + readonly terminalThemes: PluginTerminalThemeRegistry readonly skills: PluginSkillRegistry + private readonly activationErrors = new Map() constructor( contentVerifier: PluginContentVerifier, @@ -15,6 +24,8 @@ export class PluginContentPackRegistry { ) { this.themes = new PluginThemeRegistry(contentVerifier) this.languagePacks = new PluginLanguagePackRegistry(contentVerifier) + this.iconThemes = new PluginIconThemeRegistry(contentVerifier) + this.terminalThemes = new PluginTerminalThemeRegistry(contentVerifier) this.skills = new PluginSkillRegistry( contentVerifier, options.pluginsDataDir, @@ -26,17 +37,51 @@ export class PluginContentPackRegistry { discovered: readonly DiscoveredPlugin[], isApproved: (plugin: ValidDiscoveredPlugin) => boolean ): Promise { - await Promise.all([ - this.themes.reconcile(discovered, isApproved), - this.languagePacks.reconcile(discovered, isApproved), - this.skills.reconcile(discovered, isApproved) - ]) + const approvedKeys = new Set( + discovered + .filter((plugin): plugin is ValidDiscoveredPlugin => !isInvalidDiscoveredPlugin(plugin)) + .filter(isApproved) + .map((plugin) => plugin.pluginKey) + ) + const excluded = new Set() + this.activationErrors.clear() + + while (true) { + const approveAtomically = (plugin: ValidDiscoveredPlugin): boolean => + approvedKeys.has(plugin.pluginKey) && !excluded.has(plugin.pluginKey) + await Promise.all([ + this.themes.reconcile(discovered, approveAtomically), + this.languagePacks.reconcile(discovered, approveAtomically), + this.iconThemes.reconcile(discovered, approveAtomically), + this.terminalThemes.reconcile(discovered, approveAtomically), + this.skills.reconcile(discovered, approveAtomically) + ]) + + let foundNewError = false + for (const pluginKey of approvedKeys) { + const error = this.registryError(pluginKey) + if (error && !excluded.has(pluginKey)) { + excluded.add(pluginKey) + this.activationErrors.set(pluginKey, error) + foundNewError = true + } + } + if (!foundNewError) { + break + } + } } error(pluginKey: string): string | null { + return this.activationErrors.get(pluginKey) ?? this.registryError(pluginKey) + } + + private registryError(pluginKey: string): string | null { return ( this.themes.error(pluginKey) ?? this.languagePacks.error(pluginKey) ?? + this.iconThemes.error(pluginKey) ?? + this.terminalThemes.error(pluginKey) ?? this.skills.error(pluginKey) ) } diff --git a/src/main/plugins/plugin-content-safety.test.ts b/src/main/plugins/plugin-content-safety.test.ts index a90ea69f0a4..eeeb7a6adba 100644 --- a/src/main/plugins/plugin-content-safety.test.ts +++ b/src/main/plugins/plugin-content-safety.test.ts @@ -6,7 +6,8 @@ import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fi import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' import { PLUGIN_PANEL_ENTRY_MAX_BYTES, - validateDeclaredPluginArtifacts + validateDeclaredPluginArtifacts, + validatePluginInstallContent } from './plugin-artifact-validation' import { hashPluginTree } from './plugin-content-hash' import { verifyHashAddressedPluginContent } from './plugin-content-integrity' @@ -164,6 +165,32 @@ describe('declared plugin artifacts', () => { error: expect.stringContaining('artifact limit') }) }) + + it('sanitizes every icon-theme SVG at the install boundary', async () => { + const root = await tempRoot() + await mkdir(join(root, 'icons')) + await writeFile( + join(root, 'icons', 'theme.json'), + JSON.stringify({ schemaVersion: 1, icons: { file: 'icons/file.svg' } }) + ) + await writeFile( + join(root, 'icons', 'file.svg'), + '' + ) + const pluginManifest = manifest({ + contributes: { + iconThemes: [{ id: 'hostile', label: 'Hostile', path: 'icons/theme.json' }] + } + }) + + await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toEqual({ + ok: true + }) + await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('active or external content') + }) + }) }) describe('hash-addressed plugin content', () => { diff --git a/src/main/plugins/plugin-icon-theme-registry.test.ts b/src/main/plugins/plugin-icon-theme-registry.test.ts new file mode 100644 index 00000000000..a03a9e68761 --- /dev/null +++ b/src/main/plugins/plugin-icon-theme-registry.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { PluginContentVerifier } from './plugin-content-integrity' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { PluginIconThemeRegistry } from './plugin-icon-theme-registry' + +const roots: string[] = [] + +async function pluginWithIcons(svg: string): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-icon-theme-')) + roots.push(rootDir) + await mkdir(join(rootDir, 'icons')) + await writeFile( + join(rootDir, 'icons', 'theme.json'), + JSON.stringify({ + schemaVersion: 1, + icons: { file: 'icons/file.svg' }, + fileExtensions: { ts: 'icons/typescript.svg' } + }) + ) + await Promise.all([ + writeFile(join(rootDir, 'icons', 'file.svg'), svg), + writeFile(join(rootDir, 'icons', 'typescript.svg'), '') + ]) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'icons', + publisher: 'orca-samples', + name: 'Icons', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { iconThemes: [{ id: 'minimal', label: 'Minimal', path: 'icons/theme.json' }] }, + capabilities: [] + }) + return { + pluginKey: 'orca-samples.icons', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: true + } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginIconThemeRegistry', () => { + it('loads intrinsic-color SVGs as images and currentColor SVGs as masks', async () => { + const plugin = await pluginWithIcons('') + const registry = new PluginIconThemeRegistry(new PluginContentVerifier()) + + await registry.reconcile([plugin], () => true) + + expect(registry.list()).toEqual([ + expect.objectContaining({ + id: 'plugin:orca-samples.icons/minimal', + label: 'Minimal' + }) + ]) + await expect(registry.load('plugin:orca-samples.icons/minimal')).resolves.toEqual( + expect.objectContaining({ + id: 'plugin:orca-samples.icons/minimal', + icons: { + file: { + dataUrl: expect.stringMatching(/^data:image\/svg\+xml;base64,/), + rendering: 'mask' + } + }, + fileExtensions: { + ts: { + dataUrl: expect.stringMatching(/^data:image\/svg\+xml;base64,/), + rendering: 'image' + } + } + }) + ) + }) + + it('fails closed when a selected SVG is active and clears metadata when disabled', async () => { + const plugin = await pluginWithIcons('bad') + const registry = new PluginIconThemeRegistry(new PluginContentVerifier()) + + await registry.reconcile([plugin], () => true) + expect(registry.list()).toHaveLength(1) + await expect(registry.load('plugin:orca-samples.icons/minimal')).rejects.toThrow('not allowed') + + await registry.reconcile([plugin], () => false) + expect(registry.list()).toEqual([]) + await expect(registry.load('plugin:orca-samples.icons/minimal')).resolves.toBeNull() + }) +}) diff --git a/src/main/plugins/plugin-icon-theme-registry.ts b/src/main/plugins/plugin-icon-theme-registry.ts new file mode 100644 index 00000000000..081042f15e8 --- /dev/null +++ b/src/main/plugins/plugin-icon-theme-registry.ts @@ -0,0 +1,160 @@ +import { + parsePluginIconThemeArtifact, + sanitizePluginIconSvg, + type PluginIconThemeArtifact, + type PluginIconThemeImage, + type PluginIconThemeMetadata, + type PluginIconThemeRegistration +} from '../../shared/plugins/plugin-icon-theme-artifact' +import { + PLUGIN_ICON_SVG_MAX_BYTES, + PLUGIN_ICON_THEME_MAX_BYTES, + PLUGIN_ICON_TOTAL_MAX_BYTES, + readContainedPluginArtifactText +} from './plugin-artifact-validation' +import type { PluginContentVerifier } from './plugin-content-integrity' +import { mapPluginContentWithConcurrency } from './plugin-content-load-pool' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' + +const ICON_THEME_LOAD_CONCURRENCY = 4 + +type IconThemeLoadResult = + | { pluginKey: string; themes: IconThemeDescriptor[] } + | { pluginKey: string; error: string } + +type IconThemeDescriptor = PluginIconThemeMetadata & { + rootDir: string + artifact: PluginIconThemeArtifact +} + +function svgThemeImage(svg: string): PluginIconThemeImage { + return { + dataUrl: `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`, + // External SVG image documents cannot inherit host currentColor. Render + // those monochrome assets as masks so status and dark-mode colors apply. + rendering: /\bcurrentColor\b/i.test(svg) ? 'mask' : 'image' + } +} + +export class PluginIconThemeRegistry { + private themes: IconThemeDescriptor[] = [] + private readonly loadedThemes = new Map>() + private readonly errors = new Map() + + constructor(private readonly contentVerifier: PluginContentVerifier) {} + + list(): readonly PluginIconThemeMetadata[] { + return this.themes.map(({ id, pluginKey, label }) => ({ id, pluginKey, label })) + } + + async load(id: string): Promise { + const descriptor = this.themes.find((theme) => theme.id === id) + if (!descriptor) { + return null + } + const existing = this.loadedThemes.get(id) + if (existing) { + return existing + } + const loading = this.materialize(descriptor) + this.loadedThemes.set(id, loading) + try { + return await loading + } catch (error) { + this.loadedThemes.delete(id) + throw error + } + } + + error(pluginKey: string): string | null { + return this.errors.get(pluginKey) ?? null + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ): Promise { + const candidates = discovered.filter( + (plugin): plugin is ValidDiscoveredPlugin => + !isInvalidDiscoveredPlugin(plugin) && + isApproved(plugin) && + plugin.manifest.contributes.iconThemes.length > 0 + ) + const results = await mapPluginContentWithConcurrency( + candidates, + ICON_THEME_LOAD_CONCURRENCY, + async (plugin): Promise => { + try { + await this.contentVerifier.verify(plugin) + const themes: IconThemeDescriptor[] = [] + for (const contribution of plugin.manifest.contributes.iconThemes) { + const artifact = parsePluginIconThemeArtifact( + await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_ICON_THEME_MAX_BYTES + ) + ) + themes.push({ + id: `plugin:${plugin.pluginKey}/${contribution.id}`, + pluginKey: plugin.pluginKey, + label: contribution.label ?? contribution.id, + rootDir: plugin.rootDir, + artifact + }) + } + return { pluginKey: plugin.pluginKey, themes } + } catch (error) { + return { + pluginKey: plugin.pluginKey, + error: error instanceof Error ? error.message : String(error) + } + } + } + ) + this.themes = results.flatMap((result) => ('themes' in result ? result.themes : [])) + this.loadedThemes.clear() + this.errors.clear() + for (const result of results) { + if ('error' in result) { + this.errors.set(result.pluginKey, result.error) + } + } + } + + private async materialize(descriptor: IconThemeDescriptor): Promise { + const paths = new Set([ + ...Object.values(descriptor.artifact.icons), + ...Object.values(descriptor.artifact.fileNames), + ...Object.values(descriptor.artifact.fileExtensions) + ]) + const dataByPath = new Map() + let loadedIconBytes = 0 + for (const path of paths) { + const svg = await readContainedPluginArtifactText( + descriptor.rootDir, + path, + PLUGIN_ICON_SVG_MAX_BYTES + ) + loadedIconBytes += Buffer.byteLength(svg, 'utf8') + if (loadedIconBytes > PLUGIN_ICON_TOTAL_MAX_BYTES) { + throw new Error(`icon theme SVGs exceed ${PLUGIN_ICON_TOTAL_MAX_BYTES} bytes in total`) + } + dataByPath.set(path, svgThemeImage(sanitizePluginIconSvg(svg))) + } + const mapPaths = (entries: Record): Record => + Object.fromEntries(Object.entries(entries).map(([key, path]) => [key, dataByPath.get(path)!])) + return { + id: descriptor.id, + pluginKey: descriptor.pluginKey, + label: descriptor.label, + icons: mapPaths(descriptor.artifact.icons), + fileNames: mapPaths(descriptor.artifact.fileNames), + fileExtensions: mapPaths(descriptor.artifact.fileExtensions) + } + } +} diff --git a/src/main/plugins/plugin-install-staging.ts b/src/main/plugins/plugin-install-staging.ts new file mode 100644 index 00000000000..558d6bd7c7d --- /dev/null +++ b/src/main/plugins/plugin-install-staging.ts @@ -0,0 +1,164 @@ +import { existsSync } from 'node:fs' +import { cp, mkdir, rename, rm } from 'node:fs/promises' +import { join, relative, resolve, sep } from 'node:path' +import { + PLUGIN_MANIFEST_FILENAME, + parsePluginManifest, + qualifiedPluginKey, + satisfiesOrcaEngineRange, + type PluginManifest +} from '../../shared/plugins/plugin-manifest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import type { + PluginInstallSource, + PluginLockEntry +} from '../../shared/plugins/plugin-install-lockfile' +import { + validateDeclaredPluginArtifacts, + validatePluginInstallContent, + type PluginArtifactValidationResult +} from './plugin-artifact-validation' +import { hashPluginTree } from './plugin-content-hash' +import { publishPluginInstall } from './plugin-install-publication' +import { readPluginManifestText } from './plugin-manifest-file' + +export type PluginInstallResult = + | { + ok: true + pluginKey: string + version: string + contentHash: string + consentFingerprint: string + resolvedCommit: string | null + } + | { ok: false; error: string } + +async function validatePluginInstallTree( + rootDir: string, + manifest: PluginManifest +): Promise { + const declared = await validateDeclaredPluginArtifacts(rootDir, manifest) + return declared.ok ? validatePluginInstallContent(rootDir, manifest) : declared +} + +/** Installs a validated staging tree into the hash-addressed layout. */ +export async function installStagedPluginTree(input: { + pluginsDir: string + stagingDir: string + hostVersion: string + source: PluginInstallSource + resolvedCommit: string | null +}): Promise { + let manifestRaw: unknown + try { + manifestRaw = JSON.parse(await readPluginManifestText(input.stagingDir)) + } catch (error) { + return { + ok: false, + error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}` + } + } + const parsed = parsePluginManifest(manifestRaw) + if (!parsed.ok) { + return { ok: false, error: `invalid manifest: ${parsed.error}` } + } + const manifest = parsed.manifest + if (!satisfiesOrcaEngineRange(input.hostVersion, manifest.engines.orca)) { + return { + ok: false, + error: `plugin requires Orca ${manifest.engines.orca} (this is ${input.hostVersion})` + } + } + const declaredArtifacts = await validatePluginInstallTree(input.stagingDir, manifest) + if (!declaredArtifacts.ok) { + return { ok: false, error: `invalid declared artifact: ${declaredArtifacts.error}` } + } + // Hash before copy: also enforces the symlink/entry-count/size limits. + const treeHash = await hashPluginTree(input.stagingDir) + if (!treeHash.ok) { + return { ok: false, error: treeHash.error } + } + const pluginKey = qualifiedPluginKey(manifest) + const pluginDir = join(input.pluginsDir, pluginKey) + const versionDir = join(pluginDir, treeHash.hash) + if (!existsSync(versionDir)) { + const stagedVersionDir = `${versionDir}.staging` + try { + await rm(stagedVersionDir, { recursive: true, force: true }) + await mkdir(pluginDir, { recursive: true }) + // Source trees can change while copying. Hashing the destination closes + // that race before the immutable directory becomes current. + const stagingRoot = resolve(input.stagingDir) + await cp(stagingRoot, stagedVersionDir, { + recursive: true, + verbatimSymlinks: true, + // Source-control metadata is not plugin content. Skip it at the copy + // boundary so a large local repository cannot bypass install limits. + filter: (source) => { + const fromRoot = relative(stagingRoot, resolve(source)) + return fromRoot !== '.git' && !fromRoot.startsWith(`.git${sep}`) + } + }) + const copiedHash = await hashPluginTree(stagedVersionDir) + if (!copiedHash.ok || copiedHash.hash !== treeHash.hash) { + return { + ok: false, + error: copiedHash.ok + ? 'plugin content changed while it was being copied' + : copiedHash.error + } + } + const copiedArtifacts = await validatePluginInstallTree(stagedVersionDir, manifest) + if (!copiedArtifacts.ok) { + return { ok: false, error: `copied artifact validation failed: ${copiedArtifacts.error}` } + } + await rename(stagedVersionDir, versionDir) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } finally { + await rm(stagedVersionDir, { recursive: true, force: true }) + } + } else { + // Never repoint at an existing hash directory without proving its bytes; + // a previous partial/tampered install must not be revived by reinstall. + const existingHash = await hashPluginTree(versionDir) + if (!existingHash.ok || existingHash.hash !== treeHash.hash) { + return { + ok: false, + error: existingHash.ok + ? 'existing plugin content failed integrity verification' + : existingHash.error + } + } + const existingArtifacts = await validatePluginInstallTree(versionDir, manifest) + if (!existingArtifacts.ok) { + return { + ok: false, + error: `installed artifact validation failed: ${existingArtifacts.error}` + } + } + } + const consentFingerprint = fingerprintPluginConsent(manifest) + const entry: PluginLockEntry = { + pluginKey, + version: manifest.version, + source: input.source, + resolvedCommit: input.resolvedCommit, + contentHash: treeHash.hash, + consentFingerprint, + installedAt: Date.now() + } + try { + await publishPluginInstall({ pluginsDir: input.pluginsDir, pluginDir, entry }) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + return { + ok: true, + pluginKey, + version: manifest.version, + contentHash: treeHash.hash, + consentFingerprint, + resolvedCommit: input.resolvedCommit + } +} diff --git a/src/main/plugins/plugin-install.ts b/src/main/plugins/plugin-install.ts index 68c0205c141..d0c8411c59f 100644 --- a/src/main/plugins/plugin-install.ts +++ b/src/main/plugins/plugin-install.ts @@ -1,28 +1,21 @@ import { execFile } from 'node:child_process' -import { cp, mkdir, mkdtemp, realpath, rename, rm } from 'node:fs/promises' +import { mkdtemp, realpath, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { isAbsolute, join, relative, resolve, sep } from 'node:path' import { promisify } from 'node:util' import { PLUGIN_MANIFEST_FILENAME, - isQualifiedPluginKey, - parsePluginManifest, - qualifiedPluginKey, - satisfiesOrcaEngineRange + isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' import { isAllowedPluginGitUrl, - removePluginLock, - type PluginInstallSource, - type PluginLockEntry + removePluginLock } from '../../shared/plugins/plugin-install-lockfile' -import { hashPluginTree } from './plugin-content-hash' -import { validateDeclaredPluginArtifacts } from './plugin-artifact-validation' -import { readPluginManifestText } from './plugin-manifest-file' import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store' -import { publishPluginInstall } from './plugin-install-publication' +import { installStagedPluginTree, type PluginInstallResult } from './plugin-install-staging' + +export type { PluginInstallResult } from './plugin-install-staging' export { PLUGIN_LOCKFILE_MAX_BYTES, @@ -65,139 +58,6 @@ async function serializePluginMutation( } } -export type PluginInstallResult = - | { - ok: true - pluginKey: string - version: string - contentHash: string - consentFingerprint: string - resolvedCommit: string | null - } - | { ok: false; error: string } - -/** Installs a validated staging tree into the hash-addressed layout. */ -async function installStagedTree(input: { - pluginsDir: string - stagingDir: string - hostVersion: string - source: PluginInstallSource - resolvedCommit: string | null -}): Promise { - let manifestRaw: unknown - try { - manifestRaw = JSON.parse(await readPluginManifestText(input.stagingDir)) - } catch (error) { - return { - ok: false, - error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}` - } - } - const parsed = parsePluginManifest(manifestRaw) - if (!parsed.ok) { - return { ok: false, error: `invalid manifest: ${parsed.error}` } - } - const manifest = parsed.manifest - if (!satisfiesOrcaEngineRange(input.hostVersion, manifest.engines.orca)) { - return { - ok: false, - error: `plugin requires Orca ${manifest.engines.orca} (this is ${input.hostVersion})` - } - } - const declaredArtifacts = await validateDeclaredPluginArtifacts(input.stagingDir, manifest) - if (!declaredArtifacts.ok) { - return { ok: false, error: `invalid declared artifact: ${declaredArtifacts.error}` } - } - // Hash before copy: also enforces the symlink/entry-count/size limits. - const treeHash = await hashPluginTree(input.stagingDir) - if (!treeHash.ok) { - return { ok: false, error: treeHash.error } - } - const pluginKey = qualifiedPluginKey(manifest) - const pluginDir = join(input.pluginsDir, pluginKey) - const versionDir = join(pluginDir, treeHash.hash) - if (!existsSync(versionDir)) { - const stagedVersionDir = `${versionDir}.staging` - try { - await rm(stagedVersionDir, { recursive: true, force: true }) - await mkdir(pluginDir, { recursive: true }) - // Source trees can change while copying. Hashing the destination closes - // that race before the immutable directory becomes current. - const stagingRoot = resolve(input.stagingDir) - await cp(stagingRoot, stagedVersionDir, { - recursive: true, - verbatimSymlinks: true, - // Source-control metadata is not plugin content. Skip it at the copy - // boundary so a large local repository cannot bypass install limits. - filter: (source) => { - const fromRoot = relative(stagingRoot, resolve(source)) - return fromRoot !== '.git' && !fromRoot.startsWith(`.git${sep}`) - } - }) - const copiedHash = await hashPluginTree(stagedVersionDir) - if (!copiedHash.ok || copiedHash.hash !== treeHash.hash) { - return { - ok: false, - error: copiedHash.ok - ? 'plugin content changed while it was being copied' - : copiedHash.error - } - } - const copiedArtifacts = await validateDeclaredPluginArtifacts(stagedVersionDir, manifest) - if (!copiedArtifacts.ok) { - return { ok: false, error: `copied artifact validation failed: ${copiedArtifacts.error}` } - } - await rename(stagedVersionDir, versionDir) - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } finally { - await rm(stagedVersionDir, { recursive: true, force: true }) - } - } else { - // Never repoint at an existing hash directory without proving its bytes; - // a previous partial/tampered install must not be revived by reinstall. - const existingHash = await hashPluginTree(versionDir) - if (!existingHash.ok || existingHash.hash !== treeHash.hash) { - return { - ok: false, - error: existingHash.ok - ? 'existing plugin content failed integrity verification' - : existingHash.error - } - } - const existingArtifacts = await validateDeclaredPluginArtifacts(versionDir, manifest) - if (!existingArtifacts.ok) { - return { - ok: false, - error: `installed artifact validation failed: ${existingArtifacts.error}` - } - } - } - const consentFingerprint = fingerprintPluginConsent(manifest) - const entry: PluginLockEntry = { - pluginKey, - version: manifest.version, - source: input.source, - resolvedCommit: input.resolvedCommit, - contentHash: treeHash.hash, - consentFingerprint, - installedAt: Date.now() - } - try { - await publishPluginInstall({ pluginsDir: input.pluginsDir, pluginDir, entry }) - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } - return { - ok: true, - pluginKey, - version: manifest.version, - contentHash: treeHash.hash, - consentFingerprint, - resolvedCommit: input.resolvedCommit - } -} - export async function installPluginFromLocalPath(input: { pluginsDir: string sourcePath: string @@ -207,7 +67,7 @@ export async function installPluginFromLocalPath(input: { if (!existsSync(join(input.sourcePath, PLUGIN_MANIFEST_FILENAME))) { return { ok: false, error: `no ${PLUGIN_MANIFEST_FILENAME} found in ${input.sourcePath}` } } - return installStagedTree({ + return installStagedPluginTree({ pluginsDir: input.pluginsDir, stagingDir: input.sourcePath, hostVersion: input.hostVersion, @@ -260,7 +120,7 @@ export async function installPluginFromGit(input: { await runGit(args, tmpdir()) } const resolvedCommit = await runGit(['rev-parse', 'HEAD'], stagingDir) - return await installStagedTree({ + return await installStagedPluginTree({ pluginsDir: input.pluginsDir, stagingDir, hostVersion: input.hostVersion, diff --git a/src/main/plugins/plugin-refresh-settlement.ts b/src/main/plugins/plugin-refresh-settlement.ts new file mode 100644 index 00000000000..47bc464d20f --- /dev/null +++ b/src/main/plugins/plugin-refresh-settlement.ts @@ -0,0 +1,11 @@ +export async function waitForPluginRefreshSettlement( + getCurrent: () => Promise +): Promise { + while (true) { + const pending = getCurrent() + await pending.catch(() => undefined) + if (pending === getCurrent()) { + return + } + } +} diff --git a/src/main/plugins/plugin-service-reconciliation.test.ts b/src/main/plugins/plugin-service-reconciliation.test.ts index a0c7b57d7df..2ffe48370a4 100644 --- a/src/main/plugins/plugin-service-reconciliation.test.ts +++ b/src/main/plugins/plugin-service-reconciliation.test.ts @@ -256,4 +256,67 @@ describe('PluginService worker reconciliation', () => { expect(vi.getTimerCount()).toBe(1) expect(harness.factory).not.toHaveBeenCalled() }) + + it('serializes activation reconciliation so the latest disabled state wins', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await harness.service.initialize() + + const originalReconcile = harness.service.contentPacks.reconcile.bind( + harness.service.contentPacks + ) + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + let firstStarted!: () => void + const firstStartedPromise = new Promise((resolve) => { + firstStarted = resolve + }) + let activeReconciliations = 0 + let maximumConcurrentReconciliations = 0 + let callCount = 0 + const reconcile = vi + .spyOn(harness.service.contentPacks, 'reconcile') + .mockImplementation(async (...args) => { + callCount += 1 + activeReconciliations += 1 + maximumConcurrentReconciliations = Math.max( + maximumConcurrentReconciliations, + activeReconciliations + ) + try { + if (callCount === 1) { + firstStarted() + await firstGate + } + await originalReconcile(...args) + } finally { + activeReconciliations -= 1 + } + }) + + const first = harness.service.reconcileActivationState() + await firstStartedPromise + harness.setDisabled([pluginKey]) + const second = harness.service.reconcileActivationState() + let clientsReleased = false + const clientsReady = harness.service.whenReady().then(() => { + clientsReleased = true + }) + + await Promise.resolve() + expect(reconcile).toHaveBeenCalledTimes(1) + expect(clientsReleased).toBe(false) + releaseFirst() + await Promise.all([first, second, clientsReady]) + + expect(maximumConcurrentReconciliations).toBe(1) + expect(clientsReleased).toBe(true) + expect(reconcile).toHaveBeenCalledTimes(2) + expect(harness.service.activationState(harness.service.findValidPlugin(pluginKey)!)).toBe( + 'disabled' + ) + expect(harness.service.workerState(pluginKey).state).toBe('inactive') + }) }) diff --git a/src/main/plugins/plugin-service.ts b/src/main/plugins/plugin-service.ts index 4ac6927f113..9d387dc71a6 100644 --- a/src/main/plugins/plugin-service.ts +++ b/src/main/plugins/plugin-service.ts @@ -34,6 +34,8 @@ import type { PluginRunState } from './plugin-supervisor' import { isPluginApproved, snapshotPluginConsentLists } from './plugin-activation-policy' import { PluginContentPackRegistry } from './plugin-content-pack-registry' import type { PluginServiceOptions } from './plugin-service-options' +import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event' +import { waitForPluginRefreshSettlement } from './plugin-refresh-settlement' export type { PluginRuntimeDelegate } from './plugin-host-service-bindings' export type { PluginLogLine } from './plugin-log-buffer' @@ -49,7 +51,7 @@ export class PluginService { private readonly contentVerifier = new PluginContentVerifier() readonly contentPacks: PluginContentPackRegistry readonly panels: PluginPanelController - private readonly changeListeners = new Set<() => void>() + private readonly changeListeners = new Set<(event: PluginChangeEvent) => void>() private readonly housekeeping = new PluginServiceHousekeeping() private discovered: DiscoveredPlugin[] = [] private runtimeDelegate: PluginRuntimeDelegate | null = null @@ -89,7 +91,7 @@ export class PluginService { executeHostCall: (pluginKey, method, params) => this.executeHostCall(pluginKey, method, params, { viaPanel: false }), log: (pluginKey, level, line) => this.logBuffer.append(pluginKey, level, line), - onStateChanged: () => this.notifyChanged(), + onStateChanged: () => this.notifyChanged(false), onWorkerGone: (pluginKey) => this.eventBus.clear(pluginKey) }) } @@ -98,14 +100,14 @@ export class PluginService { this.runtimeDelegate = delegate } - onChanged(listener: () => void): () => void { + onChanged(listener: (event: PluginChangeEvent) => void): () => void { this.changeListeners.add(listener) return () => this.changeListeners.delete(listener) } - private notifyChanged(): void { + private notifyChanged(contentPacksChanged: boolean): void { for (const listener of this.changeListeners) { - listener() + listener({ contentPacksChanged }) } } @@ -116,6 +118,9 @@ export class PluginService { async whenReady(): Promise { await (this.initPromise ?? Promise.resolve()).catch(() => undefined) + // Client reads wait for the complete transaction so rollback-based content + // validation cannot expose a partially activated plugin between passes. + await waitForPluginRefreshSettlement(() => this.refreshChain) } refresh(): Promise { @@ -162,7 +167,7 @@ export class PluginService { isPluginApproved(enabled, plugin, consentLists) ) // Notify before slow shutdown so feature-off unmounts panels immediately. - this.notifyChanged() + this.notifyChanged(true) await this.workerController.reconcile(nextSpecs) if (this.disposed) { return @@ -173,7 +178,7 @@ export class PluginService { reapIdle: () => this.workerController.reapIdle(), refresh: () => void this.refresh() }) - this.notifyChanged() + this.notifyChanged(false) } getDiscovered(): readonly DiscoveredPlugin[] { @@ -298,12 +303,18 @@ export class PluginService { async deactivatePlugin(pluginKey: string): Promise { await this.workerController.deactivate(pluginKey) - this.notifyChanged() + this.notifyChanged(false) } /** Reconciles live workers and client projections after consent or * enablement changes without re-reading plugin files or starting workers. */ async reconcileActivationState(): Promise { + const reconcile = this.refreshChain.then(() => this.performActivationStateReconciliation()) + this.refreshChain = reconcile.catch(() => undefined) + return reconcile + } + + private async performActivationStateReconciliation(): Promise { await this.contentPacks.reconcile(this.discovered, (plugin) => { return this.activationState(plugin) === 'approved' }) @@ -312,7 +323,7 @@ export class PluginService { (plugin) => this.activationState(plugin) === 'approved' ) await this.workerController.reconcile(nextSpecs) - this.notifyChanged() + this.notifyChanged(true) } async dispose(): Promise { diff --git a/src/main/plugins/plugin-terminal-theme-registry.test.ts b/src/main/plugins/plugin-terminal-theme-registry.test.ts new file mode 100644 index 00000000000..d367e9b4383 --- /dev/null +++ b/src/main/plugins/plugin-terminal-theme-registry.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { PluginContentVerifier } from './plugin-content-integrity' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { PluginTerminalThemeRegistry } from './plugin-terminal-theme-registry' + +const roots: string[] = [] + +async function pluginWithTerminalTheme(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-terminal-theme-')) + roots.push(rootDir) + await mkdir(join(rootDir, 'terminal')) + await writeFile( + join(rootDir, 'terminal', 'nord.json'), + JSON.stringify({ + schemaVersion: 1, + mode: 'dark', + terminal: { + background: '#101010', + foreground: '#f0f0f0', + black: '#000000', + red: '#ff0000' + } + }) + ) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'terminal', + publisher: 'orca-samples', + name: 'Terminal', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + terminalThemes: [{ id: 'nord', label: 'Nord Terminal', path: 'terminal/nord.json' }] + }, + capabilities: [] + }) + return { + pluginKey: 'orca-samples.terminal', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: true + } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginTerminalThemeRegistry', () => { + it('loads and removes approved terminal palettes', async () => { + const plugin = await pluginWithTerminalTheme() + const registry = new PluginTerminalThemeRegistry(new PluginContentVerifier()) + + await registry.reconcile([plugin], () => true) + expect(registry.list()).toEqual([ + expect.objectContaining({ + id: 'plugin:orca-samples.terminal/nord', + label: 'Nord Terminal', + mode: 'dark', + terminal: expect.objectContaining({ background: '#101010', red: '#ff0000' }) + }) + ]) + + await registry.reconcile([plugin], () => false) + expect(registry.list()).toEqual([]) + }) +}) diff --git a/src/main/plugins/plugin-terminal-theme-registry.ts b/src/main/plugins/plugin-terminal-theme-registry.ts new file mode 100644 index 00000000000..218b66496cc --- /dev/null +++ b/src/main/plugins/plugin-terminal-theme-registry.ts @@ -0,0 +1,84 @@ +import { + parsePluginTerminalThemeArtifact, + type PluginTerminalThemeRegistration +} from '../../shared/plugins/plugin-terminal-theme-artifact' +import { + PLUGIN_TERMINAL_THEME_MAX_BYTES, + readContainedPluginArtifactText +} from './plugin-artifact-validation' +import type { PluginContentVerifier } from './plugin-content-integrity' +import { mapPluginContentWithConcurrency } from './plugin-content-load-pool' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' + +const TERMINAL_THEME_LOAD_CONCURRENCY = 4 + +type TerminalThemeLoadResult = + | { pluginKey: string; themes: PluginTerminalThemeRegistration[] } + | { pluginKey: string; error: string } + +export class PluginTerminalThemeRegistry { + private themes: PluginTerminalThemeRegistration[] = [] + private readonly errors = new Map() + + constructor(private readonly contentVerifier: PluginContentVerifier) {} + + list(): readonly PluginTerminalThemeRegistration[] { + return this.themes + } + + error(pluginKey: string): string | null { + return this.errors.get(pluginKey) ?? null + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ): Promise { + const candidates = discovered.filter( + (plugin): plugin is ValidDiscoveredPlugin => + !isInvalidDiscoveredPlugin(plugin) && + isApproved(plugin) && + plugin.manifest.contributes.terminalThemes.length > 0 + ) + const results = await mapPluginContentWithConcurrency( + candidates, + TERMINAL_THEME_LOAD_CONCURRENCY, + async (plugin): Promise => { + try { + await this.contentVerifier.verify(plugin) + const themes = await Promise.all( + plugin.manifest.contributes.terminalThemes.map(async (contribution) => ({ + id: `plugin:${plugin.pluginKey}/${contribution.id}` as const, + pluginKey: plugin.pluginKey, + label: contribution.label, + ...parsePluginTerminalThemeArtifact( + await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_TERMINAL_THEME_MAX_BYTES + ) + ) + })) + ) + return { pluginKey: plugin.pluginKey, themes } + } catch (error) { + return { + pluginKey: plugin.pluginKey, + error: error instanceof Error ? error.message : String(error) + } + } + } + ) + this.themes = results.flatMap((result) => ('themes' in result ? result.themes : [])) + this.errors.clear() + for (const result of results) { + if ('error' in result) { + this.errors.set(result.pluginKey, result.error) + } + } + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2fc8e84fce9..6b64a280aba 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -60,6 +60,12 @@ import type { PluginSkillContributionMapping, PluginSkillStoreSnapshot } from '../shared/plugins/plugin-skill-store' +import type { + PluginIconThemeMetadata, + PluginIconThemeRegistration +} from '../shared/plugins/plugin-icon-theme-artifact' +import type { PluginTerminalThemeRegistration } from '../shared/plugins/plugin-terminal-theme-artifact' +import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' import type { LocalhostWorktreeLabelResult, LocalhostWorktreeLabelRoute @@ -3181,6 +3187,9 @@ export type PreloadApi = { list: () => Promise listThemes: () => Promise listLanguagePacks: () => Promise + listIconThemes: () => Promise + loadIconTheme: (id: string) => Promise + listTerminalThemes: () => Promise listSkillStore: () => Promise setSkillMapping: (mapping: PluginSkillContributionMapping) => Promise /** Records the consent-dialog answer; approval is keyed to the plugin's @@ -3210,8 +3219,8 @@ export type PreloadApi = { getLogs: (args: { pluginKey: string }) => Promise /** Re-discovers after settings edits (feature flag, dev paths). */ refresh: () => Promise - /** Fires whenever installed plugins, worker states, or panels change. */ - onChanged: (callback: () => void) => () => void + /** Fires whenever installed plugins, worker states, panels, or content packs change. */ + onChanged: (callback: (event: PluginChangeEvent) => void) => () => void } agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index eff52a40dfd..ae76579fb39 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,7 @@ import type { PluginPanelEntry } from '../shared/plugins/plugin-panel-bridge' import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' +import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' import type { BaseRefSearchResult, BaseRefDefaultResult, @@ -561,6 +562,9 @@ const api = { list: (): Promise => ipcRenderer.invoke('plugins:list'), listThemes: () => ipcRenderer.invoke('plugins:listThemes'), listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), + listIconThemes: () => ipcRenderer.invoke('plugins:listIconThemes'), + loadIconTheme: (id) => ipcRenderer.invoke('plugins:loadIconTheme', id), + listTerminalThemes: () => ipcRenderer.invoke('plugins:listTerminalThemes'), listSkillStore: () => ipcRenderer.invoke('plugins:listSkillStore'), setSkillMapping: (mapping) => ipcRenderer.invoke('plugins:setSkillMapping', mapping), consent: (args: PluginConsentRequest): Promise => @@ -588,8 +592,9 @@ const api = { getLogs: (args: { pluginKey: string }): Promise => ipcRenderer.invoke('plugins:getLogs', args), refresh: (): Promise => ipcRenderer.invoke('plugins:refresh'), - onChanged: (callback: () => void): (() => void) => { - const listener = (): void => callback() + onChanged: (callback): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, change: PluginChangeEvent): void => + callback(change) ipcRenderer.on('plugins:changed', listener) return () => { ipcRenderer.removeListener('plugins:changed', listener) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4305a081cce..39be5cd8810 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -131,6 +131,8 @@ import { import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility' import { applyDocumentTheme, applyPluginAppTheme } from './lib/document-theme' import { usePluginThemes } from './store/plugin-themes' +import { usePluginIconThemes, usePluginIconThemeStore } from './store/plugin-icon-themes' +import { usePluginTerminalThemes } from './store/plugin-terminal-themes' import { getSystemPrefersDark } from './lib/terminal-theme' import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance' import { isEditableTarget } from './lib/editable-target' @@ -655,14 +657,17 @@ function App(): React.JSX.Element { const isFullScreen = useAppStore((s) => s.isFullScreen) const settings = useAppStore((s) => s.settings) const pluginThemes = usePluginThemes() + usePluginIconThemes() + const pluginTerminalThemes = usePluginTerminalThemes() + const setActivePluginIconTheme = usePluginIconThemeStore((state) => state.setActiveId) const activePluginTheme = useMemo( () => pluginThemes.find((theme) => theme.id === settings?.pluginAppTheme) ?? null, [pluginThemes, settings?.pluginAppTheme] ) const systemPrefersDark = useSystemPrefersDark() const leftSidebarStyle = useMemo( - () => resolveLeftSidebarStyleVariables(settings, systemPrefersDark), - [settings, systemPrefersDark] + () => resolveLeftSidebarStyleVariables(settings, systemPrefersDark, pluginTerminalThemes), + [settings, systemPrefersDark, pluginTerminalThemes] ) as React.CSSProperties | undefined const dictationState = useAppStore((s) => s.dictationState) const hasSshCredentialRequest = useAppStore((s) => s.sshCredentialQueue.length > 0) @@ -672,6 +677,17 @@ function App(): React.JSX.Element { settings?.primarySelectionMiddleClickPaste ) usePrimarySelectionPaste(primarySelectionMiddleClickPaste) + + useEffect(() => { + setActivePluginIconTheme(settings?.pluginIconTheme ?? null) + }, [setActivePluginIconTheme, settings?.pluginIconTheme]) + + useEffect(() => { + // Why: plugin palette changes do not mutate settings, but paired clients + // and hidden terminals still need the newly effective terminal colors. + publishTerminalViewAttributesAtAppStart(useAppStore.getState().settings, getSystemPrefersDark()) + scheduleRuntimeGraphSync() + }, [pluginTerminalThemes]) useAppMenuPaste() useLargeTextControlPaste() const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) diff --git a/src/renderer/src/components/FileTypeIcon.test.tsx b/src/renderer/src/components/FileTypeIcon.test.tsx new file mode 100644 index 00000000000..2ab953e0255 --- /dev/null +++ b/src/renderer/src/components/FileTypeIcon.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { usePluginIconThemeStore } from '@/store/plugin-icon-themes' +import { FileTypeIcon } from './FileTypeIcon' +import { PluginIconSlot } from './PluginIconSlot' + +afterEach(() => { + usePluginIconThemeStore.setState({ + themes: [], + activeId: null, + activeTheme: null, + loaded: true + }) + document.body.innerHTML = '' +}) + +describe('FileTypeIcon', () => { + it('renders sanitized plugin SVG data as an image and reacts to theme removal', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const dataUrl = 'data:image/svg+xml;base64,PHN2Zy8+' + const activeTheme = { + id: 'plugin:acme.icons/main' as const, + pluginKey: 'acme.icons', + label: 'Acme', + icons: {}, + fileNames: {}, + fileExtensions: { ts: { dataUrl, rendering: 'image' as const } } + } + usePluginIconThemeStore.setState({ + activeId: 'plugin:acme.icons/main', + activeTheme, + loaded: true, + themes: [activeTheme] + }) + + await act(async () => root.render()) + expect(container.querySelector('img')?.getAttribute('src')).toBe(dataUrl) + expect(container.querySelector('svg')).toBeNull() + + await act(async () => usePluginIconThemeStore.setState({ themes: [], activeTheme: null })) + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + + await act(async () => root.unmount()) + }) + + it('renders host-defined plugin slots and falls back when the slot is absent', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const dataUrl = 'data:image/svg+xml;base64,PHN2Zy8+' + const activeTheme = { + id: 'plugin:acme.icons/main' as const, + pluginKey: 'acme.icons', + label: 'Acme', + icons: { 'sidebar.search': { dataUrl, rendering: 'image' as const } }, + fileNames: {}, + fileExtensions: {} + } + usePluginIconThemeStore.setState({ + activeId: 'plugin:acme.icons/main', + activeTheme, + loaded: true, + themes: [activeTheme] + }) + + await act(async () => + root.render( + } + className="size-4" + /> + ) + ) + expect(container.querySelector('img')?.getAttribute('src')).toBe(dataUrl) + + await act(async () => + root.render( + } /> + ) + ) + expect(container.querySelector('[data-fallback="true"]')).not.toBeNull() + await act(async () => root.unmount()) + }) + + it('renders currentColor SVGs as masks that inherit host color', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const dataUrl = 'data:image/svg+xml;base64,PHN2Zy8+' + const activeTheme = { + id: 'plugin:acme.icons/main' as const, + pluginKey: 'acme.icons', + label: 'Acme', + icons: {}, + fileNames: {}, + fileExtensions: { ts: { dataUrl, rendering: 'mask' as const } } + } + usePluginIconThemeStore.setState({ + activeId: activeTheme.id, + activeTheme, + loaded: true, + themes: [activeTheme] + }) + + await act(async () => + root.render() + ) + + const mask = container.querySelector('span[aria-hidden="true"]') + expect(mask?.style.maskImage).toContain(dataUrl) + expect(mask?.style.backgroundColor).toBe('currentcolor') + expect(mask?.style.color).toBe('rgb(255, 0, 0)') + expect(container.querySelector('img')).toBeNull() + await act(async () => root.unmount()) + }) +}) diff --git a/src/renderer/src/components/FileTypeIcon.tsx b/src/renderer/src/components/FileTypeIcon.tsx new file mode 100644 index 00000000000..08e830e7b70 --- /dev/null +++ b/src/renderer/src/components/FileTypeIcon.tsx @@ -0,0 +1,20 @@ +import type { CSSProperties } from 'react' +import { getFileTypeIcon, getPluginFileTypeIconImage } from '@/lib/file-type-icons' +import { usePluginIconThemeStore } from '@/store/plugin-icon-themes' +import { PluginIconImage } from './PluginIconImage' + +type FileTypeIconProps = { + filePath: string | null | undefined + className?: string + style?: CSSProperties +} + +export function FileTypeIcon({ filePath, className, style }: FileTypeIconProps): React.JSX.Element { + const activeTheme = usePluginIconThemeStore((state) => state.activeTheme) + const pluginIcon = getPluginFileTypeIconImage(activeTheme, filePath) + if (pluginIcon) { + return + } + const BuiltInIcon = getFileTypeIcon(filePath) + return