mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
Deletes local-agent-install-dir-detection, notification-burst-cooldown, terminal-git-credential-guard and tui-agent-detection-commands (pure re-export modules) and the re-export lines in seven more ipc modules; importers and test mocks name the real modules.
254 lines
9.3 KiB
TypeScript
254 lines
9.3 KiB
TypeScript
import { ipcMain } from 'electron'
|
|
import { z } from 'zod'
|
|
import type { Store } from '../persistence'
|
|
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
|
import type {
|
|
PluginPanelActionOutcome,
|
|
PluginPanelEntry
|
|
} from '../../shared/plugins/plugin-panel-bridge'
|
|
import { getUserPluginsDir, getPluginsDataDir } from '../plugins/plugin-discovery'
|
|
import {
|
|
installPluginFromGit,
|
|
installPluginFromLocalPath,
|
|
readPluginLockfile,
|
|
removeInstalledPlugin
|
|
} from '../plugins/plugin-install'
|
|
import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement'
|
|
import type { PluginService } from '../plugins/plugin-service'
|
|
import { bindPluginPanelOwnerLifecycle } from '../plugins/plugin-panel-owner-lifecycle'
|
|
import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest'
|
|
import { pluginConsentRequestSchema } from '../../shared/plugins/plugin-consent-request'
|
|
import { normalizePluginIdList } from '../../shared/plugins/plugin-consent-state'
|
|
import {
|
|
isAllowedPluginGitUrl,
|
|
type PluginLockfile
|
|
} from '../../shared/plugins/plugin-install-lockfile'
|
|
import {
|
|
registerPluginMarketplaceHandlers,
|
|
type PluginMarketplaceHandlerServices
|
|
} from './plugin-marketplaces'
|
|
|
|
export function parsePluginConsentArgs(args: unknown): z.infer<typeof pluginConsentRequestSchema> {
|
|
return pluginConsentRequestSchema.parse(args)
|
|
}
|
|
|
|
const setEnabledArgsSchema = z.object({
|
|
pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'),
|
|
enabled: z.boolean()
|
|
})
|
|
|
|
const readPanelEntryArgsSchema = z.object({
|
|
pluginKey: z.string().min(1),
|
|
panelId: z.string().min(1)
|
|
})
|
|
|
|
const invokeCommandArgsSchema = z.object({
|
|
pluginKey: z.string().min(1),
|
|
commandId: z.string().min(1),
|
|
args: z.unknown().optional()
|
|
})
|
|
|
|
const installArgsSchema = z.discriminatedUnion('kind', [
|
|
z.object({ kind: z.literal('local-path'), path: z.string().min(1) }),
|
|
z.object({
|
|
kind: z.literal('git'),
|
|
url: z.string().trim().min(1).refine(isAllowedPluginGitUrl, 'git URL must use HTTPS or SSH'),
|
|
// Why: installs must stay reproducible even when callers bypass renderer validation.
|
|
ref: z.string().trim().min(1)
|
|
})
|
|
])
|
|
|
|
export function parsePluginInstallArgs(args: unknown): z.infer<typeof installArgsSchema> {
|
|
return installArgsSchema.parse(args)
|
|
}
|
|
|
|
const removeArgsSchema = z.object({
|
|
pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key')
|
|
})
|
|
const logsArgsSchema = z.object({ pluginKey: z.string().min(1) })
|
|
|
|
// Why re-exported: moved to ../plugins/plugin-client-list so the runtime RPC can reach
|
|
// it without ipcMain. Existing importers of this path keep working.
|
|
import { listPluginsForClients } from '../plugins/plugin-client-list'
|
|
|
|
export function canRemoveInstalledPlugin(
|
|
pluginService: PluginService,
|
|
pluginKey: string,
|
|
lock?: PluginLockfile
|
|
): boolean {
|
|
return (
|
|
lock?.plugins[pluginKey]?.source.kind !== 'bundled' &&
|
|
pluginService.getDiscovered().some((plugin) => plugin.pluginKey === pluginKey && !plugin.isDev)
|
|
)
|
|
}
|
|
|
|
function rendererPanelOwner(webContentsId: number): string {
|
|
return `renderer:${webContentsId}`
|
|
}
|
|
|
|
export function registerPluginHandlers(
|
|
store: Store,
|
|
pluginService: PluginService,
|
|
runtime: OrcaRuntimeService | null,
|
|
marketplaceServices?: PluginMarketplaceHandlerServices
|
|
): void {
|
|
// The runtime IS the delegate: the structural PluginRuntimeDelegate type
|
|
// keeps the facade electron-free while main binds the real service.
|
|
if (runtime) {
|
|
pluginService.setRuntimeDelegate(runtime)
|
|
}
|
|
|
|
store.onSettingsChanged((updates) => {
|
|
if ('pluginSystemEnabled' in updates || 'devPluginPaths' in updates) {
|
|
// Main owns plugin lifecycle. Renderer follow-up refreshes are UX only;
|
|
// a crashed or remote caller must not leave old workers authoritative.
|
|
void pluginService.refresh().catch((error) => {
|
|
console.warn('[plugins] failed to apply plugin settings change:', error)
|
|
})
|
|
}
|
|
})
|
|
|
|
// Why: startup discovery is fire-and-forget; every handler awaits it so an
|
|
// early renderer fetch can't observe the empty pre-discovery list.
|
|
ipcMain.handle('plugins:list', async () => listPluginsForClients(pluginService))
|
|
ipcMain.handle('plugins:listLanguagePacks', async () => {
|
|
await pluginService.whenReady()
|
|
return pluginService.contentPacks.languagePacks.list()
|
|
})
|
|
ipcMain.handle('plugins:consent', async (event, args: unknown) => {
|
|
await pluginService.whenReady()
|
|
const parsed = parsePluginConsentArgs(args)
|
|
await applyPluginConsent({
|
|
store,
|
|
pluginService,
|
|
pluginKey: parsed.pluginKey,
|
|
reviewedFingerprint: parsed.reviewedFingerprint,
|
|
decision: parsed.decision,
|
|
originWebContentsId: event.sender.id
|
|
})
|
|
return listPluginsForClients(pluginService)
|
|
})
|
|
|
|
ipcMain.handle('plugins:setEnabled', async (event, args: unknown) => {
|
|
await pluginService.whenReady()
|
|
const parsed = setEnabledArgsSchema.parse(args)
|
|
await applyPluginEnablement({
|
|
store,
|
|
pluginService,
|
|
pluginKey: parsed.pluginKey,
|
|
enabled: parsed.enabled,
|
|
originWebContentsId: event.sender.id
|
|
})
|
|
return listPluginsForClients(pluginService)
|
|
})
|
|
|
|
// Why: the renderer renders panel HTML via a sandboxed iframe srcdoc, so it
|
|
// needs (CSP-wrapped) file contents — never a file:// path — across IPC.
|
|
ipcMain.handle(
|
|
'plugins:readPanelEntry',
|
|
async (event, args: unknown): Promise<PluginPanelEntry | null> => {
|
|
const ownerKey = rendererPanelOwner(event.sender.id)
|
|
const ownerLease = bindPluginPanelOwnerLifecycle(event.sender, () =>
|
|
pluginService.panels.revokeOwner(ownerKey)
|
|
)
|
|
await pluginService.whenReady()
|
|
const parsed = readPanelEntryArgsSchema.parse(args)
|
|
const entry = await pluginService.panels.open(ownerKey, parsed.pluginKey, parsed.panelId)
|
|
if (!ownerLease.isCurrent()) {
|
|
pluginService.panels.revokeOwner(ownerKey)
|
|
return null
|
|
}
|
|
return entry
|
|
}
|
|
)
|
|
|
|
// Panel-originated actions relayed by the renderer's postMessage bridge
|
|
// host. Capability enforcement happens in main, never in the renderer.
|
|
ipcMain.handle(
|
|
'plugins:panelAction',
|
|
async (event, args: unknown): Promise<PluginPanelActionOutcome> => {
|
|
await pluginService.whenReady()
|
|
return pluginService.panels.execute(rendererPanelOwner(event.sender.id), args)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle('plugins:invokeCommand', async (_event, args: unknown) => {
|
|
await pluginService.whenReady()
|
|
const parsed = invokeCommandArgsSchema.parse(args)
|
|
return pluginService.invokeCommand(parsed.pluginKey, parsed.commandId, parsed.args)
|
|
})
|
|
|
|
ipcMain.handle('plugins:install', async (_event, args: unknown) => {
|
|
await pluginService.whenReady()
|
|
const parsed = parsePluginInstallArgs(args)
|
|
const pluginsDir = getUserPluginsDir(pluginService.options.userDataPath)
|
|
const hostVersion = pluginService.options.hostVersion
|
|
const blockedPluginReason = (pluginKey: string): string | null =>
|
|
pluginService.options.getPluginKillListEntry?.(pluginKey)?.reason ?? null
|
|
const result =
|
|
parsed.kind === 'local-path'
|
|
? await installPluginFromLocalPath({
|
|
pluginsDir,
|
|
sourcePath: parsed.path,
|
|
hostVersion,
|
|
blockedPluginReason
|
|
})
|
|
: await installPluginFromGit({
|
|
pluginsDir,
|
|
url: parsed.url,
|
|
ref: parsed.ref,
|
|
hostVersion,
|
|
blockedPluginReason
|
|
})
|
|
if (result.ok) {
|
|
await pluginService.refresh()
|
|
}
|
|
return result
|
|
})
|
|
|
|
ipcMain.handle('plugins:remove', async (event, args: unknown) => {
|
|
await pluginService.whenReady()
|
|
const parsed = removeArgsSchema.parse(args)
|
|
const pluginsDir = getUserPluginsDir(pluginService.options.userDataPath)
|
|
const lock = await readPluginLockfile(pluginsDir)
|
|
if (!canRemoveInstalledPlugin(pluginService, parsed.pluginKey, lock)) {
|
|
throw new Error(`cannot remove protected or non-installed plugin ${parsed.pluginKey}`)
|
|
}
|
|
await pluginService.removePlugin(parsed.pluginKey, () =>
|
|
removeInstalledPlugin({
|
|
pluginsDir,
|
|
pluginsDataDir: getPluginsDataDir(pluginService.options.userDataPath),
|
|
pluginKey: parsed.pluginKey
|
|
})
|
|
)
|
|
// Drop the stale consent so a later reinstall re-prompts from scratch.
|
|
const settings = store.getSettings()
|
|
const consents = { ...settings.pluginConsents }
|
|
delete consents[parsed.pluginKey]
|
|
const disabledPlugins = normalizePluginIdList(settings.disabledPlugins).filter(
|
|
(pluginKey) => pluginKey !== parsed.pluginKey
|
|
)
|
|
store.updateSettings(
|
|
{ pluginConsents: consents, disabledPlugins },
|
|
{ notifyListeners: true, originWebContentsId: event.sender.id }
|
|
)
|
|
await pluginService.refresh()
|
|
return listPluginsForClients(pluginService)
|
|
})
|
|
|
|
ipcMain.handle('plugins:getLogs', async (_event, args: unknown) => {
|
|
const parsed = logsArgsSchema.parse(args)
|
|
return pluginService.getLogs(parsed.pluginKey)
|
|
})
|
|
|
|
// Re-discover after settings edits (feature flag, dev paths) — the
|
|
// renderer calls this right after updating those settings.
|
|
ipcMain.handle('plugins:refresh', async () => {
|
|
await pluginService.refresh()
|
|
return listPluginsForClients(pluginService)
|
|
})
|
|
if (marketplaceServices) {
|
|
registerPluginMarketplaceHandlers(pluginService, marketplaceServices)
|
|
}
|
|
}
|