plugins: show the community catalog by default (#36294)

This commit is contained in:
Neil
2026-10-30 01:20:40 -07:00
committed by GitHub
parent ce7ca7f9a0
commit 230166b9cd
7 changed files with 179 additions and 1 deletions
+3 -1
View File
@@ -31,7 +31,8 @@ function createServices(): PluginMarketplaceHandlerServices {
refreshSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }),
refreshAll: vi.fn().mockResolvedValue([{ id: SOURCE_ID }]),
listPlugins: vi.fn().mockResolvedValue([{ pluginKey: PLUGIN_KEY }]),
seedOfficialSource: vi.fn().mockResolvedValue({ id: SOURCE_ID })
seedOfficialSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }),
seedCommunitySource: vi.fn().mockResolvedValue(undefined)
} as unknown as PluginMarketplaceService,
installer: {
preview: vi.fn().mockResolvedValue({ pluginKey: PLUGIN_KEY }),
@@ -144,6 +145,7 @@ describe('plugin marketplace IPC authority', () => {
expect(services.marketplace.listSources).toHaveBeenCalledTimes(2)
// The startup seed is deferred, so opening the marketplace seeds the official catalog.
expect(services.marketplace.seedOfficialSource).toHaveBeenCalledTimes(2)
expect(services.marketplace.seedCommunitySource).toHaveBeenCalledTimes(2)
expect(services.marketplace.addSource).toHaveBeenCalledWith(source)
expect(services.marketplace.removeSource).toHaveBeenCalledWith(SOURCE_ID)
expect(services.marketplace.refreshSource).toHaveBeenCalledWith(SOURCE_ID)
+1
View File
@@ -78,6 +78,7 @@ export function registerPluginMarketplaceHandlers(
const ensureOfficialSeed = (): void => {
if (getSettings().pluginSystemEnabled === true) {
void services.marketplace.seedOfficialSource().catch(() => undefined)
void services.marketplace.seedCommunitySource().catch(() => undefined)
}
}
ipcMain.handle('plugins:listMarketplaces', () => {
@@ -0,0 +1,69 @@
import { COMMUNITY_MARKETPLACE_GIT_SOURCE } from '../../../shared/plugins/catalog/plugin-install-link'
import {
isSameGitRepository,
type PluginMarketplaceGitSource
} from '../../../shared/plugins/catalog/plugin-marketplace'
import type { PluginMarketplaceStore } from './plugin-marketplace-store'
// Why throttled: listings wait on an in-flight seed, so an offline retry must not stall every view.
const COMMUNITY_SEED_RETRY_MS = 10 * 60 * 1000
/**
* Adds the community catalog once, so Orca shows it by default. It stays non-official, so its
* plugins keep the publisher notice and install review; a user who removes it keeps it removed.
*/
export class CommunityMarketplaceSeed {
private inFlight: Promise<void> | null = null
private failedAt: number | null = null
constructor(
private readonly store: Pick<
PluginMarketplaceStore,
'hasSeededDefault' | 'markDefaultSeeded' | 'listSources'
>,
private readonly addSource: (source: PluginMarketplaceGitSource) => Promise<unknown>
) {}
run(now = Date.now()): Promise<void> {
if (this.inFlight) {
return this.inFlight
}
if (this.failedAt !== null && now - this.failedAt < COMMUNITY_SEED_RETRY_MS) {
return Promise.resolve()
}
const seed = this.perform()
.then(() => {
this.failedAt = null
})
.catch((error: unknown) => {
this.failedAt = now
throw error
})
.finally(() => {
if (this.inFlight === seed) {
this.inFlight = null
}
})
this.inFlight = seed
return seed
}
/** Settles when a running seed does, so the first view after it already lists the catalog. */
async settled(): Promise<void> {
await this.inFlight?.catch(() => undefined)
}
private async perform(): Promise<void> {
if (await this.store.hasSeededDefault('community')) {
return
}
const sources = await this.store.listSources()
const present = sources.some((source) =>
isSameGitRepository(source.source.url, COMMUNITY_MARKETPLACE_GIT_SOURCE.url)
)
if (!present) {
await this.addSource(COMMUNITY_MARKETPLACE_GIT_SOURCE)
}
await this.store.markDefaultSeeded('community')
}
}
@@ -7,6 +7,7 @@ import type {
PluginMarketplaceGitSource
} from '../../../shared/plugins/catalog/plugin-marketplace'
import { OFFICIAL_MARKETPLACE_GIT_SOURCE } from '../../../shared/plugins/catalog/plugin-marketplace'
import { COMMUNITY_MARKETPLACE_GIT_SOURCE } from '../../../shared/plugins/catalog/plugin-install-link'
import type { PluginMarketplaceFetchResult } from './plugin-marketplace-fetch'
import { PluginMarketplaceService } from './plugin-marketplace-service'
import {
@@ -333,6 +334,59 @@ describe('PluginMarketplaceService', () => {
expect(sources).toContainEqual(expect.objectContaining({ official: true }))
})
const communityMarketplace = (): PluginMarketplace => ({
...marketplace('Orca Community Plugins', 'acme.dusk', 'https://github.com/acme/dusk.git'),
owner: 'stablyai'
})
it('adds the community catalog once, as a non-official source, and keeps a removal', async () => {
const root = await tempRoot()
const fetcher = vi.fn(async () => fetched(communityMarketplace()))
const service = new PluginMarketplaceService({ pluginsDataDir: root, fetcher })
await service.seedCommunitySource()
const [community] = await service.listSources()
expect(community).toMatchObject({ source: COMMUNITY_MARKETPLACE_GIT_SOURCE, official: false })
await expect(service.listPlugins()).resolves.toEqual([
expect.objectContaining({ pluginKey: 'acme.dusk', official: false })
])
await service.removeSource(community!.id)
const restarted = new PluginMarketplaceService({ pluginsDataDir: root, fetcher })
await restarted.seedCommunitySource()
await expect(restarted.listSources()).resolves.toEqual([])
expect(fetcher).toHaveBeenCalledTimes(1)
})
it('does not add the community catalog twice when the user already added it', async () => {
const root = await tempRoot()
const fetcher = vi.fn(async () => fetched(communityMarketplace()))
const service = new PluginMarketplaceService({ pluginsDataDir: root, fetcher })
await service.addSource(source('https://github.com/StablyAI/orca-community-plugins'))
await service.seedCommunitySource()
await expect(service.listSources()).resolves.toHaveLength(1)
expect(fetcher).toHaveBeenCalledTimes(1)
})
it('retries an offline community seed only after a pause, and lists without it meanwhile', async () => {
const root = await tempRoot()
const fetcher = vi.fn(async (): Promise<PluginMarketplaceFetchResult> => {
throw new Error('offline')
})
const service = new PluginMarketplaceService({ pluginsDataDir: root, fetcher })
await expect(service.seedCommunitySource(1_000)).rejects.toThrow('offline')
await expect(service.seedCommunitySource(2_000)).resolves.toBeUndefined()
expect(fetcher).toHaveBeenCalledTimes(1)
await expect(service.listSources()).resolves.toEqual([])
fetcher.mockResolvedValueOnce(fetched(communityMarketplace()))
await service.seedCommunitySource(1_000 + 10 * 60 * 1000)
await expect(service.listSources()).resolves.toHaveLength(1)
})
it('removes source metadata and browse listings together', async () => {
const service = new PluginMarketplaceService({
pluginsDataDir: await tempRoot(),
@@ -19,6 +19,7 @@ import {
import type { PluginKillListEntry } from '../../../shared/plugins/catalog/plugin-kill-list'
import type { PluginImageCache } from '../plugin-image-cache'
import { validateMarketplaceProvenance } from './plugin-marketplace-provenance'
import { CommunityMarketplaceSeed } from './plugin-community-marketplace-seed'
import { pluginMarketplaceErrorMessage } from './plugin-marketplace-error-message'
import {
pluginMarketplaceListing,
@@ -44,6 +45,7 @@ export class PluginMarketplaceService {
private readonly sourceErrors = new Map<string, string>()
private officialSeedPromise: Promise<PluginMarketplaceSourceState> | null = null
private officialSeedRequested = false
private readonly communitySeed: CommunityMarketplaceSeed
constructor(options: {
pluginsDataDir: string
@@ -59,6 +61,9 @@ export class PluginMarketplaceService {
this.store = options.store ?? new PluginMarketplaceStore(options.pluginsDataDir)
this.fetcher = options.fetcher ?? fetchPluginMarketplace
this.getKillListEntry = options.getKillListEntry ?? (() => null)
this.communitySeed = new CommunityMarketplaceSeed(this.store, (source) =>
this.addSource(source)
)
}
async listSources(): Promise<PluginMarketplaceSourceState[]> {
@@ -135,6 +140,11 @@ export class PluginMarketplaceService {
return this.officialSeedPromise
}
/** Adds the community catalog once by default; see CommunityMarketplaceSeed. */
seedCommunitySource(now?: number): Promise<void> {
return this.communitySeed.run(now)
}
async refreshSource(sourceId: string): Promise<PluginMarketplaceSourceState> {
const previous = this.refreshChains.get(sourceId) ?? Promise.resolve(null)
const refresh = previous.catch(() => null).then(() => this.performRefresh(sourceId))
@@ -235,6 +245,7 @@ export class PluginMarketplaceService {
private async waitForOfficialSeed(): Promise<void> {
await this.officialSeedPromise?.catch(() => undefined)
await this.communitySeed.settled()
}
private async fetchAndValidate(
@@ -50,6 +50,13 @@ export type PluginMarketplaceCachedSnapshot =
| z.infer<typeof cachedSnapshotSchema>
| z.infer<typeof legacySnapshotSchema>
// Why a separate file: sources.json is parsed strictly by older builds, so it can't gain fields.
const seededDefaultsSchema = z.object({
schemaVersion: z.literal(1),
seeded: z.array(z.string().max(64)).max(16)
})
export type PluginMarketplaceDefaultSource = 'community'
const SOURCE_FILE_MAX_BYTES = 2 * 1024 * 1024
const SNAPSHOT_FILE_MAX_BYTES = 16 * 1024 * 1024
@@ -63,6 +70,7 @@ export function marketplaceSourceId(source: PluginMarketplaceGitSource): string
export class PluginMarketplaceStore {
private readonly sourcesPath: string
private readonly seededDefaultsPath: string
private readonly snapshotDirectory: string
private sources: PluginMarketplaceRegisteredSource[] | null = null
private writeChain: Promise<void> = Promise.resolve()
@@ -71,6 +79,7 @@ export class PluginMarketplaceStore {
constructor(pluginsDataDir: string) {
const root = join(pluginsDataDir, 'marketplaces')
this.sourcesPath = join(root, 'sources.json')
this.seededDefaultsPath = join(root, 'seeded-defaults.json')
this.snapshotDirectory = join(root, 'snapshots')
}
@@ -117,6 +126,35 @@ export class PluginMarketplaceStore {
return removed
}
/** Whether Orca already added this default source once; a user's later removal must stick. */
async hasSeededDefault(name: PluginMarketplaceDefaultSource): Promise<boolean> {
return (await this.readSeededDefaults()).includes(name)
}
async markDefaultSeeded(name: PluginMarketplaceDefaultSource): Promise<void> {
const seeded = await this.readSeededDefaults()
if (!seeded.includes(name)) {
await writeAtomicJson(this.seededDefaultsPath, {
schemaVersion: 1,
seeded: [...seeded, name]
})
}
}
private async readSeededDefaults(): Promise<string[]> {
try {
return seededDefaultsSchema.parse(
JSON.parse(await readBoundedText(this.seededDefaultsPath, SOURCE_FILE_MAX_BYTES))
).seeded
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return []
}
// Why seeded on corruption: re-adding a source the user removed is worse than not adding it.
return ['community']
}
}
async writeSnapshot(input: {
source: PluginMarketplaceRegisteredSource
marketplaceCommit: string
+3
View File
@@ -65,6 +65,9 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService):
.catch((error) =>
console.warn('[plugins] failed to configure the official marketplace:', error)
)
void state.pluginMarketplaceService
?.seedCommunitySource()
.catch((error) => console.warn('[plugins] failed to add the community marketplace:', error))
}
state.pluginMarketplaceInstaller = new PluginMarketplaceInstaller({
marketplace: state.pluginMarketplaceService,