Files
orca/src/main/startup/main-process-plugins.ts
T
Neil a9b1041ecc feat(plugins): status bar items plugins can update live (#37291)
* plugins: contributes.statusBarItems with worker updates (statusBar.update)

A plugin declares status bar items { id, text?, icon?, tone?, tooltip?,
command? } (declarative profile, engines >=1.4.247); pressing one runs
its own command. A trusted worker changes them with the new worker-only
host method statusBar.update; main keeps the session's changes and
pushes snapshots to desktop windows over plugins:statusBarChanged.
Regenerates schemas, references, typings and the authoring skill.

* plugins: statusBarItems ship in 1.4.248, beside contributes.hooks

* plugins: clear a plugin's status bar changes when it is disabled, removed or updated

* plugins: list status bar items in the plugin page's Adds section

* plugins: count statusBarItems' since in the host's newest readable version

* i18n: add the plugin page's status bar row label to en.json

* plugins: status bar items respect workspace commands, dev reloads and catalog summaries

- An item whose command needs a workspace is disabled with no open workspace.
- A dev code edit (new devRevision) drops the old worker's status bar changes.
- Catalog summaries list status bar items, and marketplace details show them.

* plugins: authoring skill lists status bar items as buildable; approval lists them

The skill no longer calls status bar items reserved and gains a row for them. The
dev-load/inspect approval already lists them via the contribution summary; a test pins
that and the fingerprint binding.

* chore(plugins): regenerate the testing bundle after rebasing on main
2026-11-02 04:26:55 +00:00

314 lines
15 KiB
TypeScript

import { join } from 'node:path'
import { PluginImageCache } from '../plugins/plugin-image-cache'
import { installPluginImageProtocolHandler } from '../plugins/plugin-image-protocol'
import { getPluginDesktopFrameServices } from '../plugins/panels/plugin-frame-desktop-services'
import { pluginHostVersion } from '../../shared/plugins/installation/plugin-host-version'
import { app, BrowserWindow, net } from 'electron'
import { performance } from 'node:perf_hooks'
import { PluginService } from '../plugins/plugin-service'
import { PluginKillListService } from '../plugins/catalog/plugin-kill-list-service'
import { PluginInstallStatsService } from '../plugins/catalog/plugin-install-stats-service'
import { PluginMarketplaceService } from '../plugins/catalog/plugin-marketplace-service'
import { PluginMarketplaceInstaller } from '../plugins/catalog/plugin-marketplace-installer'
import { PluginAutoUpdater } from '../plugins/catalog/plugin-auto-updater'
import { PluginReleaseFirstSeenStore } from '../plugins/catalog/plugin-release-first-seen-store'
import { PluginUpdateReviewNotices } from '../plugins/catalog/plugin-update-review-notices'
import { isPluginAutoUpdateOn } from '../../shared/plugins/catalog/plugin-update-policy'
import { PluginBundledBootstrapCoordinator } from '../plugins/plugin-bundled-bootstrap-coordinator'
import { getPluginsDataDir } from '../plugins/plugin-discovery'
import { bundledPluginRootForApp } from '../plugins/plugin-bundled-bootstrap'
import { approveBundledPluginsFromStore } from '../plugins/plugin-bundled-approval'
import { resolvePluginHostEntryPath } from '../plugins/workers/plugin-host-process'
import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement'
import { clearRemovedPluginSettings } from '../plugins/plugin-removal-settings'
import { clearPluginFrameStorage } from '../plugins/panels/plugin-frame-guest-sessions'
import { setPluginServiceForRpc } from '../runtime/rpc/methods/plugins'
import { bindPluginDesktopViews } from '../plugins/view/plugin-view-desktop-binding'
import { openPluginFrameGuestDevTools } from '../plugins/panels/plugin-frame-guest-diagnostics'
import {
normalizePluginConsents,
normalizePluginIdList
} from '../../shared/plugins/approval/plugin-consent-state'
import { projectPluginAgentStatusChangedPayload } from '../plugins/plugin-agent-status-event'
import { setMainPluginLanguagePacks, setMainUiLanguage } from '../i18n/main-i18n'
import { isUsablePluginLanguagePack } from '../../shared/plugins/languages/plugin-language-pack-coverage'
import { rebuildAppMenu } from '../menu/register-app-menu'
import { rebuildSystemTrayMenu } from '../tray/system-tray'
import { logStartupMilestone } from './startup-diagnostics'
import { agentHookServer } from '../agent-hooks/server'
import {
createPluginAgentStatusForwarder,
pluginRuntimeDelegate,
watchPluginWorkspaceContext
} from '../plugins/plugin-runtime-binding'
import { emitPluginWorktreeLifecycle } from './main-process-pty-startup'
import { mainProcessState as state } from './main-process-state'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { PluginStatusBarState } from '../plugins/status-bar/plugin-status-bar-state'
const PLUGIN_AUTO_UPDATE_STARTUP_DELAY_MS = 60_000
// Why: the official catalog fetch must not compete with first paint; opening the marketplace seeds sooner.
const PLUGIN_MARKETPLACE_SEED_STARTUP_DELAY_MS = 15_000
export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): Promise<void> {
const store = state.store
const keybindings = state.keybindings
if (!store || !keybindings) {
throw new Error('Store and keybindings must be initialized before plugins')
}
const pluginSystemStartupStartedAt = performance.now()
state.pluginKillListService = new PluginKillListService({
pluginsDataDir: getPluginsDataDir(app.getPath('userData'))
})
await state.pluginKillListService.initialize()
state.pluginInstallStatsService = new PluginInstallStatsService({
pluginsDataDir: getPluginsDataDir(app.getPath('userData')),
// Why packaged only (like the safety list): dev and e2e runs must not reach the network unasked.
isFetchAllowed: () =>
store.getSettings().pluginSystemEnabled === true &&
(app.isPackaged || process.env.ORCA_PLUGIN_INSTALL_STATS === '1')
})
state.pluginInstallStatsService.onChanged(() => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send('plugins:installCountsChanged')
}
}
})
const pluginImageCache = new PluginImageCache(
join(getPluginsDataDir(app.getPath('userData')), 'image-cache')
)
state.pluginMarketplaceService = new PluginMarketplaceService({
imageCache: pluginImageCache,
pluginsDataDir: getPluginsDataDir(app.getPath('userData')),
getKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null,
hostVersion: pluginHostVersion(app.getVersion())
})
const requestOfficialMarketplaceSeed = (): void => {
if (store.getSettings().pluginSystemEnabled !== true) {
return
}
void state.pluginMarketplaceService
?.seedOfficialSource()
.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,
userDataPath: app.getPath('userData'),
hostVersion: pluginHostVersion(app.getVersion()),
blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null,
imageCache: pluginImageCache
})
const bundledPluginRoot = bundledPluginRootForApp(app)
state.pluginService = new PluginService({
userDataPath: app.getPath('userData'),
hostVersion: pluginHostVersion(app.getVersion()),
// Feature flag: with the setting off, discovery returns nothing and no
// plugin code path runs at all.
isPluginSystemEnabled: () => state.store?.getSettings().pluginSystemEnabled === true,
getDisabledPlugins: () => normalizePluginIdList(state.store?.getSettings().disabledPlugins),
getPluginConsents: () => normalizePluginConsents(state.store?.getSettings().pluginConsents),
getDevPluginPaths: () => normalizePluginIdList(state.store?.getSettings().devPluginPaths),
getKeybindings: () => state.keybindings?.getOverrides() ?? {},
getPluginKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null,
hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged),
getDesktopFrameServices: getPluginDesktopFrameServices,
statusBar: new PluginStatusBarState(),
bundledPluginRoot
})
const pluginService = state.pluginService
const pluginAutoUpdater = new PluginAutoUpdater({
pluginService,
installer: state.pluginMarketplaceInstaller,
firstSeen: new PluginReleaseFirstSeenStore(getPluginsDataDir(app.getPath('userData'))),
isEnabled: () => {
const settings = store.getSettings()
return settings.pluginSystemEnabled === true && isPluginAutoUpdateOn(settings)
},
refreshCatalogs: () => state.pluginMarketplaceService?.refreshAll() ?? Promise.resolve(),
refreshKillList: () => state.pluginKillListService?.refresh() ?? Promise.resolve(),
approve: (pluginKey, reviewedFingerprint) =>
applyPluginConsent({
store,
pluginService,
pluginKey,
reviewedFingerprint,
decision: 'approve'
}),
onChanged: (autoUpdate, updatedNow) => {
const updated = Object.entries(updatedNow).map(([pluginKey, version]) => ({
pluginKey,
version,
name: pluginService.findValidPlugin(pluginKey)?.manifest.name ?? pluginKey
}))
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send('plugins:autoUpdateChanged', { state: autoUpdate, updated })
}
}
}
})
state.pluginAutoUpdater = pluginAutoUpdater
state.pluginUpdateReviewNotices = new PluginUpdateReviewNotices({
pluginsDataDir: getPluginsDataDir(app.getPath('userData')),
getHeld: () => pluginAutoUpdater.getHeldForReview(),
findName: (pluginKey) => pluginService.findValidPlugin(pluginKey)?.manifest.name ?? null,
isKillListed: (pluginKey) => Boolean(state.pluginKillListService?.find(pluginKey))
})
// Why packaged only (like the safety list): dev and e2e runs must not update fixtures unasked.
const autoUpdateScheduled = app.isPackaged || process.env.ORCA_PLUGIN_AUTO_UPDATE === '1'
const syncPluginAutoUpdate = (runNow: boolean): void => {
if (!autoUpdateScheduled) {
return
}
const enabled =
store.getSettings().pluginSystemEnabled === true && isPluginAutoUpdateOn(store.getSettings())
pluginAutoUpdater.syncPeriodicRun(enabled)
if (enabled && runNow) {
void pluginAutoUpdater
.run()
.catch((error) => console.warn('[plugins] auto-update failed:', error))
}
}
installPluginImageProtocolHandler({
cache: pluginImageCache,
findValidPlugin: (pluginKey) => state.pluginService?.findValidPlugin(pluginKey) ?? null
})
const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({
root: bundledPluginRoot,
userDataPath: app.getPath('userData'),
hostVersion: pluginHostVersion(app.getVersion()),
isEnabled: () => state.store?.getSettings().pluginSystemEnabled === true,
blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null,
// Why: a retired bundled plugin must not leave consent, theme choices or frame storage behind.
beforeRetire: (pluginKey) => clearRemovedPluginSettings(store, pluginKey),
afterRetire: (pluginKey) => clearPluginFrameStorage(pluginKey),
refreshPlugins: () => state.pluginService?.refresh() ?? Promise.resolve(),
approveContentPlugins: (pluginKeys) =>
approveBundledPluginsFromStore(store, pluginService, pluginKeys)
})
const requestBundledPluginBootstrap = (): void => {
void bundledPluginBootstrap
.request()
.then((result) => {
for (const failure of result?.errors ?? []) {
console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error)
}
})
.catch((error) => console.warn('[plugins] failed to bootstrap bundled plugins:', error))
}
state.pluginKillListService.onChanged(() => {
void state.pluginService
?.reconcileActivationState()
.catch((error) =>
console.warn('[plugins] failed to apply plugin safety-list refresh:', error)
)
})
store.onSettingsChanged((updates) => {
if (updates.pluginSystemEnabled === true) {
requestBundledPluginBootstrap()
requestOfficialMarketplaceSeed()
}
if (app.isPackaged && updates.pluginSystemEnabled === true) {
void state.pluginKillListService
?.refresh()
.catch((error) =>
console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error)
)
}
if (app.isPackaged && updates.pluginSystemEnabled !== undefined) {
state.pluginKillListService?.syncPeriodicRefresh(updates.pluginSystemEnabled === true)
}
if (updates.pluginSystemEnabled !== undefined || updates.pluginUpdatePolicy !== undefined) {
syncPluginAutoUpdate(true)
}
if ('pluginSystemEnabled' in updates || 'devPluginPaths' in updates) {
// Main owns lifecycle refresh even when a caller crashes before its follow-up refresh.
void pluginService.refresh().catch((error) => {
console.warn('[plugins] failed to apply plugin settings change:', error)
})
}
})
// Bound here, not in the IPC handlers: a headless serve never opens a window to register them.
const pluginDelegate = pluginRuntimeDelegate(runtime, () => agentHookServer.getStatusSnapshot())
pluginService.setRuntimeDelegate(pluginDelegate)
// Headless RPC resolves this service too; consent shares the desktop hash-keyed write path.
setPluginServiceForRpc(state.pluginService, {
applyConsent: (request) =>
applyPluginConsent({ store, pluginService: state.pluginService!, ...request }),
applyEnablement: (pluginKey, enabled) =>
applyPluginEnablement({ store, pluginService: state.pluginService!, pluginKey, enabled }),
setPluginSystemEnabled: (pluginSystemEnabled) =>
store.updateSettings({ pluginSystemEnabled }, { notifyListeners: true }),
setDevPluginPaths: (devPluginPaths) =>
store.updateSettings({ devPluginPaths }, { notifyListeners: true }),
openFrameDevTools: openPluginFrameGuestDevTools
})
bindPluginDesktopViews(state.isServeMode, pluginService, () => state.mainWindow)
// Initialization discovers manifests only; workers/panel reads wait for explicit triggers.
void state.pluginService
.initialize()
.then(() => {
logStartupMilestone('plugin-system-initialized', {
durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)),
installedPlugins: state.pluginService?.getDiscovered().length ?? 0
})
// Why delayed: catalog fetches and git checkouts must not compete with startup.
setTimeout(() => syncPluginAutoUpdate(true), PLUGIN_AUTO_UPDATE_STARTUP_DELAY_MS).unref?.()
})
.catch((error) => console.warn('[plugins] failed to initialize plugin service:', error))
if (app.isPackaged && store.getSettings().pluginSystemEnabled === true) {
void state.pluginKillListService
.refresh()
.catch((error) =>
console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error)
)
state.pluginKillListService.syncPeriodicRefresh(true)
}
state.pluginService.onChanged((event) => {
if (
event.contentPacksChanged &&
setMainPluginLanguagePacks(
(state.pluginService?.contentPacks.languagePacks.list() ?? []).filter(
isUsablePluginLanguagePack
)
)
) {
void setMainUiLanguage(store.getSettings().uiLanguage).then(() => {
rebuildAppMenu()
rebuildSystemTrayMenu()
})
}
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send('plugins:changed', event)
}
}
})
requestBundledPluginBootstrap()
setTimeout(() => {
if (net.isOnline()) {
requestOfficialMarketplaceSeed()
}
}, PLUGIN_MARKETPLACE_SEED_STARTUP_DELAY_MS).unref?.()
// Filter hook/worktree events server-side by each plugin subscription.
const forwardAgentStatus = createPluginAgentStatusForwarder(pluginDelegate, pluginService)
agentHookServer.subscribeEnrichedStatus((enriched) => {
const payload = projectPluginAgentStatusChangedPayload(enriched)
if (payload) {
forwardAgentStatus(payload, enriched.terminalHandle)
}
})
watchPluginWorkspaceContext({
delegate: pluginDelegate,
events: pluginService,
onSessionWritten: (listener) => store.onWorkspaceSessionWritten(listener)
})
runtime.onWorktreeLifecycle(emitPluginWorktreeLifecycle)
}