diff --git a/src/main/ipc/plugin-marketplaces.test.ts b/src/main/ipc/plugin-marketplaces.test.ts index a6b52a47f26..50f07e12d0d 100644 --- a/src/main/ipc/plugin-marketplaces.test.ts +++ b/src/main/ipc/plugin-marketplaces.test.ts @@ -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) diff --git a/src/main/ipc/plugin-marketplaces.ts b/src/main/ipc/plugin-marketplaces.ts index 5ee0ad78c2f..43896d89c83 100644 --- a/src/main/ipc/plugin-marketplaces.ts +++ b/src/main/ipc/plugin-marketplaces.ts @@ -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', () => { diff --git a/src/main/plugins/catalog/plugin-community-marketplace-seed.ts b/src/main/plugins/catalog/plugin-community-marketplace-seed.ts new file mode 100644 index 00000000000..090972f80bf --- /dev/null +++ b/src/main/plugins/catalog/plugin-community-marketplace-seed.ts @@ -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 | null = null + private failedAt: number | null = null + + constructor( + private readonly store: Pick< + PluginMarketplaceStore, + 'hasSeededDefault' | 'markDefaultSeeded' | 'listSources' + >, + private readonly addSource: (source: PluginMarketplaceGitSource) => Promise + ) {} + + run(now = Date.now()): Promise { + 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 { + await this.inFlight?.catch(() => undefined) + } + + private async perform(): Promise { + 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') + } +} diff --git a/src/main/plugins/catalog/plugin-marketplace-service.test.ts b/src/main/plugins/catalog/plugin-marketplace-service.test.ts index ce681270ac1..cbcdc0807eb 100644 --- a/src/main/plugins/catalog/plugin-marketplace-service.test.ts +++ b/src/main/plugins/catalog/plugin-marketplace-service.test.ts @@ -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 => { + 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(), diff --git a/src/main/plugins/catalog/plugin-marketplace-service.ts b/src/main/plugins/catalog/plugin-marketplace-service.ts index 6971b5ca06c..bc1587188e6 100644 --- a/src/main/plugins/catalog/plugin-marketplace-service.ts +++ b/src/main/plugins/catalog/plugin-marketplace-service.ts @@ -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() private officialSeedPromise: Promise | 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 { @@ -135,6 +140,11 @@ export class PluginMarketplaceService { return this.officialSeedPromise } + /** Adds the community catalog once by default; see CommunityMarketplaceSeed. */ + seedCommunitySource(now?: number): Promise { + return this.communitySeed.run(now) + } + async refreshSource(sourceId: string): Promise { 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 { await this.officialSeedPromise?.catch(() => undefined) + await this.communitySeed.settled() } private async fetchAndValidate( diff --git a/src/main/plugins/catalog/plugin-marketplace-store.ts b/src/main/plugins/catalog/plugin-marketplace-store.ts index 6ee642b7d90..b90093cb905 100644 --- a/src/main/plugins/catalog/plugin-marketplace-store.ts +++ b/src/main/plugins/catalog/plugin-marketplace-store.ts @@ -50,6 +50,13 @@ export type PluginMarketplaceCachedSnapshot = | z.infer | z.infer +// 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 = 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 { + return (await this.readSeededDefaults()).includes(name) + } + + async markDefaultSeeded(name: PluginMarketplaceDefaultSource): Promise { + const seeded = await this.readSeededDefaults() + if (!seeded.includes(name)) { + await writeAtomicJson(this.seededDefaultsPath, { + schemaVersion: 1, + seeded: [...seeded, name] + }) + } + } + + private async readSeededDefaults(): Promise { + 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 diff --git a/src/main/startup/main-process-plugins.ts b/src/main/startup/main-process-plugins.ts index e119fbe9197..0917f64b99c 100644 --- a/src/main/startup/main-process-plugins.ts +++ b/src/main/startup/main-process-plugins.ts @@ -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,