mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
plugins: show the community catalog by default (#36294)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user