diff --git a/src/main/plugins/plugin-artifact-pack-registry.ts b/src/main/plugins/plugin-artifact-pack-registry.ts new file mode 100644 index 00000000000..1b9e979f619 --- /dev/null +++ b/src/main/plugins/plugin-artifact-pack-registry.ts @@ -0,0 +1,79 @@ +import type { PluginContentVerifier } from './plugin-content-integrity' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' + +const ARTIFACT_PACK_LOAD_CONCURRENCY = 4 + +type ArtifactPackLoadResult = + | { pluginKey: string; entries: Entry[] } + | { pluginKey: string; error: string } + +/** Loads one data contribution family from verified plugin trees. A plugin whose content fails + * verification or parsing contributes nothing and reports one error. */ +export class PluginArtifactPackRegistry { + private entries: Entry[] = [] + private readonly errors = new Map() + + constructor( + private readonly options: { + contentVerifier: PluginContentVerifier + contributions: (plugin: ValidDiscoveredPlugin) => readonly Contribution[] + load: (plugin: ValidDiscoveredPlugin, contribution: Contribution) => Promise + } + ) {} + + list(): readonly Entry[] { + return this.entries + } + + error(pluginKey: string): string | null { + return this.errors.get(pluginKey) ?? null + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ): Promise { + const candidates: ValidDiscoveredPlugin[] = [] + for (const plugin of discovered) { + if ( + !isInvalidDiscoveredPlugin(plugin) && + isApproved(plugin) && + this.options.contributions(plugin).length > 0 + ) { + candidates.push(plugin) + } + } + const results = await mapWithConcurrency( + candidates, + ARTIFACT_PACK_LOAD_CONCURRENCY, + async (plugin): Promise> => { + try { + await this.options.contentVerifier.verify(plugin) + const entries = await Promise.all( + this.options + .contributions(plugin) + .map((contribution) => this.options.load(plugin, contribution)) + ) + return { pluginKey: plugin.pluginKey, entries } + } catch (error) { + return { + pluginKey: plugin.pluginKey, + error: error instanceof Error ? error.message : String(error) + } + } + } + ) + this.entries = results.flatMap((result) => ('entries' in result ? result.entries : [])) + this.errors.clear() + for (const result of results) { + if ('error' in result) { + this.errors.set(result.pluginKey, result.error) + } + } + } +} diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts index 00e508b35af..72d4e085ccb 100644 --- a/src/main/plugins/plugin-artifact-validation.ts +++ b/src/main/plugins/plugin-artifact-validation.ts @@ -17,52 +17,28 @@ type DeclaredArtifact = | { label: string; path: string; kind: 'file'; maxBytes: number } | { label: string; path: string; kind: 'directory' } +function declaredFile(label: string, path: string | undefined, maxBytes: number) { + return path === undefined ? [] : [{ label, path, kind: 'file' as const, maxBytes }] +} + +/** One line per contribution family that names a file. */ function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] { + const c = manifest.contributes return [ - ...(manifest.icon - ? [ - { - label: 'icon', - path: manifest.icon, - kind: 'file' as const, - maxBytes: PLUGIN_ICON_MAX_BYTES - } - ] - : []), - ...(manifest.main - ? [ - { - label: 'worker entry', - path: manifest.main, - kind: 'file' as const, - maxBytes: PLUGIN_WORKER_ENTRY_MAX_BYTES - } - ] - : []), - ...manifest.contributes.panels.map((panel) => ({ - label: `panel "${panel.id}" entry`, - path: panel.entry, - kind: 'file' as const, - maxBytes: PLUGIN_PANEL_ENTRY_MAX_BYTES - })), - ...manifest.contributes.languagePacks.map((languagePack) => ({ - label: `language pack "${languagePack.locale}"`, - path: languagePack.path, - kind: 'file' as const, - maxBytes: PLUGIN_LANGUAGE_PACK_MAX_BYTES - })), - ...manifest.contributes.vmRecipes.map((recipe) => ({ - label: 'VM recipe', - path: recipe.path, - kind: 'file' as const, - maxBytes: PLUGIN_VM_RECIPE_MAX_BYTES - })), - ...manifest.contributes.agents.map((agent) => ({ - label: 'agent profile', - path: agent.path, - kind: 'file' as const, - maxBytes: PLUGIN_AGENT_PROFILE_MAX_BYTES - })) + ...declaredFile('icon', manifest.icon, PLUGIN_ICON_MAX_BYTES), + ...declaredFile('worker entry', manifest.main, PLUGIN_WORKER_ENTRY_MAX_BYTES), + ...c.panels.flatMap((panel) => + declaredFile(`panel "${panel.id}" entry`, panel.entry, PLUGIN_PANEL_ENTRY_MAX_BYTES) + ), + ...c.languagePacks.flatMap((pack) => + declaredFile(`language pack "${pack.locale}"`, pack.path, PLUGIN_LANGUAGE_PACK_MAX_BYTES) + ), + ...c.vmRecipes.flatMap((recipe) => + declaredFile('VM recipe', recipe.path, PLUGIN_VM_RECIPE_MAX_BYTES) + ), + ...c.agents.flatMap((agent) => + declaredFile('agent profile', agent.path, PLUGIN_AGENT_PROFILE_MAX_BYTES) + ) ] } diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts index b6e19d3b998..1e15d583f14 100644 --- a/src/main/plugins/plugin-content-pack-registry.ts +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -10,6 +10,14 @@ import { PluginCommandRegistry } from './plugin-command-registry' import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' import type { KeybindingOverrides } from '../../shared/keybindings' +type ContentFamily = { + reconcile: ( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ) => Promise + error: (pluginKey: string) => string | null +} + export class PluginContentPackRegistry { readonly languagePacks: PluginLanguagePackRegistry readonly vmRecipes: PluginVmRecipeRegistry @@ -70,10 +78,9 @@ export class PluginContentPackRegistry { approvedKeys.has(plugin.pluginKey) && !excluded.has(plugin.pluginKey) && !this.isKilled(plugin.pluginKey) - const languagePacks = this.languagePacks.reconcile(discovered, approveAtomically) - const vmRecipes = this.vmRecipes.reconcile(discovered, approveAtomically) - this.commands.reconcile(discovered, approveAtomically, keybindings) - await Promise.all([languagePacks, vmRecipes]) + await Promise.all( + this.families(keybindings).map((family) => family.reconcile(discovered, approveAtomically)) + ) let foundNewError = false for (const pluginKey of approvedKeys) { @@ -95,10 +102,25 @@ export class PluginContentPackRegistry { } private registryError(pluginKey: string): string | null { - return ( - this.languagePacks.error(pluginKey) ?? - this.vmRecipes.error(pluginKey) ?? - this.commands.error(pluginKey) - ) + for (const family of this.families()) { + const error = family.error(pluginKey) + if (error !== null) { + return error + } + } + return null + } + + /** Every contribution family, in error-reporting order; a new family is one entry here. */ + private families(keybindings: KeybindingOverrides = {}): readonly ContentFamily[] { + return [ + this.languagePacks, + this.vmRecipes, + { + reconcile: async (discovered, isApproved) => + this.commands.reconcile(discovered, isApproved, keybindings), + error: (pluginKey) => this.commands.error(pluginKey) + } + ] } } diff --git a/src/main/plugins/plugin-language-pack-registry.ts b/src/main/plugins/plugin-language-pack-registry.ts index df409264772..48db107f3ce 100644 --- a/src/main/plugins/plugin-language-pack-registry.ts +++ b/src/main/plugins/plugin-language-pack-registry.ts @@ -3,94 +3,43 @@ import { pluginLanguageResourceId, type PluginLanguagePackRegistration } from '../../shared/plugins/plugin-language-pack-artifact' +import type { PluginManifest } from '../../shared/plugins/plugin-manifest' import { PLUGIN_LANGUAGE_PACK_MAX_BYTES, readContainedPluginArtifactText } from './plugin-artifact-validation' +import { PluginArtifactPackRegistry } from './plugin-artifact-pack-registry' import type { PluginContentVerifier } from './plugin-content-integrity' -import { mapWithConcurrency } from '../../shared/map-with-concurrency' -import { - isInvalidDiscoveredPlugin, - type DiscoveredPlugin, - type ValidDiscoveredPlugin -} from './plugin-discovery' -const LANGUAGE_PACK_LOAD_CONCURRENCY = 4 +type LanguagePackContribution = PluginManifest['contributes']['languagePacks'][number] -type LanguageLoadResult = - | { pluginKey: string; packs: PluginLanguagePackRegistration[] } - | { pluginKey: string; error: string } - -export class PluginLanguagePackRegistry { - private packs: PluginLanguagePackRegistration[] = [] - private readonly errors = new Map() - - constructor(private readonly contentVerifier: PluginContentVerifier) {} - - list(): readonly PluginLanguagePackRegistration[] { - return this.packs - } - - error(pluginKey: string): string | null { - return this.errors.get(pluginKey) ?? null - } - - async reconcile( - discovered: readonly DiscoveredPlugin[], - isApproved: (plugin: ValidDiscoveredPlugin) => boolean - ): Promise { - const candidates: ValidDiscoveredPlugin[] = [] - for (const plugin of discovered) { - if ( - !isInvalidDiscoveredPlugin(plugin) && - isApproved(plugin) && - plugin.manifest.contributes.languagePacks.length > 0 - ) { - candidates.push(plugin) - } - } - const results = await mapWithConcurrency( - candidates, - LANGUAGE_PACK_LOAD_CONCURRENCY, - async (plugin): Promise => { - try { - await this.contentVerifier.verify(plugin) - const packs = await Promise.all( - plugin.manifest.contributes.languagePacks.map(async (contribution) => { - const text = await readContainedPluginArtifactText( - plugin.rootDir, - contribution.path, - PLUGIN_LANGUAGE_PACK_MAX_BYTES - ) - const parsed = parsePluginLanguagePackArtifact(text) - if (!parsed.ok) { - throw new Error(`language pack "${contribution.locale}" ${parsed.error}`) - } - const id = `plugin:${plugin.pluginKey}/${contribution.locale}` as const - return { - id, - resourceLanguage: pluginLanguageResourceId(id), - pluginKey: plugin.pluginKey, - locale: contribution.locale, - catalog: parsed.catalog - } - }) - ) - return { pluginKey: plugin.pluginKey, packs } - } catch (error) { - return { - pluginKey: plugin.pluginKey, - error: error instanceof Error ? error.message : String(error) - } +export class PluginLanguagePackRegistry extends PluginArtifactPackRegistry< + LanguagePackContribution, + PluginLanguagePackRegistration +> { + constructor(contentVerifier: PluginContentVerifier) { + super({ + contentVerifier, + contributions: (plugin) => plugin.manifest.contributes.languagePacks, + load: async (plugin, contribution) => { + const text = await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_LANGUAGE_PACK_MAX_BYTES + ) + const parsed = parsePluginLanguagePackArtifact(text) + if (!parsed.ok) { + throw new Error(`language pack "${contribution.locale}" ${parsed.error}`) + } + const id = `plugin:${plugin.pluginKey}/${contribution.locale}` as const + return { + id, + resourceLanguage: pluginLanguageResourceId(id), + pluginKey: plugin.pluginKey, + locale: contribution.locale, + catalog: parsed.catalog } } - ) - this.packs = results.flatMap((result) => ('packs' in result ? result.packs : [])) - this.errors.clear() - for (const result of results) { - if ('error' in result) { - this.errors.set(result.pluginKey, result.error) - } - } + }) } } diff --git a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx index 162e2ab8d23..1f127910f82 100644 --- a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx +++ b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx @@ -68,7 +68,7 @@ vi.mock('@/store', () => ({ vi.mock('@/store/plugin-language-packs', () => ({ usePluginLanguagePackStore: (selector: (s: Record) => unknown) => - selector({ packs: [], loaded: true }) + selector({ entries: [], loaded: true }) })) vi.mock('@/i18n/i18n', () => ({ diff --git a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts index f278616a5e4..ca4999389fa 100644 --- a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts +++ b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts @@ -26,7 +26,7 @@ export function useMacTccAttributionSeveredNotice(): void { const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery) const uiLanguage = useAppStore((s) => s.settings?.uiLanguage ?? null) - const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.packs) + const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.entries) const pluginLanguagePacksLoaded = usePluginLanguagePackStore((s) => s.loaded) const { i18n } = useTranslation() const selectedPluginLanguage = pluginLanguagePacks.find((pack) => pack.id === uiLanguage) diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx index 47becbaa7e0..36f75750865 100644 --- a/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx @@ -89,7 +89,7 @@ it('isolates plugin language-pack discovery from its parent render path', async root?.render(createElement(I18nextProvider, { i18n }, createElement(ParentProbe))) }) await act(async () => { - usePluginLanguagePackStore.setState({ packs: [], loaded: true }) + usePluginLanguagePackStore.setState({ entries: [], loaded: true }) }) expect(parentRenderCount).toBe(1) diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.ts b/src/renderer/src/hooks/useMacosTccPromptNotice.ts index e62464a0b6f..087bb1dbb97 100644 --- a/src/renderer/src/hooks/useMacosTccPromptNotice.ts +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.ts @@ -20,7 +20,7 @@ export function useMacosTccPromptNotice(): void { const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const uiLanguage = useAppStore((s) => s.settings?.uiLanguage ?? null) - const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.packs) + const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.entries) const pluginLanguagePacksLoaded = usePluginLanguagePackStore((s) => s.loaded) const { i18n } = useTranslation() const selectedPluginLanguage = pluginLanguagePacks.find((pack) => pack.id === uiLanguage) diff --git a/src/renderer/src/store/plugin-content-list-store.ts b/src/renderer/src/store/plugin-content-list-store.ts new file mode 100644 index 00000000000..1a2efd43cdd --- /dev/null +++ b/src/renderer/src/store/plugin-content-list-store.ts @@ -0,0 +1,97 @@ +import { useEffect } from 'react' +import { create } from 'zustand' +import type { PluginsApi } from '../../../preload/api/plugin-host-api' + +type PluginContentListState = { + entries: Entry[] + loaded: boolean + fetchEntries: () => Promise +} + +/** + * Joining the startup request saves a duplicate IPC round trip, but its handler awaits plugin + * discovery — a wedged one must not turn `ensureLoaded` into a no-op for the session, which would + * pin every consumer on built-in content. Past this bound the pending request is wedged rather than + * slow, so a later consumer starts its own (mirrors the reasoning in + * `src/main/git/coalesced-probe.ts`). + */ +const STARTUP_REQUEST_JOIN_WINDOW_MS = 10_000 + +/** One renderer list of a plugin content family, refetched whenever plugin content changes. */ +export function createPluginContentListStore(options: { + /** Plural noun for logs, e.g. "language packs". */ + kind: string + list: (api: PluginsApi) => (() => Promise) | undefined + isEntry: (value: unknown) => value is Entry +}) { + let requestGeneration = 0 + let latestRequestStartedAt: number | null = null + let changeSubscriptionStarted = false + + const useStore = create>()((set) => ({ + entries: [], + loaded: false, + fetchEntries: async () => { + const generation = ++requestGeneration + latestRequestStartedAt = Date.now() + const api = window.api?.plugins + const list = api ? options.list(api) : undefined + if (!list) { + if (generation === requestGeneration) { + latestRequestStartedAt = null + set({ entries: [], loaded: true }) + } + return + } + try { + const response = await list() + const entries = Array.isArray(response) ? response.filter(options.isEntry) : [] + // Why: a non-array response and a rejected member are different upstream bugs; keep them distinguishable in the log. + if (!Array.isArray(response)) { + console.warn(`[plugins] Ignoring non-array ${options.kind} list (${typeof response})`) + } else if (entries.length !== response.length) { + console.warn( + `[plugins] Ignoring ${response.length - entries.length} of ${response.length} malformed ${options.kind}` + ) + } + if (generation === requestGeneration) { + set({ entries, loaded: true }) + } + } catch { + if (generation === requestGeneration) { + set({ entries: [], loaded: true }) + } + } finally { + if (generation === requestGeneration) { + latestRequestStartedAt = null + } + } + } + })) + + function ensureLoaded(): void { + const state = useStore.getState() + const joinable = + latestRequestStartedAt !== null && + Date.now() - latestRequestStartedAt < STARTUP_REQUEST_JOIN_WINDOW_MS + if (!state.loaded && !joinable) { + void state.fetchEntries() + } + if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) { + changeSubscriptionStarted = true + window.api.plugins.onChanged((event) => { + if (event?.contentPacksChanged ?? true) { + void useStore.getState().fetchEntries() + } + }) + } + } + + function useEntries(): Entry[] { + const entries = useStore((state) => state.entries) + useEffect(() => ensureLoaded(), []) + return entries + } + + return { useStore, ensureLoaded, useEntries } +} diff --git a/src/renderer/src/store/plugin-language-packs-startup.test.ts b/src/renderer/src/store/plugin-language-packs-startup.test.ts index 6cb5a9c5711..2f74f677f8b 100644 --- a/src/renderer/src/store/plugin-language-packs-startup.test.ts +++ b/src/renderer/src/store/plugin-language-packs-startup.test.ts @@ -77,8 +77,8 @@ describe('plugin language-pack startup ownership', () => { async (settlement) => { const bridge = installBridge() const store = languagePacks.usePluginLanguagePackStore - const older = store.getState().fetchPacks() - const newer = store.getState().fetchPacks() + const older = store.getState().fetchEntries() + const newer = store.getState().fetchEntries() if (settlement === 'resolve') { bridge.requests[0].resolve([pack('old')]) } else { @@ -93,7 +93,7 @@ describe('plugin language-pack startup ownership', () => { } await newer expect(dispatches).toBe(2) - expect(store.getState().packs).toEqual([pack('current')]) + expect(store.getState().entries).toEqual([pack('current')]) } ) @@ -110,7 +110,7 @@ describe('plugin language-pack startup ownership', () => { await bridge.requests[0].promise languagePacks.ensurePluginLanguagePacksLoaded() expect(bridge.listLanguagePacks).toHaveBeenCalledTimes(2) - expect(languagePacks.usePluginLanguagePackStore.getState().packs).toEqual([pack('changed')]) + expect(languagePacks.usePluginLanguagePackStore.getState().entries).toEqual([pack('changed')]) }) it('keeps the shared startup request alive after the first consumer unmounts', async () => { @@ -146,7 +146,9 @@ describe('plugin language-pack startup ownership', () => { await vi.waitFor(() => expect(languagePacks.usePluginLanguagePackStore.getState().loaded).toBe(true) ) - expect(languagePacks.usePluginLanguagePackStore.getState().packs).toEqual([pack('recovered')]) + expect(languagePacks.usePluginLanguagePackStore.getState().entries).toEqual([ + pack('recovered') + ]) } finally { vi.useRealTimers() } @@ -155,17 +157,17 @@ describe('plugin language-pack startup ownership', () => { it('retains fail-closed loading and permits an explicit retry after rejection', async () => { const bridge = installBridge() const store = languagePacks.usePluginLanguagePackStore - const failed = store.getState().fetchPacks() + const failed = store.getState().fetchEntries() bridge.requests[0].reject(new Error('bridge unavailable')) await failed expect(store.getState().loaded).toBe(true) - expect(store.getState().packs).toEqual([]) + expect(store.getState().entries).toEqual([]) languagePacks.ensurePluginLanguagePacksLoaded() expect(bridge.listLanguagePacks).toHaveBeenCalledTimes(1) - const retry = store.getState().fetchPacks() + const retry = store.getState().fetchEntries() bridge.requests[1].resolve([pack('retry')]) await retry - expect(store.getState().packs).toEqual([pack('retry')]) + expect(store.getState().entries).toEqual([pack('retry')]) }) it('finishes without an older bridge API and admits a later explicit refresh', async () => { @@ -173,10 +175,10 @@ describe('plugin language-pack startup ownership', () => { languagePacks.ensurePluginLanguagePacksLoaded() expect(languagePacks.usePluginLanguagePackStore.getState().loaded).toBe(true) const bridge = installBridge() - const refresh = languagePacks.usePluginLanguagePackStore.getState().fetchPacks() + const refresh = languagePacks.usePluginLanguagePackStore.getState().fetchEntries() bridge.requests[0].resolve([pack('available')]) await refresh expect(bridge.listLanguagePacks).toHaveBeenCalledTimes(1) - expect(languagePacks.usePluginLanguagePackStore.getState().packs).toEqual([pack('available')]) + expect(languagePacks.usePluginLanguagePackStore.getState().entries).toEqual([pack('available')]) }) }) diff --git a/src/renderer/src/store/plugin-language-packs.test.ts b/src/renderer/src/store/plugin-language-packs.test.ts index 53a608d89f4..3cfb6c49498 100644 --- a/src/renderer/src/store/plugin-language-packs.test.ts +++ b/src/renderer/src/store/plugin-language-packs.test.ts @@ -8,7 +8,7 @@ import { usePluginLanguagePackStore } from './plugin-language-packs' afterEach(() => { vi.restoreAllMocks() setRendererPluginLanguagePacks([]) - usePluginLanguagePackStore.setState({ packs: [], loaded: false }) + usePluginLanguagePackStore.setState({ entries: [], loaded: false }) vi.unstubAllGlobals() }) @@ -22,12 +22,12 @@ describe('plugin language pack loading', () => { } }) - await usePluginLanguagePackStore.getState().fetchPacks() + await usePluginLanguagePackStore.getState().fetchEntries() const state = usePluginLanguagePackStore.getState() expect(state.loaded).toBe(true) - expect(state.packs).toEqual([]) - expect(() => state.packs.find(() => true)).not.toThrow() + expect(state.entries).toEqual([]) + expect(() => state.entries.find(() => true)).not.toThrow() }) it('filters malformed language-pack array members without changing valid order', async () => { @@ -55,9 +55,9 @@ describe('plugin language pack loading', () => { } }) - await usePluginLanguagePackStore.getState().fetchPacks() + await usePluginLanguagePackStore.getState().fetchEntries() - expect(usePluginLanguagePackStore.getState().packs).toEqual([first, second]) + expect(usePluginLanguagePackStore.getState().entries).toEqual([first, second]) }) it.each([ @@ -81,9 +81,9 @@ describe('plugin language pack loading', () => { } }) - await usePluginLanguagePackStore.getState().fetchPacks() + await usePluginLanguagePackStore.getState().fetchEntries() - expect(usePluginLanguagePackStore.getState().packs).toEqual([]) + expect(usePluginLanguagePackStore.getState().entries).toEqual([]) }) it('rejects a structured-cloned cyclic catalog before i18next resource installation', async () => { @@ -103,8 +103,8 @@ describe('plugin language pack loading', () => { api: { plugins: { listLanguagePacks: vi.fn().mockResolvedValue(response) } } }) - await usePluginLanguagePackStore.getState().fetchPacks() - const packs = usePluginLanguagePackStore.getState().packs + await usePluginLanguagePackStore.getState().fetchEntries() + const packs = usePluginLanguagePackStore.getState().entries expect(() => setRendererPluginLanguagePacks(packs)).not.toThrow() expect(packs).toEqual([]) @@ -127,8 +127,8 @@ describe('plugin language pack loading', () => { } }) - await usePluginLanguagePackStore.getState().fetchPacks() - setRendererPluginLanguagePacks(usePluginLanguagePackStore.getState().packs) + await usePluginLanguagePackStore.getState().fetchEntries() + setRendererPluginLanguagePacks(usePluginLanguagePackStore.getState().entries) const removeResourceBundle = vi .spyOn(i18n, 'removeResourceBundle') .mockImplementation((language) => { @@ -156,8 +156,8 @@ describe('plugin language pack loading', () => { } }) - await usePluginLanguagePackStore.getState().fetchPacks() - const packs = usePluginLanguagePackStore.getState().packs + await usePluginLanguagePackStore.getState().fetchEntries() + const packs = usePluginLanguagePackStore.getState().entries expect(() => setRendererPluginLanguagePacks(packs)).not.toThrow() expect(packs).toEqual([]) @@ -193,11 +193,11 @@ describe('plugin language pack loading', () => { } }) - const olderFetch = usePluginLanguagePackStore.getState().fetchPacks() - await usePluginLanguagePackStore.getState().fetchPacks() + const olderFetch = usePluginLanguagePackStore.getState().fetchEntries() + await usePluginLanguagePackStore.getState().fetchEntries() resolveOlder([older]) await olderFetch - expect(usePluginLanguagePackStore.getState().packs).toEqual([latest]) + expect(usePluginLanguagePackStore.getState().entries).toEqual([latest]) }) }) diff --git a/src/renderer/src/store/plugin-language-packs.ts b/src/renderer/src/store/plugin-language-packs.ts index 009ab459612..20b7c672840 100644 --- a/src/renderer/src/store/plugin-language-packs.ts +++ b/src/renderer/src/store/plugin-language-packs.ts @@ -1,89 +1,15 @@ -import { useEffect } from 'react' -import { create } from 'zustand' import { isPluginLanguagePackRegistration, type PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact' +import { createPluginContentListStore } from './plugin-content-list-store' -type PluginLanguagePackState = { - packs: PluginLanguagePackRegistration[] - loaded: boolean - fetchPacks: () => Promise -} +const languagePacks = createPluginContentListStore({ + kind: 'language packs', + list: (api) => api.listLanguagePacks, + isEntry: isPluginLanguagePackRegistration +}) -/** - * Joining the startup request saves a duplicate IPC round trip, but its handler awaits plugin - * discovery — a wedged one must not turn `ensurePluginLanguagePacksLoaded` into a no-op for the - * session, which would pin every consumer on built-in translations. Past this bound the pending - * request is wedged rather than slow, so a later consumer starts its own (mirrors the reasoning in - * `src/main/git/coalesced-probe.ts`). - */ -const STARTUP_REQUEST_JOIN_WINDOW_MS = 10_000 - -let requestGeneration = 0 -let latestRequestStartedAt: number | null = null -let changeSubscriptionStarted = false - -export const usePluginLanguagePackStore = create()((set) => ({ - packs: [], - loaded: false, - fetchPacks: async () => { - const generation = ++requestGeneration - latestRequestStartedAt = Date.now() - const api = window.api?.plugins - if (!api?.listLanguagePacks) { - if (generation === requestGeneration) { - latestRequestStartedAt = null - set({ packs: [], loaded: true }) - } - return - } - try { - const response = await api.listLanguagePacks() - const packs = Array.isArray(response) ? response.filter(isPluginLanguagePackRegistration) : [] - // Why: a non-array response and a rejected member are different upstream bugs; keep them distinguishable in the log. - if (!Array.isArray(response)) { - console.warn(`[plugins] Ignoring non-array language-pack list (${typeof response})`) - } else if (packs.length !== response.length) { - console.warn( - `[plugins] Ignoring ${response.length - packs.length} of ${response.length} malformed language packs` - ) - } - if (generation === requestGeneration) { - set({ packs, loaded: true }) - } - } catch { - if (generation === requestGeneration) { - set({ packs: [], loaded: true }) - } - } finally { - if (generation === requestGeneration) { - latestRequestStartedAt = null - } - } - } -})) - -export function ensurePluginLanguagePacksLoaded(): void { - const state = usePluginLanguagePackStore.getState() - const joinable = - latestRequestStartedAt !== null && - Date.now() - latestRequestStartedAt < STARTUP_REQUEST_JOIN_WINDOW_MS - if (!state.loaded && !joinable) { - void state.fetchPacks() - } - if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) { - changeSubscriptionStarted = true - window.api.plugins.onChanged((event) => { - if (event?.contentPacksChanged ?? true) { - void usePluginLanguagePackStore.getState().fetchPacks() - } - }) - } -} - -export function usePluginLanguagePacks(): PluginLanguagePackRegistration[] { - const packs = usePluginLanguagePackStore((state) => state.packs) - useEffect(() => ensurePluginLanguagePacksLoaded(), []) - return packs -} +export const usePluginLanguagePackStore = languagePacks.useStore +export const ensurePluginLanguagePacksLoaded = languagePacks.ensureLoaded +export const usePluginLanguagePacks = languagePacks.useEntries diff --git a/src/shared/plugins/plugin-manifest.ts b/src/shared/plugins/plugin-manifest.ts index 5c30d974a65..8115006f415 100644 --- a/src/shared/plugins/plugin-manifest.ts +++ b/src/shared/plugins/plugin-manifest.ts @@ -113,15 +113,8 @@ const pluginManifestV1Object = z.object({ .default([]) }) .strict() - .default(() => ({ - panels: [], - commands: [], - events: [], - languagePacks: [], - keybindings: [], - vmRecipes: [], - agents: [] - })), + // Why prefault: runs `{}` through the schema, so every family's own default applies. + .prefault({}), capabilities: z.array(pluginCapabilitySchema).max(32).default([]) })