Files
orca/src/preload/api/plugin-host-api.ts
T
Neil 5018241866 feat(plugins): app and terminal themes in one Theme list, painted safely (#30719)
* plugins: app and terminal theme contributions

Manifests can contribute themes (public CSS tokens over a light or dark
base) and terminal themes (an ANSI palette), each at most 16 KiB. App
themes must set --background and --foreground; omitted tokens are derived
from them (surfaces copy the background, muted text, borders and accents
mix the foreground into it), and values must be colors (no keywords,
lengths, calc, var or url). Both load through the shared artifact-pack
registry with the plugin's display name and a content revision, reach the
renderer over IPC and are re-validated there. Catalog releases can declare
app-theme and terminal-theme, and marketplaces stop hiding theme listings.
docs/reference/plugin-themes.md documents the format.

* plugins: resolve a selected plugin terminal palette on every host

Selecting a plugin terminal theme saves a copy of its palette in settings
(terminalPluginPalettes). The shared lookup reads it, so main, headless
serve and relays answer terminal color queries and native chat matches the
terminal without the plugin loaded; a malformed snapshot falls back to the
default theme.

* plugins: one Theme list with plugin themes, painted safely

Appearance keeps the System/Dark/Light control until a plugin offers a
theme; then Theme becomes one list with plugin themes after a divider,
labelled with the publisher when names collide. Choosing one stores its
base in theme and remembers the earlier built-in choice, which comes back
while the plugin is off or after it is uninstalled; a plugin update that
changes the base re-syncs theme. It also offers the plugin's terminal
colors in one click.

The renderer paints only values CSS parses as colors, checks every
text/surface pair, ring and border against the effective set (plugin
values over this mode's built-ins) and repeats until nothing changes, so
dropping one pair cannot expose another. The applied theme is cached with
its id and revision, painted before first render, and dropped as soon as
settings show it no longer applies. Main bounds the new settings on every
write and load, and uninstalling a plugin clears its theme choices and
palette copies. Terminal themes appear under Plugins in the picker with the
plugin's name and are passed to lookups instead of read from a store.

* plugins: type the theme settings normalizer input; let the store own its normalization

* fix(plugins): check text contrast on the editor surface and clear terminal overrides when adopting a theme's terminal colors

* fix(plugins): reject theme colors the canvas probe silently ignores
2026-10-28 15:13:10 -07:00

188 lines
6.2 KiB
TypeScript

import type { PluginThemeRegistration } from '../../shared/plugins/plugin-theme-artifact'
import type { PluginTerminalThemeRegistration } from '../../shared/plugins/plugin-terminal-theme-artifact'
import type {
PluginPanelActionOutcome,
PluginPanelEntry
} from '../../shared/plugins/plugin-panel-bridge'
import type { PluginConsentRequest } from '../../shared/plugins/plugin-consent-request'
import type { PluginLanguagePackRegistration } from '../../shared/plugins/plugin-language-pack-artifact'
import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event'
import type { PluginManifest } from '../../shared/plugins/plugin-manifest'
import type { PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace'
export type PluginHostPanel = {
id: string
title: string
/** Lucide icon name declared in the plugin manifest. */
icon?: string
tabKey: `plugin:${string}`
}
/** pending awaits consent; idle defers worker startup; errored exhausted retries or failed startup. */
export type PluginHostStatus =
| 'running'
| 'restarting'
| 'idle'
| 'pending'
| 'disabled'
| 'errored'
| 'invalid'
// Keep assignable from main/plugins/plugin-list-projection.ts.
export type PluginHostListEntry = {
pluginKey: string
consentFingerprint: string | null
name: string
version: string
publisher: string
description?: string
status: PluginHostStatus
needsReconsent: boolean
error?: string
isDev: boolean
official: boolean
bundled: boolean
capabilities: { kind: string; description: string }[]
panels: PluginHostPanel[]
commands: {
id: string
title: string
context: 'global' | 'worktree'
handler: { type: 'built-in'; action: string } | { type: 'worker' }
keybindings: { key: string; when: 'global' | 'worktree' }[]
}[]
hasWorker: boolean
vmRecipes?: {
id: string
name: string
description?: string
commands: {
phase: 'create' | 'suspend' | 'resume' | 'destroy'
command: string
}[]
}[]
restarts: number
blockedByKillList?: { reason: string; advisoryUrl?: string }
source?: {
kind: 'local-path' | 'git' | 'marketplace' | 'bundled'
reference: string
resolvedCommit: string | null
contentHash: string
marketplace?: { reference: string; resolvedCommit: string }
}
}
export type PluginHostLogLine = {
ts: number
level: 'info' | 'warn' | 'error'
line: string
}
export type PluginHostInstallSource =
| { kind: 'local-path'; path: string }
| { kind: 'git'; url: string; ref: string }
export type PluginHostInstallResult =
| {
ok: true
pluginKey: string
version: string
contentHash: string
consentFingerprint: string
resolvedCommit: string | null
}
| { ok: false; error: string }
export type PluginMarketplaceHostSourceState = {
id: string
source: PluginMarketplaceGitSource
addedAt: number
marketplace: {
name: string
owner: string
resolvedCommit: string
fetchedAt: number
} | null
stale: boolean
official: boolean
error?: string
}
export type PluginMarketplaceHostListing = {
marketplaceSourceId: string
marketplaceName: string
marketplaceOwner: string
marketplaceCommit: string
pluginKey: string
source: PluginMarketplaceGitSource
description?: string
categories: string[]
official: boolean
bundled: boolean
blockedByKillList?: { reason: string; advisoryUrl?: string }
}
export type PluginMarketplaceHostInstallPreview = {
marketplaceSourceId: string
marketplaceName: string
marketplaceOwner: string
marketplaceCommit: string
pluginKey: string
source: PluginMarketplaceGitSource
resolvedCommit: string
contentHash: string
consentFingerprint: string
manifest: PluginManifest
official: boolean
bundled: boolean
blockedByKillList?: { reason: string; advisoryUrl?: string }
}
export type PluginsApi = {
list: () => Promise<PluginHostListEntry[]>
listLanguagePacks: () => Promise<PluginLanguagePackRegistration[]>
listThemes: () => Promise<PluginThemeRegistration[]>
listTerminalThemes: () => Promise<PluginTerminalThemeRegistration[]>
/** Approval is bound to the current capability and trusted-worker fingerprint. */
consent: (args: PluginConsentRequest) => Promise<PluginHostListEntry[]>
setEnabled: (args: { pluginKey: string; enabled: boolean }) => Promise<PluginHostListEntry[]>
/** CSP-wrapped HTML for a sandboxed iframe; null if missing or disabled. */
readPanelEntry: (args: { pluginKey: string; panelId: string }) => Promise<PluginPanelEntry | null>
invokeCommand: (args: {
pluginKey: string
commandId: string
args?: unknown
}) => Promise<unknown>
/** Main enforces consented capabilities before executing sandboxed panel requests. */
panelAction: (args: {
sessionToken: string
action: string
params?: unknown
}) => Promise<PluginPanelActionOutcome>
install: (source: PluginHostInstallSource) => Promise<PluginHostInstallResult>
listMarketplaces: () => Promise<PluginMarketplaceHostSourceState[]>
addMarketplace: (source: PluginMarketplaceGitSource) => Promise<PluginMarketplaceHostSourceState>
removeMarketplace: (args: { sourceId: string }) => Promise<PluginMarketplaceHostSourceState[]>
refreshMarketplaces: (args?: { sourceId?: string }) => Promise<PluginMarketplaceHostSourceState[]>
listMarketplacePlugins: () => Promise<PluginMarketplaceHostListing[]>
previewMarketplacePlugin: (args: {
marketplaceSourceId: string
pluginKey: string
}) => Promise<PluginMarketplaceHostInstallPreview>
installMarketplacePlugin: (
preview: Pick<
PluginMarketplaceHostInstallPreview,
'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit'
>
) => Promise<PluginHostInstallResult>
previewMarketplaceUpdate: (args: {
pluginKey: string
}) => Promise<PluginMarketplaceHostInstallPreview>
rollbackMarketplacePlugin: (args: { pluginKey: string }) => Promise<PluginHostInstallResult>
remove: (args: { pluginKey: string }) => Promise<PluginHostListEntry[]>
getLogs: (args: { pluginKey: string }) => Promise<PluginHostLogLine[]>
/** Re-discovers after settings edits (feature flag, dev paths). */
refresh: () => Promise<PluginHostListEntry[]>
onChanged: (callback: (event: PluginChangeEvent) => void) => () => void
}