mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
refactor(plugins): extract the shared content-pack registry and list store (#30697)
* plugins: extract the verified artifact-pack registry from language packs Pure refactor. Verify, bounded read, parse and per-plugin error handling move into PluginArtifactPackRegistry; language packs become one instance, so later data families reuse it instead of copying the file. * plugins: reconcile content families from one list Pure refactor. Reconcile and error reporting walk one family list in the same order as before, so a new family is one entry. * plugins: one line per family for declared artifacts and contribution defaults Pure refactor. Declared artifact paths are a list of per-family rows, and the contributes default comes from the schema (prefault) instead of a second copy of the field list; the existing defaults test pins the result. * plugins: extract the renderer plugin content list store Pure refactor. The fetch generation, startup join window and content-change subscription move into createPluginContentListStore; language packs become one instance, and store fields are renamed to entries/fetchEntries.
This commit is contained in:
@@ -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<Entry> =
|
||||
| { 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<Contribution, Entry> {
|
||||
private entries: Entry[] = []
|
||||
private readonly errors = new Map<string, string>()
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
contentVerifier: PluginContentVerifier
|
||||
contributions: (plugin: ValidDiscoveredPlugin) => readonly Contribution[]
|
||||
load: (plugin: ValidDiscoveredPlugin, contribution: Contribution) => Promise<Entry>
|
||||
}
|
||||
) {}
|
||||
|
||||
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<void> {
|
||||
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<ArtifactPackLoadResult<Entry>> => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>
|
||||
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)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>()
|
||||
|
||||
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<void> {
|
||||
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<LanguageLoadResult> => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ vi.mock('@/store', () => ({
|
||||
|
||||
vi.mock('@/store/plugin-language-packs', () => ({
|
||||
usePluginLanguagePackStore: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({ packs: [], loaded: true })
|
||||
selector({ entries: [], loaded: true })
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import type { PluginsApi } from '../../../preload/api/plugin-host-api'
|
||||
|
||||
type PluginContentListState<Entry> = {
|
||||
entries: Entry[]
|
||||
loaded: boolean
|
||||
fetchEntries: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Entry>(options: {
|
||||
/** Plural noun for logs, e.g. "language packs". */
|
||||
kind: string
|
||||
list: (api: PluginsApi) => (() => Promise<unknown>) | undefined
|
||||
isEntry: (value: unknown) => value is Entry
|
||||
}) {
|
||||
let requestGeneration = 0
|
||||
let latestRequestStartedAt: number | null = null
|
||||
let changeSubscriptionStarted = false
|
||||
|
||||
const useStore = create<PluginContentListState<Entry>>()((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 }
|
||||
}
|
||||
@@ -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')])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
const languagePacks = createPluginContentListStore<PluginLanguagePackRegistration>({
|
||||
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<PluginLanguagePackState>()((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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user