mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* 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
314 lines
15 KiB
TypeScript
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)
|
|
}
|