From 979dc13dfb61f9968306ea4cc74fdccdf6780343 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:15:51 -0700 Subject: [PATCH] feat(plugins): add consent settings and demo workflow --- examples/plugins/hello-orca/main.mjs | 24 + examples/plugins/hello-orca/orca-plugin.json | 23 + examples/plugins/hello-orca/panel.html | 125 +++++ .../settings/PluginConsentDialog.test.tsx | 168 +++++++ .../settings/PluginConsentDialog.tsx | 241 +++++++++ .../settings/PluginDevelopmentSection.tsx | 129 +++++ .../settings/PluginInstallDialog.test.tsx | 85 ++++ .../settings/PluginInstallDialog.tsx | 206 ++++++++ .../settings/PluginRemoveDialog.tsx | 65 +++ .../settings/PluginSettingsOverview.tsx | 140 ++++++ .../components/settings/PluginSettingsRow.tsx | 275 ++++++++++ .../PluginsSettingsSection.lifecycle.test.tsx | 476 ++++++++++++++++++ .../settings/PluginsSettingsSection.tsx | 426 ++++++++++++++++ .../src/components/settings/Settings.tsx | 10 + .../settings/plugin-error-presentation.ts | 95 ++++ .../settings/plugin-install-source.test.ts | 51 ++ .../settings/plugin-install-source.ts | 40 ++ .../src/components/settings/plugins-search.ts | 19 + .../useSettingsNavigationMetadata.test.ts | 14 + .../hooks/useSettingsNavigationMetadata.ts | 16 + src/renderer/src/i18n/locales/en.json | 27 +- src/renderer/src/i18n/locales/es.json | 27 +- src/renderer/src/i18n/locales/ja.json | 27 +- src/renderer/src/i18n/locales/ko.json | 27 +- src/renderer/src/i18n/locales/zh.json | 27 +- .../src/lib/settings-navigation-types.ts | 1 + .../src/store/slices/settings.test.ts | 64 +++ src/renderer/src/store/slices/settings.ts | 135 ++--- .../plugins/plugin-demo-fixture.test.ts | 20 + tests/e2e/plugin-demo.spec.ts | 180 +++++++ 30 files changed, 3087 insertions(+), 76 deletions(-) create mode 100644 examples/plugins/hello-orca/main.mjs create mode 100644 examples/plugins/hello-orca/orca-plugin.json create mode 100644 examples/plugins/hello-orca/panel.html create mode 100644 src/renderer/src/components/settings/PluginConsentDialog.test.tsx create mode 100644 src/renderer/src/components/settings/PluginConsentDialog.tsx create mode 100644 src/renderer/src/components/settings/PluginDevelopmentSection.tsx create mode 100644 src/renderer/src/components/settings/PluginInstallDialog.test.tsx create mode 100644 src/renderer/src/components/settings/PluginInstallDialog.tsx create mode 100644 src/renderer/src/components/settings/PluginRemoveDialog.tsx create mode 100644 src/renderer/src/components/settings/PluginSettingsOverview.tsx create mode 100644 src/renderer/src/components/settings/PluginSettingsRow.tsx create mode 100644 src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx create mode 100644 src/renderer/src/components/settings/PluginsSettingsSection.tsx create mode 100644 src/renderer/src/components/settings/plugin-error-presentation.ts create mode 100644 src/renderer/src/components/settings/plugin-install-source.test.ts create mode 100644 src/renderer/src/components/settings/plugin-install-source.ts create mode 100644 src/renderer/src/components/settings/plugins-search.ts create mode 100644 src/shared/plugins/plugin-demo-fixture.test.ts create mode 100644 tests/e2e/plugin-demo.spec.ts diff --git a/examples/plugins/hello-orca/main.mjs b/examples/plugins/hello-orca/main.mjs new file mode 100644 index 00000000000..17dacd63722 --- /dev/null +++ b/examples/plugins/hello-orca/main.mjs @@ -0,0 +1,24 @@ +// Sample Orca plugin worker entry. Runs inside the out-of-process plugin +// worker (plain Node, no Electron), forked lazily on the first trigger. The +// default export receives the `orca` API: command registration, event +// handlers, and the capability-gated host API. +export default function activate(orca) { + orca.commands.register('hello-ping', async (args) => { + const stored = await orca.host.call('storage.get', { key: 'pings' }) + const count = (typeof stored?.value === 'number' ? stored.value : 0) + 1 + await orca.host.call('storage.set', { key: 'pings', value: count }) + return { pong: true, count, args: args ?? null } + }) + + orca.events.on('worktree.created', async (payload) => { + orca.log(`worktree created: ${payload.worktreeId} at ${payload.path}`) + await orca.host.call('notifications.show', { + title: 'Worktree created', + body: payload.path + }) + }) + + orca.events.on('agent.status.changed', (payload) => { + orca.log(`agent status: ${payload.state} in ${payload.worktreeId ?? 'unknown worktree'}`) + }) +} diff --git a/examples/plugins/hello-orca/orca-plugin.json b/examples/plugins/hello-orca/orca-plugin.json new file mode 100644 index 00000000000..da41d0f7e72 --- /dev/null +++ b/examples/plugins/hello-orca/orca-plugin.json @@ -0,0 +1,23 @@ +{ + "manifestVersion": 1, + "id": "hello-orca", + "publisher": "orca-samples", + "name": "Hello Orca", + "version": "1.0.0", + "description": "Sample plugin combining a sandboxed panel, a worker command, and event subscriptions.", + "engines": { "orca": ">=1.4.0" }, + "pluginApi": 1, + "main": "main.mjs", + "contributes": { + "panels": [{ "id": "hello", "title": "Hello Orca", "icon": "plug", "entry": "panel.html" }], + "commands": [{ "id": "hello-ping", "title": "Hello: Ping" }], + "events": [{ "on": "worktree.created" }, { "on": "agent.status.changed" }] + }, + "capabilities": [ + { "kind": "workspace:read" }, + { "kind": "terminal:send" }, + { "kind": "notifications:show" }, + { "kind": "storage" }, + { "kind": "events:subscribe" } + ] +} diff --git a/examples/plugins/hello-orca/panel.html b/examples/plugins/hello-orca/panel.html new file mode 100644 index 00000000000..6c7b5cf8181 --- /dev/null +++ b/examples/plugins/hello-orca/panel.html @@ -0,0 +1,125 @@ + + + + + + + +

Hello Orca 👋

+

Panel + worker command + events, gated by consent.

+ + + + +

+ + + diff --git a/src/renderer/src/components/settings/PluginConsentDialog.test.tsx b/src/renderer/src/components/settings/PluginConsentDialog.test.tsx new file mode 100644 index 00000000000..aa8b474a794 --- /dev/null +++ b/src/renderer/src/components/settings/PluginConsentDialog.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment happy-dom + +import { act, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PluginHostListEntry } from '../../../../preload/api-types' +import { PluginConsentDialog } from './PluginConsentDialog' + +const plugin: PluginHostListEntry = { + pluginKey: 'acme.worker', + consentFingerprint: 'sha256-acme-worker', + name: 'Acme Worker', + version: '1.2.3', + publisher: 'acme', + status: 'pending', + needsReconsent: false, + isDev: false, + capabilities: [{ kind: 'worker', description: 'Run a background worker process' }], + panels: [], + commands: [], + hasWorker: true, + restarts: 0, + source: { + kind: 'git', + reference: 'https://gitlab.example/acme/worker#v1.2.3', + resolvedCommit: '0123456789abcdef', + contentHash: 'sha256' + } +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +async function renderConsent( + entry: PluginHostListEntry, + onDecision: ( + key: string, + reviewedFingerprint: string, + decision: 'approve' | 'keep-disabled' + ) => Promise +): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + function Harness(): React.JSX.Element { + const [selected, setSelected] = useState(entry) + return ( + { + await onDecision(key, reviewedFingerprint, decision) + setSelected(null) + }} + /> + ) + } + await act(async () => root.render()) +} + +describe('PluginConsentDialog', () => { + it('keeps the displayed fingerprint immutable during a same-key update', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onDecision = vi.fn().mockResolvedValue(undefined) + await act(async () => { + root.render() + }) + await act(async () => { + root.render( + + ) + }) + + expect(document.body.textContent).not.toContain('Read a newly added secret') + const enable = Array.from(document.querySelectorAll('button')).find( + (candidate) => candidate.textContent?.trim() === 'Enable plugin' + ) + await act(async () => enable?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + + expect(onDecision).toHaveBeenCalledWith(plugin.pluginKey, plugin.consentFingerprint, 'approve') + }) + + it('shows provenance, capabilities, worker warning, and focuses the safe default', async () => { + await renderConsent(plugin, vi.fn().mockResolvedValue(undefined)) + + expect(document.body.textContent).toContain(plugin.source?.reference) + expect(document.body.textContent).toContain(plugin.source?.resolvedCommit) + expect(document.body.textContent).toContain('Run a background worker process') + expect(document.body.textContent).toContain( + 'full access to your files, network, and other processes' + ) + expect(document.activeElement?.textContent).toContain('Keep Disabled') + }) + + it('explains that panel-only plugins have no worker process', async () => { + await renderConsent( + { + ...plugin, + pluginKey: 'acme.panel', + name: 'Acme Panel', + hasWorker: false, + capabilities: [{ kind: 'panels', description: 'Add an Acme panel' }] + }, + vi.fn().mockResolvedValue(undefined) + ) + + expect(document.body.textContent).toContain( + "These permissions limit how the plugin uses Orca's API. This plugin has no worker process." + ) + expect(document.body.textContent).not.toContain('full access to your files') + }) + + it('records Keep Disabled when Escape dismisses the dialog', async () => { + const onDecision = vi.fn().mockResolvedValue(undefined) + await renderConsent(plugin, onDecision) + + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + + expect(onDecision).toHaveBeenCalledWith( + plugin.pluginKey, + plugin.consentFingerprint, + 'keep-disabled' + ) + }) + + it('labels re-consent generically and enables only after an explicit action', async () => { + const onDecision = vi.fn().mockResolvedValue(undefined) + await renderConsent({ ...plugin, needsReconsent: true }, onDecision) + expect(document.body.textContent).toContain('Permissions or the worker trust tier changed') + const enable = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Enable plugin' + ) + if (!enable) { + throw new Error('missing enable action') + } + + await act(async () => enable.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + + expect(onDecision).toHaveBeenCalledWith(plugin.pluginKey, plugin.consentFingerprint, 'approve') + }) + + it('explains how to recover when the reviewed plugin changed', async () => { + const onDecision = vi + .fn() + .mockRejectedValue(new Error('reviewed fingerprint is no longer current')) + await renderConsent(plugin, onDecision) + const enable = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Enable plugin' + ) + + await act(async () => enable?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + + expect(document.body.textContent).toContain( + 'The plugin changed while you were reviewing it. Close this dialog and review the updated permissions.' + ) + }) +}) diff --git a/src/renderer/src/components/settings/PluginConsentDialog.tsx b/src/renderer/src/components/settings/PluginConsentDialog.tsx new file mode 100644 index 00000000000..402b03d93db --- /dev/null +++ b/src/renderer/src/components/settings/PluginConsentDialog.tsx @@ -0,0 +1,241 @@ +import { useRef, useState } from 'react' +import { AlertTriangle, Check, Loader2 } from 'lucide-react' +import type { PluginHostListEntry } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { pluginConsentErrorMessage } from './plugin-error-presentation' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' + +type PluginConsentDialogProps = { + plugin: PluginHostListEntry | null + onDecision: ( + pluginKey: string, + reviewedFingerprint: string, + decision: 'approve' | 'keep-disabled' + ) => Promise +} + +function trustTier(plugin: PluginHostListEntry): string { + return plugin.hasWorker + ? translate( + 'auto.components.settings.PluginConsentDialog.workerTrust', + 'Background worker — runs its own process' + ) + : translate( + 'auto.components.settings.PluginConsentDialog.panelTrust', + 'Panel only — no worker process' + ) +} + +function capabilityDescription(kind: string, fallback: string): string { + switch (kind) { + case 'workspace:read': + return translate( + 'auto.components.settings.PluginConsentDialog.capability.workspaceRead', + 'Read the name, branch, and terminal list of your focused worktree' + ) + case 'terminal:send': + return translate( + 'auto.components.settings.PluginConsentDialog.capability.terminalSend', + 'Type text into a terminal you can see (always a specific terminal)' + ) + case 'notifications:show': + return translate( + 'auto.components.settings.PluginConsentDialog.capability.notificationsShow', + 'Show desktop notifications labeled with the plugin name' + ) + case 'storage': + return translate( + 'auto.components.settings.PluginConsentDialog.capability.storage', + "Store data in the plugin's own storage folder" + ) + case 'secrets': + return translate( + 'auto.components.settings.PluginConsentDialog.capability.secrets', + "Store and read secrets in the plugin's own encrypted vault" + ) + case 'events:subscribe': + return translate( + 'auto.components.settings.PluginConsentDialog.capability.eventsSubscribe', + 'Get notified when worktrees are created or removed and when agent status changes' + ) + case 'settings:own': + return translate( + 'auto.components.settings.PluginConsentDialog.capability.settingsOwn', + "Read and change the plugin's own settings" + ) + default: + return fallback + } +} + +export function PluginConsentDialog({ + plugin: currentPlugin, + onDecision +}: PluginConsentDialogProps): React.JSX.Element { + // Why: a same-key update must not replace the trust boundary while the user is reviewing it. + const plugin = useRef(currentPlugin).current + const keepDisabledRef = useRef(null) + const [busyDecision, setBusyDecision] = useState<'approve' | 'keep-disabled' | null>(null) + const [error, setError] = useState(null) + + const decide = async (decision: 'approve' | 'keep-disabled'): Promise => { + if (!plugin?.consentFingerprint || busyDecision) { + return + } + setBusyDecision(decision) + setError(null) + try { + // Why: consent is conditional on the exact trust boundary rendered by this dialog. + await onDecision(plugin.pluginKey, plugin.consentFingerprint, decision) + } catch (cause) { + console.warn('[plugins] consent update failed:', cause) + setError(pluginConsentErrorMessage(cause)) + } finally { + setBusyDecision(null) + } + } + + return ( + { + if (!open) { + void decide('keep-disabled') + } + }} + > + { + // Why: dismissal is the safety-preserving path, so it receives initial focus. + event.preventDefault() + keepDisabledRef.current?.focus() + }} + > + {plugin ? ( + <> + + + {translate( + 'auto.components.settings.PluginConsentDialog.title', + 'Review permissions' + )} + + + {translate( + 'auto.components.settings.PluginConsentDialog.subtitle', + '{{value0}} v{{value1}} · {{value2}}', + { value0: plugin.name, value1: plugin.version, value2: plugin.publisher } + )} + + + {plugin.needsReconsent ? ( +

+ {translate( + 'auto.components.settings.PluginConsentDialog.reconsent', + 'Permissions or the worker trust tier changed since you last reviewed this plugin. Review them again before it can run.' + )} +

+ ) : null} +
+
+ {translate('auto.components.settings.PluginConsentDialog.source', 'Source')} +
+
+ {plugin.source?.reference ?? + translate( + 'auto.components.settings.PluginConsentDialog.unknownSource', + 'Not available' + )} +
+
+ {translate('auto.components.settings.PluginConsentDialog.commit', 'Commit')} +
+
+ {plugin.source?.resolvedCommit ?? + translate( + 'auto.components.settings.PluginConsentDialog.localCommit', + 'Not available (local folder)' + )} +
+
+ {translate('auto.components.settings.PluginConsentDialog.trust', 'Trust tier')} +
+
{trustTier(plugin)}
+
+
+

+ {translate( + 'auto.components.settings.PluginConsentDialog.capabilities', + 'This plugin can' + )} +

+
+ {plugin.capabilities.map((capability) => ( +
+ + + {capabilityDescription(capability.kind, capability.description)}{' '} + + ({capability.kind}) + + +
+ ))} +
+
+
+ + + {plugin.hasWorker + ? translate( + 'auto.components.settings.PluginConsentDialog.warning', + "These permissions limit how the plugin uses Orca's API. Its worker still runs as a normal process on your computer with full access to your files, network, and other processes." + ) + : translate( + 'auto.components.settings.PluginConsentDialog.panelWarning', + "These permissions limit how the plugin uses Orca's API. This plugin has no worker process." + )} + +
+ {error ?

{error}

: null} + + + + + + ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/PluginDevelopmentSection.tsx b/src/renderer/src/components/settings/PluginDevelopmentSection.tsx new file mode 100644 index 00000000000..0c2385cae5d --- /dev/null +++ b/src/renderer/src/components/settings/PluginDevelopmentSection.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react' +import { ChevronRight, Loader2 } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' + +type PluginDevelopmentSectionProps = { + paths: readonly string[] + busy: boolean + onChange: (paths: string[]) => Promise +} + +function saveErrorMessage(cause: unknown): string { + console.warn('[plugins] development path update failed:', cause) + return translate( + 'auto.components.settings.PluginDevelopmentSection.saveFailed', + 'Could not save development plugin paths.' + ) +} + +export function PluginDevelopmentSection({ + paths, + busy, + onChange +}: PluginDevelopmentSectionProps): React.JSX.Element { + const [pathInput, setPathInput] = useState('') + const [error, setError] = useState(null) + + const addPath = async (): Promise => { + const path = pathInput.trim() + if (!path) { + setError( + translate( + 'auto.components.settings.PluginDevelopmentSection.pathRequired', + 'Enter a plugin folder path.' + ) + ) + return + } + setError(null) + try { + await onChange([...paths, path]) + setPathInput('') + } catch (cause) { + setError(saveErrorMessage(cause)) + } + } + + const removePath = async (index: number): Promise => { + setError(null) + try { + await onChange(paths.filter((_, pathIndex) => pathIndex !== index)) + } catch (cause) { + setError(saveErrorMessage(cause)) + } + } + + return ( +
+ + + {translate('auto.components.settings.PluginDevelopmentSection.title', 'Development')} + +
+

+ {translate( + 'auto.components.settings.PluginDevelopmentSection.help', + 'Load plugins directly from folders on this computer while you develop them. Dev plugins still require permission review. Workers run on this desktop host; SSH workspace actions route through Orca, so paths here are desktop paths.' + )} +

+ {paths.map((path, index) => ( +
+ + {path} + + +
+ ))} +
{ + event.preventDefault() + void addPath() + }} + > + + setPathInput(event.target.value)} + className="h-8 min-w-0 font-mono text-xs" + placeholder={translate( + 'auto.components.settings.PluginDevelopmentSection.placeholder', + '/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin' + )} + spellCheck={false} + aria-invalid={Boolean(error)} + aria-describedby={error ? 'plugin-development-path-error' : undefined} + /> + +
+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/PluginInstallDialog.test.tsx b/src/renderer/src/components/settings/PluginInstallDialog.test.tsx new file mode 100644 index 00000000000..a9cb71e6b2e --- /dev/null +++ b/src/renderer/src/components/settings/PluginInstallDialog.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PluginHostInstallSource } from '../../../../preload/api-types' +import { PluginInstallDialog } from './PluginInstallDialog' + +afterEach(() => { + document.body.innerHTML = '' +}) + +async function renderDialog( + onInstall: (source: PluginHostInstallSource) => Promise +): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render() + }) + return root +} + +async function enter(input: HTMLInputElement, value: string): Promise { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +function button(label: string): HTMLButtonElement { + const match = Array.from(document.querySelectorAll('button')).find( + (candidate) => candidate.textContent?.trim() === label + ) + if (!match) { + throw new Error(`missing ${label} button`) + } + return match +} + +describe('PluginInstallDialog', () => { + it('preserves local input and stays out of consent when installation fails', async () => { + const onInstall = vi + .fn<(source: PluginHostInstallSource) => Promise>() + .mockRejectedValue(new Error('Integrity check failed')) + const root = await renderDialog(onInstall) + const input = document.querySelector('#plugin-local-path') + if (!input) { + throw new Error('missing local path input') + } + await enter(input, 'C:\\plugins\\demo') + + await act(async () => { + button('Install').dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onInstall).toHaveBeenCalledWith({ kind: 'local-path', path: 'C:\\plugins\\demo' }) + expect(input.value).toBe('C:\\plugins\\demo') + expect(document.body.textContent).toContain('Plugin installation failed.') + expect(document.body.textContent).not.toContain('Review permissions') + act(() => root.unmount()) + }) + + it('turns invalid-manifest failures into actionable localized guidance', async () => { + const onInstall = vi + .fn<(source: PluginHostInstallSource) => Promise>() + .mockRejectedValue(new Error('invalid manifest: contributes.panels.0.entry is required')) + const root = await renderDialog(onInstall) + const input = document.querySelector('#plugin-local-path') + if (!input) { + throw new Error('missing local path input') + } + await enter(input, '/plugins/demo') + + await act(async () => { + button('Install').dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(document.body.textContent).toContain( + 'orca-plugin.json is invalid. Ask the plugin author to fix the manifest.' + ) + act(() => root.unmount()) + }) +}) diff --git a/src/renderer/src/components/settings/PluginInstallDialog.tsx b/src/renderer/src/components/settings/PluginInstallDialog.tsx new file mode 100644 index 00000000000..1b13bea59e1 --- /dev/null +++ b/src/renderer/src/components/settings/PluginInstallDialog.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react' +import { Loader2 } from 'lucide-react' +import type { PluginHostInstallSource } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs' +import { parsePluginInstallSource } from './plugin-install-source' +import { pluginInstallErrorMessage } from './plugin-error-presentation' + +type PluginInstallDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + onInstall: (source: PluginHostInstallSource) => Promise +} + +function installValidationMessage(reason: string): string { + switch (reason) { + case 'missing-local-path': + return translate( + 'auto.components.settings.PluginInstallDialog.localRequired', + 'Enter the plugin folder path.' + ) + case 'missing-git-url': + return translate( + 'auto.components.settings.PluginInstallDialog.gitUrlRequired', + 'Enter a repository URL.' + ) + case 'invalid-git-url': + return translate( + 'auto.components.settings.PluginInstallDialog.gitUrlInvalid', + 'Use an HTTPS or SSH Git URL. Executable Git helper protocols are not allowed.' + ) + default: + return translate( + 'auto.components.settings.PluginInstallDialog.gitRefRequired', + 'Add an explicit #ref (tag or commit) so the install is pinned — for example #v0.1.0.' + ) + } +} + +export function PluginInstallDialog({ + open, + onOpenChange, + onInstall +}: PluginInstallDialogProps): React.JSX.Element { + const [kind, setKind] = useState<'local-path' | 'git'>('local-path') + const [localPath, setLocalPath] = useState('') + const [gitUrl, setGitUrl] = useState('') + const [error, setError] = useState(null) + const [installing, setInstalling] = useState(false) + + const submit = async (): Promise => { + const parsed = parsePluginInstallSource(kind, kind === 'git' ? gitUrl : localPath) + if (!parsed.ok) { + setError(installValidationMessage(parsed.reason)) + return + } + setError(null) + setInstalling(true) + try { + await onInstall(parsed.source) + } catch (cause) { + console.warn('[plugins] installation failed:', cause) + setError(pluginInstallErrorMessage(cause)) + } finally { + setInstalling(false) + } + } + + return ( + !installing && onOpenChange(nextOpen)}> + + + + {translate('auto.components.settings.PluginInstallDialog.title', 'Install plugin')} + + + {translate( + 'auto.components.settings.PluginInstallDialog.description', + 'Installing copies the plugin into Orca and shows its permissions for review. No plugin code runs until you enable it.' + )} + + +
{ + event.preventDefault() + void submit() + }} + > + { + setKind(value as 'local-path' | 'git') + setError(null) + }} + > + + + {translate('auto.components.settings.PluginInstallDialog.localTab', 'Local folder')} + + + {translate('auto.components.settings.PluginInstallDialog.gitTab', 'Git URL')} + + + + + setLocalPath(event.target.value)} + placeholder={translate( + 'auto.components.settings.PluginInstallDialog.localPlaceholder', + '/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin' + )} + spellCheck={false} + aria-invalid={kind === 'local-path' && Boolean(error)} + aria-describedby={error ? 'plugin-install-error' : undefined} + autoFocus + /> +

+ {translate( + 'auto.components.settings.PluginInstallDialog.localHelp', + 'Full path to a folder containing orca-plugin.json on this computer. The path is used exactly as entered.' + )} +

+
+ + + setGitUrl(event.target.value)} + placeholder={translate( + 'auto.components.settings.PluginInstallDialog.gitPlaceholder', + 'https://git.example/acme/orca-notes#v0.1.0' + )} + spellCheck={false} + aria-invalid={kind === 'git' && Boolean(error)} + aria-describedby={error ? 'plugin-install-error' : undefined} + /> +

+ {translate( + 'auto.components.settings.PluginInstallDialog.gitHelp', + 'Append an explicit #ref — a tag or commit — so the install is pinned. Works with GitHub, GitLab, and any git host.' + )} +

+
+
+ {error ? ( +

+ {error} +

+ ) : null} + + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/PluginRemoveDialog.tsx b/src/renderer/src/components/settings/PluginRemoveDialog.tsx new file mode 100644 index 00000000000..3cae9dd9aaf --- /dev/null +++ b/src/renderer/src/components/settings/PluginRemoveDialog.tsx @@ -0,0 +1,65 @@ +import { useRef } from 'react' +import { Loader2 } from 'lucide-react' +import type { PluginHostListEntry } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' + +type PluginRemoveDialogProps = { + plugin: PluginHostListEntry | null + busy: boolean + onCancel: () => void + onConfirm: (pluginKey: string) => void +} + +export function PluginRemoveDialog({ + plugin, + busy, + onCancel, + onConfirm +}: PluginRemoveDialogProps): React.JSX.Element { + const cancelRef = useRef(null) + return ( + !open && !busy && onCancel()}> + { + event.preventDefault() + cancelRef.current?.focus() + }} + > + + + {translate('auto.components.settings.PluginRemoveDialog.title', 'Remove plugin?')} + + + {translate( + 'auto.components.settings.PluginRemoveDialog.description', + 'This removes {{value0}} and its stored plugin data from this computer. You can install it again later.', + { value0: plugin?.name ?? '' } + )} + + + + + + + + + ) +} diff --git a/src/renderer/src/components/settings/PluginSettingsOverview.tsx b/src/renderer/src/components/settings/PluginSettingsOverview.tsx new file mode 100644 index 00000000000..93006eedc36 --- /dev/null +++ b/src/renderer/src/components/settings/PluginSettingsOverview.tsx @@ -0,0 +1,140 @@ +import { Loader2, RefreshCw } from 'lucide-react' +import type { PluginHostListEntry } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { PluginDevelopmentSection } from './PluginDevelopmentSection' +import { PluginSettingsRow, type PluginLogsState } from './PluginSettingsRow' +import { SettingsRow, SettingsSwitch } from './SettingsFormControls' + +type PluginSettingsOverviewProps = { + featureEnabled: boolean + featureBusy: boolean + settingsError: string | null + loading: boolean + refreshBusy: boolean + error: string | null + plugins: PluginHostListEntry[] + busyPluginKeys: ReadonlySet + openLogs: ReadonlySet + logsByPlugin: Readonly> + devPaths: readonly string[] + devPathsBusy: boolean + onToggleFeature: () => void + onRefresh: () => void + onReview: (pluginKey: string) => void + onToggleEnabled: (plugin: PluginHostListEntry) => void + onToggleLogs: (pluginKey: string) => void + onRemoveRequest: (pluginKey: string) => void + onUpdateDevPaths: (paths: string[]) => Promise +} + +export function PluginSettingsOverview({ + featureEnabled, + featureBusy, + settingsError, + loading, + refreshBusy, + error, + plugins, + busyPluginKeys, + openLogs, + logsByPlugin, + devPaths, + devPathsBusy, + onToggleFeature, + onRefresh, + onReview, + onToggleEnabled, + onToggleLogs, + onRemoveRequest, + onUpdateDevPaths +}: PluginSettingsOverviewProps): React.JSX.Element { + return ( + <> + + } + /> + {settingsError ?

{settingsError}

: null} +
+ {!featureEnabled ? ( +
+ {translate( + 'auto.components.settings.PluginsSettingsSection.featureOff', + 'Turn on the plugin system to see and manage installed plugins. Anything already installed stays on disk and stays disabled while the system is off.' + )} +
+ ) : loading ? ( +
+ + {translate('auto.components.settings.PluginsSettingsSection.loading', 'Loading plugins…')} +
+ ) : ( + <> +
+ + {translate('auto.components.settings.PluginsSettingsSection.installed', 'Installed')} + + +
+ {error ? ( +
+

{error}

+ +
+ ) : plugins.length === 0 ? ( +
+ {translate( + 'auto.components.settings.PluginsSettingsSection.empty', + 'No plugins installed. Use Install plugin to add one from a local folder or a pinned git ref.' + )} +
+ ) : ( +
+ {plugins.map((plugin) => ( + + ))} +
+ )} +
+ + + )} + + ) +} diff --git a/src/renderer/src/components/settings/PluginSettingsRow.tsx b/src/renderer/src/components/settings/PluginSettingsRow.tsx new file mode 100644 index 00000000000..3fc6fc53a09 --- /dev/null +++ b/src/renderer/src/components/settings/PluginSettingsRow.tsx @@ -0,0 +1,275 @@ +import { AlertTriangle, FileText, Loader2, MoreHorizontal, Trash2 } from 'lucide-react' +import type { PluginHostListEntry, PluginHostLogLine } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { invalidPluginErrorMessage } from './plugin-error-presentation' +import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '../ui/dropdown-menu' +import { SettingsSwitch } from './SettingsFormControls' + +export type PluginLogsState = { + loading: boolean + lines?: PluginHostLogLine[] + error?: string +} + +type PluginSettingsRowProps = { + plugin: PluginHostListEntry + busy: boolean + logsOpen: boolean + logsState?: PluginLogsState + onReview: (pluginKey: string) => void + onToggleEnabled: (plugin: PluginHostListEntry) => void + onToggleLogs: (pluginKey: string) => void + onRemoveRequest: (pluginKey: string) => void +} + +function statusPresentation(plugin: PluginHostListEntry): { label: string; className: string } { + if (plugin.needsReconsent || plugin.status === 'pending') { + return { + label: translate('auto.components.settings.PluginSettingsRow.needsReview', 'Needs review'), + className: 'border-foreground/20 bg-foreground/8 text-foreground' + } + } + if (plugin.status === 'restarting') { + return { + label: translate('auto.components.settings.PluginSettingsRow.restarting', 'Restarting'), + className: 'border-foreground/20 bg-foreground/8 text-foreground' + } + } + if (plugin.status === 'errored' || plugin.status === 'invalid') { + return { + label: + plugin.status === 'invalid' + ? translate('auto.components.settings.PluginSettingsRow.invalid', 'Invalid') + : translate('auto.components.settings.PluginSettingsRow.error', 'Error'), + className: 'border-destructive/25 bg-destructive/8 text-destructive' + } + } + if (plugin.status === 'disabled') { + return { + label: translate('auto.components.settings.PluginSettingsRow.disabled', 'Disabled'), + className: 'border-border bg-muted/40 text-muted-foreground' + } + } + return { + label: + plugin.status === 'running' + ? translate('auto.components.settings.PluginSettingsRow.running', 'Running') + : translate('auto.components.settings.PluginSettingsRow.enabled', 'Enabled'), + className: 'border-status-success-border bg-status-success-background text-status-success' + } +} + +function sourceLabel(plugin: PluginHostListEntry): string | null { + if (!plugin.source) { + return null + } + const commit = plugin.source.resolvedCommit?.slice(0, 10) + return commit ? `${plugin.source.reference} @ ${commit}` : plugin.source.reference +} + +function trustTier(plugin: PluginHostListEntry): string { + return plugin.hasWorker + ? translate('auto.components.settings.PluginSettingsRow.workerTier', 'background worker') + : translate('auto.components.settings.PluginSettingsRow.panelTier', 'panel only') +} + +function PluginLogs({ pluginKey, state }: { pluginKey: string; state?: PluginLogsState }) { + return ( +
+ {state?.loading ? ( +
+ + {translate('auto.components.settings.PluginSettingsRow.loadingLogs', 'Loading logs…')} +
+ ) : state?.error ? ( +

{state.error}

+ ) : ( + <> +
+            {state?.lines?.length
+              ? state.lines
+                  .map(
+                    (entry) =>
+                      `${new Date(entry.ts).toLocaleTimeString()} ${entry.level.padEnd(5)} ${entry.line}`
+                  )
+                  .join('\n')
+              : translate(
+                  'auto.components.settings.PluginSettingsRow.noLogs',
+                  'No log lines recorded.'
+                )}
+          
+
+ + {translate( + 'auto.components.settings.PluginSettingsRow.logCount', + 'Last {{value0}} of up to 200 retained lines', + { value0: state?.lines?.length ?? 0 } + )} + + {pluginKey} +
+ + )} +
+ ) +} + +export function PluginSettingsRow({ + plugin, + busy, + logsOpen, + logsState, + onReview, + onToggleEnabled, + onToggleLogs, + onRemoveRequest +}: PluginSettingsRowProps): React.JSX.Element { + const status = statusPresentation(plugin) + const source = sourceLabel(plugin) + const needsReview = plugin.needsReconsent || plugin.status === 'pending' + const enabled = + plugin.status === 'running' || + plugin.status === 'restarting' || + plugin.status === 'idle' || + plugin.status === 'errored' + const switchDisabled = busy || needsReview || plugin.status === 'invalid' + + return ( +
+
+
+
+ {plugin.name} + v{plugin.version} + {plugin.isDev ? ( + + {translate('auto.components.settings.PluginSettingsRow.dev', 'Dev')} + + ) : null} + + + {busy ? : null} +
+

+ {plugin.publisher ? `${plugin.publisher} · ` : null} + {plugin.pluginKey} +

+ {source ? ( +

+ {source} +

+ ) : null} +

+ {[...plugin.capabilities.map((capability) => capability.kind), trustTier(plugin)].join( + ' · ' + )} +

+ {plugin.error ? ( +

+ + + {plugin.status === 'invalid' + ? invalidPluginErrorMessage(plugin.error) + : translate( + 'auto.components.settings.PluginSettingsRow.runtimeError', + 'The plugin stopped after an activation or worker error.' + )} + {plugin.restarts > 0 + ? translate( + 'auto.components.settings.PluginSettingsRow.restartCount', + ' · {{value0}} restarts', + { value0: plugin.restarts } + ) + : null} + +

+ ) : null} +
+
+ {needsReview ? ( + + ) : null} + + + + + + onToggleLogs(plugin.pluginKey)}> + + {logsOpen + ? translate('auto.components.settings.PluginSettingsRow.hideLogs', 'Hide logs') + : translate('auto.components.settings.PluginSettingsRow.viewLogs', 'View logs')} + + {!plugin.isDev ? ( + onRemoveRequest(plugin.pluginKey)} + > + + {translate('auto.components.settings.PluginSettingsRow.remove', 'Remove')} + + ) : null} + + + onToggleEnabled(plugin)} + ariaLabel={ + enabled && !needsReview + ? translate( + 'auto.components.settings.PluginSettingsRow.disableLabel', + 'Disable {{value0}}', + { value0: plugin.name } + ) + : translate( + 'auto.components.settings.PluginSettingsRow.enableLabel', + 'Enable {{value0}}', + { value0: plugin.name } + ) + } + /> +
+
+ {logsOpen ? : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx b/src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx new file mode 100644 index 00000000000..37f7268efc6 --- /dev/null +++ b/src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx @@ -0,0 +1,476 @@ +// @vitest-environment happy-dom + +import { act, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PluginHostListEntry } from '../../../../preload/api-types' +import { getDefaultSettings } from '../../../../shared/constants' +import type { GlobalSettings } from '../../../../shared/types' +import { PluginsSettingsSection } from './PluginsSettingsSection' + +vi.mock('./SettingsSection', () => ({ + SettingsSection: ({ + children, + headerAction + }: { + children: React.ReactNode + headerAction: React.ReactNode + }) => ( +
+ {headerAction} + {children} +
+ ) +})) + +vi.mock('../ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect + }: { + children: React.ReactNode + onSelect?: () => void + }) => +})) + +vi.mock('./PluginInstallDialog', () => ({ PluginInstallDialog: () => null })) +vi.mock('./PluginConsentDialog', () => ({ + PluginConsentDialog: ({ + plugin, + onDecision + }: { + plugin: PluginHostListEntry | null + onDecision: (key: string, reviewedFingerprint: string, decision: 'approve') => Promise + }) => { + const [dirty, setDirty] = useState(false) + return plugin ? ( + <> + + + {dirty ? Consent local state : null} + + ) : null + } +})) +vi.mock('./PluginRemoveDialog', () => ({ + PluginRemoveDialog: ({ + plugin, + onConfirm + }: { + plugin: PluginHostListEntry | null + onConfirm: (key: string) => void + }) => + plugin ? : null +})) + +const plugin: PluginHostListEntry = { + pluginKey: 'acme.notes', + consentFingerprint: 'sha256-acme-notes', + name: 'Notes', + version: '1.0.0', + publisher: 'acme', + status: 'idle', + needsReconsent: false, + isDev: false, + capabilities: [{ kind: 'panels', description: 'Add a Notes panel' }], + panels: [], + commands: [], + hasWorker: false, + restarts: 0 +} + +const secondPlugin: PluginHostListEntry = { + ...plugin, + pluginKey: 'acme.tasks', + consentFingerprint: 'sha256-acme-tasks', + name: 'Tasks' +} + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((nextResolve) => { + resolve = nextResolve + }) + return { promise, resolve } +} + +function click(element: Element): void { + element.dispatchEvent(new MouseEvent('click', { bubbles: true })) +} + +async function renderSection( + settings: GlobalSettings = { ...getDefaultSettings('/tmp'), pluginSystemEnabled: true }, + updateSettings = vi.fn().mockResolvedValue(undefined), + mounted = true +): Promise<{ root: Root; container: HTMLDivElement; updateSettings: typeof updateSettings }> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render( + + ) + }) + return { root, container, updateSettings } +} + +function installApi(overrides: Record = {}): { + unsubscribe: ReturnType +} { + const unsubscribe = vi.fn() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + plugins: { + list: vi.fn().mockResolvedValue([plugin]), + onChanged: vi.fn().mockReturnValue(unsubscribe), + refresh: vi.fn().mockResolvedValue([plugin]), + setEnabled: vi.fn().mockResolvedValue([{ ...plugin, status: 'disabled' }]), + remove: vi.fn().mockResolvedValue([]), + getLogs: vi.fn().mockResolvedValue([]), + consent: vi.fn().mockResolvedValue([plugin]), + install: vi.fn(), + ...overrides + } + } + }) + return { unsubscribe } +} + +beforeEach(() => installApi()) + +afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +describe('PluginsSettingsSection lifecycle', () => { + it('defers discovery and its change listener until the pane is mounted', async () => { + const settings = { ...getDefaultSettings('/tmp'), pluginSystemEnabled: true } + const updateSettings = vi.fn().mockResolvedValue(undefined) + const { root } = await renderSection(settings, updateSettings, false) + + expect(window.api.plugins.list).not.toHaveBeenCalled() + expect(window.api.plugins.onChanged).not.toHaveBeenCalled() + + await act(async () => { + root.render( + + ) + }) + + expect(window.api.plugins.list).toHaveBeenCalledOnce() + expect(window.api.plugins.onChanged).toHaveBeenCalledOnce() + }) + + it('suppresses stale lists and cleans up the change subscription', async () => { + const first = deferred() + const second = deferred() + let onChanged: (() => void) | undefined + const { unsubscribe } = installApi({ + list: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise), + onChanged: vi.fn((callback: () => void) => { + onChanged = callback + return unsubscribe + }) + }) + const { root, container } = await renderSection() + + act(() => onChanged?.()) + await act(async () => second.resolve([{ ...plugin, name: 'Newer Notes' }])) + await act(async () => first.resolve([{ ...plugin, name: 'Older Notes' }])) + + expect(container.textContent).toContain('Newer Notes') + expect(container.textContent).not.toContain('Older Notes') + act(() => root.unmount()) + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('persists the feature switch before refreshing discovery', async () => { + const updateSettings = vi.fn().mockResolvedValue(undefined) + const { container } = await renderSection( + { ...getDefaultSettings('/tmp'), pluginSystemEnabled: false }, + updateSettings + ) + const featureSwitch = container.querySelector('[aria-labelledby="plugin-system-label"]') + if (!featureSwitch) { + throw new Error('missing feature switch') + } + expect(window.api.plugins.list).not.toHaveBeenCalled() + + await act(async () => click(featureSwitch)) + + expect(updateSettings).toHaveBeenCalledWith({ pluginSystemEnabled: true }) + expect(window.api.plugins.refresh).toHaveBeenCalledOnce() + }) + + it('routes enablement and confirmed removal through plugin IPC', async () => { + const { container } = await renderSection() + const pluginSwitch = container.querySelector('[aria-label="Disable Notes"]') + if (!pluginSwitch) { + throw new Error('missing plugin switch') + } + + await act(async () => click(pluginSwitch)) + expect(window.api.plugins.setEnabled).toHaveBeenCalledWith({ + pluginKey: plugin.pluginKey, + enabled: false + }) + await act(async () => click(pluginSwitch)) + expect(window.api.plugins.setEnabled).toHaveBeenLastCalledWith({ + pluginKey: plugin.pluginKey, + enabled: true + }) + + const removeAction = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Remove' + ) + if (!removeAction) { + throw new Error('missing remove action') + } + await act(async () => click(removeAction)) + const confirm = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Confirm remove' + ) + if (!confirm) { + throw new Error('missing remove confirmation') + } + await act(async () => click(confirm)) + expect(window.api.plugins.remove).toHaveBeenCalledWith({ pluginKey: plugin.pluginKey }) + }) + + it('accepts overlapping mutations in completion order without regressing final state', async () => { + const firstToggle = deferred() + const secondToggle = deferred() + installApi({ + list: vi.fn().mockResolvedValue([plugin, secondPlugin]), + setEnabled: vi + .fn() + .mockReturnValueOnce(firstToggle.promise) + .mockReturnValueOnce(secondToggle.promise) + }) + const { container } = await renderSection() + const notesSwitch = container.querySelector('[aria-label="Disable Notes"]') + const tasksSwitch = container.querySelector('[aria-label="Disable Tasks"]') + if (!notesSwitch || !tasksSwitch) { + throw new Error('missing plugin switches') + } + + act(() => click(notesSwitch)) + act(() => click(tasksSwitch)) + expect(notesSwitch.disabled).toBe(true) + expect(tasksSwitch.disabled).toBe(true) + + await act(async () => secondToggle.resolve([plugin, { ...secondPlugin, status: 'disabled' }])) + + expect(notesSwitch.disabled).toBe(true) + expect(tasksSwitch.disabled).toBe(false) + await act(async () => + firstToggle.resolve([ + { ...plugin, status: 'disabled' }, + { ...secondPlugin, status: 'disabled' } + ]) + ) + expect(notesSwitch.disabled).toBe(false) + expect(notesSwitch.getAttribute('aria-checked')).toBe('false') + expect(tasksSwitch.getAttribute('aria-checked')).toBe('false') + }) + + it('fetches bounded logs only when their disclosure opens', async () => { + const getLogs = vi.fn().mockResolvedValue([{ ts: 1, level: 'info', line: 'started' }]) + installApi({ getLogs }) + const { container } = await renderSection() + expect(getLogs).not.toHaveBeenCalled() + const viewLogs = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'View logs' + ) + if (!viewLogs) { + throw new Error('missing logs action') + } + + await act(async () => click(viewLogs)) + + expect(getLogs).toHaveBeenCalledWith({ pluginKey: plugin.pluginKey }) + expect(container.textContent).toContain('started') + }) + + it('shows supervised backoff as an enabled restarting plugin', async () => { + installApi({ + list: vi.fn().mockResolvedValue([{ ...plugin, status: 'restarting', restarts: 1 }]) + }) + const { container } = await renderSection() + const pluginSwitch = container.querySelector('[aria-label="Disable Notes"]') + + expect(container.textContent).toContain('Restarting') + expect(pluginSwitch?.getAttribute('aria-checked')).toBe('true') + }) + + it('prunes dialogs and stale logs after external removal and same-key reinstall', async () => { + const removedList = deferred() + const reinstalledList = deferred() + const oldLogs = deferred>>() + const freshLogs = deferred>>() + const pendingPlugin = { ...plugin, status: 'pending' as const } + let onChanged: (() => void) | undefined + const unsubscribe = vi.fn() + installApi({ + list: vi + .fn() + .mockResolvedValueOnce([pendingPlugin]) + .mockReturnValueOnce(removedList.promise) + .mockReturnValueOnce(reinstalledList.promise), + onChanged: vi.fn((callback: () => void) => { + onChanged = callback + return unsubscribe + }), + getLogs: vi.fn().mockReturnValueOnce(oldLogs.promise).mockReturnValueOnce(freshLogs.promise) + }) + const { container } = await renderSection() + const findButton = (label: string): HTMLButtonElement | undefined => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label + ) + + act(() => click(findButton('Review permissions')!)) + act(() => click(findButton('Mark consent dirty')!)) + act(() => click(findButton('View logs')!)) + act(() => click(findButton('Remove')!)) + expect(container.textContent).toContain('Consent local state') + expect(container.textContent).toContain('Confirm remove') + + act(() => onChanged?.()) + await act(async () => removedList.resolve([])) + + expect(container.textContent).not.toContain('Approve') + expect(container.textContent).not.toContain('Confirm remove') + act(() => onChanged?.()) + await act(async () => reinstalledList.resolve([pendingPlugin])) + expect(container.textContent).not.toContain('Loading logs…') + + act(() => click(findButton('Review permissions')!)) + expect(container.textContent).not.toContain('Consent local state') + act(() => click(findButton('View logs')!)) + await act(async () => freshLogs.resolve([{ ts: 2, level: 'info', line: 'fresh installation' }])) + await act(async () => oldLogs.resolve([{ ts: 1, level: 'info', line: 'stale installation' }])) + + expect(container.textContent).toContain('fresh installation') + expect(container.textContent).not.toContain('stale installation') + }) + + it('clears feature busy state and skips refresh when the setting write fails', async () => { + const updateSettings = vi.fn().mockRejectedValue(new Error('disk write failed')) + const { container } = await renderSection( + { ...getDefaultSettings('/tmp'), pluginSystemEnabled: false }, + updateSettings + ) + const featureSwitch = container.querySelector( + '[aria-labelledby="plugin-system-label"]' + ) + if (!featureSwitch) { + throw new Error('missing feature switch') + } + + await act(async () => click(featureSwitch)) + + expect(featureSwitch.disabled).toBe(false) + expect(window.api.plugins.refresh).not.toHaveBeenCalled() + expect(container.textContent).toContain('Could not save plugin settings.') + }) + + it('persists verbatim development paths and refreshes discovery', async () => { + const updateSettings = vi.fn().mockResolvedValue(undefined) + const { container } = await renderSection(undefined, updateSettings) + const input = container.querySelector('details input') + if (!input) { + throw new Error('missing development path input') + } + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( + input, + 'C:\\plugins\\demo' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + const add = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Add path' + ) + if (!add) { + throw new Error('missing add path action') + } + + await act(async () => click(add)) + + expect(updateSettings).toHaveBeenCalledWith({ devPluginPaths: ['C:\\plugins\\demo'] }) + expect(window.api.plugins.refresh).toHaveBeenCalled() + }) + + it('preserves development input and clears busy when adding a path fails', async () => { + const updateSettings = vi.fn().mockRejectedValue(new Error('disk write failed')) + const { container } = await renderSection(undefined, updateSettings) + const input = container.querySelector('details input') + if (!input) { + throw new Error('missing development path input') + } + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( + input, + '/plugins/demo' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + const add = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Add path' + ) + if (!add) { + throw new Error('missing add path action') + } + + await act(async () => click(add)) + + expect(input.value).toBe('/plugins/demo') + expect(add.disabled).toBe(false) + expect(window.api.plugins.refresh).not.toHaveBeenCalled() + expect(container.textContent).toContain('Could not save plugin settings.') + }) + + it('keeps a development path and handles a failed removal without rejection', async () => { + const updateSettings = vi.fn().mockRejectedValue(new Error('disk write failed')) + const settings = { + ...getDefaultSettings('/tmp'), + pluginSystemEnabled: true, + devPluginPaths: ['/plugins/demo'] + } + const { container } = await renderSection(settings, updateSettings) + const remove = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Remove' && button.closest('details') + ) + if (!remove) { + throw new Error('missing development path remove action') + } + + await act(async () => click(remove)) + + expect(container.textContent).toContain('/plugins/demo') + expect(remove.disabled).toBe(false) + expect(window.api.plugins.refresh).not.toHaveBeenCalled() + expect(container.textContent).toContain('Could not save plugin settings.') + }) +}) diff --git a/src/renderer/src/components/settings/PluginsSettingsSection.tsx b/src/renderer/src/components/settings/PluginsSettingsSection.tsx new file mode 100644 index 00000000000..82d8b81784d --- /dev/null +++ b/src/renderer/src/components/settings/PluginsSettingsSection.tsx @@ -0,0 +1,426 @@ +import { useEffect, useRef, useState } from 'react' +import { Plus } from 'lucide-react' +import type { + PluginHostInstallSource, + PluginHostListEntry, + PluginHostLogLine +} from '../../../../preload/api-types' +import type { GlobalSettings } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { PluginConsentDialog } from './PluginConsentDialog' +import { PluginInstallDialog } from './PluginInstallDialog' +import { PluginRemoveDialog } from './PluginRemoveDialog' +import { PluginSettingsOverview } from './PluginSettingsOverview' +import type { PluginLogsState } from './PluginSettingsRow' +import { getPluginsPaneSearchEntries } from './plugins-search' +import { SettingsSection } from './SettingsSection' + +type PluginsSettingsSectionProps = { + mounted: boolean + settings: GlobalSettings + updateSettings: (updates: Partial) => Promise +} + +function errorMessage(cause: unknown, fallback: string): string { + console.warn('[plugins] settings action failed:', cause) + return fallback +} + +export function PluginsSettingsSection({ + mounted, + settings, + updateSettings +}: PluginsSettingsSectionProps): React.JSX.Element { + const [plugins, setPlugins] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [installOpen, setInstallOpen] = useState(false) + const [consentPluginId, setConsentPluginId] = useState(null) + const [removePluginId, setRemovePluginId] = useState(null) + const [busyPluginKeys, setBusyPluginKeys] = useState>(() => new Set()) + const [featureBusy, setFeatureBusy] = useState(false) + const [refreshBusy, setRefreshBusy] = useState(false) + const [devPathsBusy, setDevPathsBusy] = useState(false) + const [settingsError, setSettingsError] = useState(null) + const [openLogs, setOpenLogs] = useState>(() => new Set()) + const [logsByPlugin, setLogsByPlugin] = useState>({}) + const mountedRef = useRef(false) + const listRequestRef = useRef(0) + const nextLogsRequestRef = useRef(0) + const logsRequestRef = useRef>({}) + + const applyPluginList = (nextPlugins: PluginHostListEntry[]): void => { + const installedPluginKeys = new Set(nextPlugins.map((plugin) => plugin.pluginKey)) + setPlugins(nextPlugins) + setError(null) + // Why: accepted discovery results own installed-plugin identity and invalidate stale UI state. + setConsentPluginId((current) => (current && installedPluginKeys.has(current) ? current : null)) + setRemovePluginId((current) => (current && installedPluginKeys.has(current) ? current : null)) + setBusyPluginKeys( + (current) => new Set([...current].filter((pluginKey) => installedPluginKeys.has(pluginKey))) + ) + setOpenLogs( + (current) => new Set([...current].filter((pluginKey) => installedPluginKeys.has(pluginKey))) + ) + setLogsByPlugin((current) => + Object.fromEntries( + Object.entries(current).filter(([pluginKey]) => installedPluginKeys.has(pluginKey)) + ) + ) + for (const pluginKey of Object.keys(logsRequestRef.current)) { + if (!installedPluginKeys.has(pluginKey)) { + delete logsRequestRef.current[pluginKey] + } + } + } + + const setPluginListError = (cause: unknown): void => { + setError( + errorMessage( + cause, + translate( + 'auto.components.settings.PluginsSettingsSection.loadFailed', + 'Could not load plugins.' + ) + ) + ) + } + + const loadPluginList = async ( + request: Promise + ): Promise => { + const requestId = ++listRequestRef.current + try { + const nextPlugins = await request + if (!mountedRef.current || requestId !== listRequestRef.current) { + return null + } + applyPluginList(nextPlugins) + return nextPlugins + } catch (cause) { + if (mountedRef.current && requestId === listRequestRef.current) { + setPluginListError(cause) + } + return null + } + } + + const applyCompletedMutation = (nextPlugins: PluginHostListEntry[]): void => { + if (!mountedRef.current) { + return + } + // Why: a mutation result is authoritative at completion time, even when an earlier action + // finishes after a later-started action or a passive refresh is still in flight. + listRequestRef.current += 1 + applyPluginList(nextPlugins) + } + + useEffect(() => { + mountedRef.current = mounted + if (!mounted) { + // Why: hidden lazy panes must not resurrect dialogs or unresolved async state when reopened. + setPlugins([]) + setLoading(true) + setError(null) + setInstallOpen(false) + setConsentPluginId(null) + setRemovePluginId(null) + setBusyPluginKeys(new Set()) + setFeatureBusy(false) + setRefreshBusy(false) + setDevPathsBusy(false) + setSettingsError(null) + setOpenLogs(new Set()) + setLogsByPlugin({}) + logsRequestRef.current = {} + } + return () => { + mountedRef.current = false + listRequestRef.current += 1 + } + }, [mounted]) + + useEffect(() => { + if (!mounted || !settings.pluginSystemEnabled) { + return + } + setLoading(true) + const load = async (): Promise => { + await loadPluginList(window.api.plugins.list()) + if (mountedRef.current) { + setLoading(false) + } + } + void load() + const unsubscribe = window.api.plugins.onChanged(() => { + void loadPluginList(window.api.plugins.list()) + }) + return () => { + listRequestRef.current += 1 + unsubscribe() + } + }, [mounted, settings.pluginSystemEnabled]) + + const sectionPresentation = { + title: translate('auto.components.settings.PluginsSettingsSection.title', 'Plugins'), + badge: translate( + 'auto.components.settings.PluginsSettingsSection.experimental', + 'Experimental' + ), + description: translate( + 'auto.components.settings.PluginsSettingsSection.description', + 'Install and manage Orca plugins. Plugins run on this computer, even for SSH workspaces.' + ), + searchEntries: getPluginsPaneSearchEntries() + } + + if (!mounted) { + return + } + + const selectedConsentPlugin = + plugins.find((plugin) => plugin.pluginKey === consentPluginId) ?? null + const consentPlugin = selectedConsentPlugin?.consentFingerprint ? selectedConsentPlugin : null + const removePlugin = plugins.find((plugin) => plugin.pluginKey === removePluginId) ?? null + + const toggleFeature = async (): Promise => { + setFeatureBusy(true) + setSettingsError(null) + setError(null) + try { + await updateSettings({ pluginSystemEnabled: !settings.pluginSystemEnabled }) + await loadPluginList(window.api.plugins.refresh()) + } catch { + if (mountedRef.current) { + setSettingsError( + translate( + 'auto.components.settings.PluginsSettingsSection.settingsUpdateFailed', + 'Could not save plugin settings.' + ) + ) + } + } finally { + if (mountedRef.current) { + setFeatureBusy(false) + } + } + } + + const install = async (source: PluginHostInstallSource): Promise => { + const result = await window.api.plugins.install(source) + if (!result.ok) { + throw new Error(result.error) + } + // Why: installation is not consent; select by id and wait for the authoritative list. + setInstallOpen(false) + setConsentPluginId(result.pluginKey) + await loadPluginList(window.api.plugins.refresh()) + } + + const decideConsent = async ( + pluginKey: string, + reviewedFingerprint: string, + decision: 'approve' | 'keep-disabled' + ): Promise => { + setBusyPluginKeys((current) => new Set(current).add(pluginKey)) + try { + const nextPlugins = await window.api.plugins.consent({ + pluginKey, + reviewedFingerprint, + decision + }) + applyCompletedMutation(nextPlugins) + if (mountedRef.current) { + setConsentPluginId(null) + } + } finally { + if (mountedRef.current) { + setBusyPluginKeys((current) => { + const next = new Set(current) + next.delete(pluginKey) + return next + }) + } + } + } + + const toggleEnabled = async (plugin: PluginHostListEntry): Promise => { + const enabled = + plugin.status === 'running' || + plugin.status === 'restarting' || + plugin.status === 'idle' || + plugin.status === 'errored' + setBusyPluginKeys((current) => new Set(current).add(plugin.pluginKey)) + try { + const nextPlugins = await window.api.plugins.setEnabled({ + pluginKey: plugin.pluginKey, + enabled: !enabled + }) + applyCompletedMutation(nextPlugins) + } catch (cause) { + if (mountedRef.current) { + setPluginListError(cause) + } + } finally { + if (mountedRef.current) { + setBusyPluginKeys((current) => { + const next = new Set(current) + next.delete(plugin.pluginKey) + return next + }) + } + } + } + + const remove = async (pluginKey: string): Promise => { + setBusyPluginKeys((current) => new Set(current).add(pluginKey)) + try { + const nextPlugins = await window.api.plugins.remove({ pluginKey }) + applyCompletedMutation(nextPlugins) + if (mountedRef.current) { + setRemovePluginId(null) + } + } catch (cause) { + if (mountedRef.current) { + setPluginListError(cause) + } + } finally { + if (mountedRef.current) { + setBusyPluginKeys((current) => { + const next = new Set(current) + next.delete(pluginKey) + return next + }) + } + } + } + + const refresh = async (): Promise => { + setRefreshBusy(true) + await loadPluginList(window.api.plugins.refresh()) + if (mountedRef.current) { + setRefreshBusy(false) + } + } + + const toggleLogs = (pluginKey: string): void => { + if (openLogs.has(pluginKey)) { + setOpenLogs((current) => { + const next = new Set(current) + next.delete(pluginKey) + return next + }) + return + } + setOpenLogs((current) => new Set(current).add(pluginKey)) + if (logsByPlugin[pluginKey]?.lines) { + return + } + const requestId = ++nextLogsRequestRef.current + logsRequestRef.current[pluginKey] = requestId + setLogsByPlugin((current) => ({ ...current, [pluginKey]: { loading: true } })) + void window.api.plugins + .getLogs({ pluginKey }) + .then((lines: PluginHostLogLine[]) => { + if (mountedRef.current && logsRequestRef.current[pluginKey] === requestId) { + setLogsByPlugin((current) => ({ + ...current, + [pluginKey]: { loading: false, lines } + })) + } + }) + .catch((cause: unknown) => { + if (mountedRef.current && logsRequestRef.current[pluginKey] === requestId) { + setLogsByPlugin((current) => ({ + ...current, + [pluginKey]: { + loading: false, + error: errorMessage( + cause, + translate( + 'auto.components.settings.PluginsSettingsSection.logsFailed', + 'Could not load plugin logs.' + ) + ) + } + })) + } + }) + } + + const updateDevPaths = async (paths: string[]): Promise => { + setDevPathsBusy(true) + setSettingsError(null) + try { + await updateSettings({ devPluginPaths: paths }) + await loadPluginList(window.api.plugins.refresh()) + } catch { + const message = translate( + 'auto.components.settings.PluginsSettingsSection.settingsUpdateFailed', + 'Could not save plugin settings.' + ) + if (mountedRef.current) { + setSettingsError(message) + } + throw new Error(message) + } finally { + if (mountedRef.current) { + setDevPathsBusy(false) + } + } + } + + const featureEnabled = settings.pluginSystemEnabled + return ( + setInstallOpen(true)} + > + + {translate('auto.components.settings.PluginsSettingsSection.install', 'Install plugin')} + + } + > + void toggleFeature()} + onRefresh={() => void refresh()} + onReview={setConsentPluginId} + onToggleEnabled={(entry) => void toggleEnabled(entry)} + onToggleLogs={toggleLogs} + onRemoveRequest={setRemovePluginId} + onUpdateDevPaths={updateDevPaths} + /> + + + setRemovePluginId(null)} + onConfirm={(pluginKey) => void remove(pluginKey)} + /> + + ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 2d5a941226c..6e977c10e27 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -47,6 +47,7 @@ import { NotificationsPane } from './NotificationsPane' import { VoicePane } from './VoicePane' import { SshPane } from './SshPane' import { ExperimentalPane } from './ExperimentalPane' +import { PluginsSettingsSection } from './PluginsSettingsSection' import { AgentsPane } from './AgentsPane' import { OrchestrationPane } from './OrchestrationPane' import { AccountsPane } from './AccountsPane' @@ -271,6 +272,7 @@ function Settings(): React.JSX.Element { const settings = useAppStore((s) => s.settings) const keybindings = useAppStore((s) => s.keybindings) const updateSettings = useAppStore((s) => s.updateSettings) + const updateSettingsOrThrow = useAppStore((s) => s.updateSettingsOrThrow) const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) const fetchSettings = useAppStore((s) => s.fetchSettings) const fetchKeybindings = useAppStore((s) => s.fetchKeybindings) @@ -1646,6 +1648,14 @@ function Settings(): React.JSX.Element { ) : null} + {showDesktopOnlySettings ? ( + + ) : null} + {repos.map((repo) => { const repoSectionId = `repo-${repo.id}` const repoHooksState = repoHooksMap[repo.id] diff --git a/src/renderer/src/components/settings/plugin-error-presentation.ts b/src/renderer/src/components/settings/plugin-error-presentation.ts new file mode 100644 index 00000000000..78072aecbf0 --- /dev/null +++ b/src/renderer/src/components/settings/plugin-error-presentation.ts @@ -0,0 +1,95 @@ +import { translate } from '@/i18n/i18n' + +function errorText(cause: unknown): string { + return (cause instanceof Error ? cause.message : String(cause)).toLowerCase() +} + +export function pluginInstallErrorMessage(cause: unknown): string { + const detail = errorText(cause) + if (detail.includes('orca-plugin.json') && /(missing|unreadable|no )/.test(detail)) { + return translate( + 'auto.components.settings.pluginError.installManifestMissing', + "No readable orca-plugin.json was found. Choose the plugin's root folder." + ) + } + if (detail.includes('invalid manifest')) { + return translate( + 'auto.components.settings.pluginError.installManifestInvalid', + 'orca-plugin.json is invalid. Ask the plugin author to fix the manifest.' + ) + } + if (detail.includes('requires orca')) { + return translate( + 'auto.components.settings.pluginError.incompatible', + 'This plugin requires a different Orca version.' + ) + } + if (/(symlink|outside|absolute|path traversal|drive prefix)/.test(detail)) { + return translate( + 'auto.components.settings.pluginError.installUnsafePath', + 'The plugin contains an unsafe file path or symlink and was not installed.' + ) + } + if (/(exceeds|too many)/.test(detail)) { + return translate( + 'auto.components.settings.pluginError.installLimit', + "The plugin exceeds Orca's install size or file-count limits." + ) + } + if (/(git|repository|fetch|clone|checkout|remote)/.test(detail)) { + return translate( + 'auto.components.settings.pluginError.installGit', + 'Orca could not fetch the pinned Git revision. Check the URL, #ref, access, and system Git setup.' + ) + } + return translate( + 'auto.components.settings.PluginInstallDialog.installFailed', + 'Plugin installation failed. Check the source and try again.' + ) +} + +export function invalidPluginErrorMessage(detailValue: string): string { + const detail = detailValue.toLowerCase() + if (detail.includes('missing orca-plugin.json')) { + return translate( + 'auto.components.settings.pluginError.invalidManifestMissing', + 'The plugin root is missing orca-plugin.json. Add it, then refresh plugins.' + ) + } + if (detail.includes('invalid manifest')) { + return translate( + 'auto.components.settings.pluginError.invalidManifest', + 'orca-plugin.json is invalid. Fix it, then refresh plugins.' + ) + } + if (detail.includes('artifact')) { + return translate( + 'auto.components.settings.pluginError.invalidArtifact', + 'A declared worker or panel file is missing or unsafe. Fix the plugin files, then refresh.' + ) + } + if (detail.includes('requires orca')) { + return translate( + 'auto.components.settings.pluginError.incompatible', + 'This plugin requires a different Orca version.' + ) + } + return translate( + 'auto.components.settings.PluginSettingsRow.invalidPluginError', + 'The plugin manifest or installed files are invalid. Fix the plugin, then refresh.' + ) +} + +export function pluginConsentErrorMessage(cause: unknown): string { + const detail = errorText(cause) + if (/(changed|fingerprint|current|stale|review)/.test(detail)) { + return translate( + 'auto.components.settings.pluginError.consentChanged', + 'The plugin changed while you were reviewing it. Close this dialog and review the updated permissions.' + ) + } + return translate( + 'auto.components.settings.PluginConsentDialog.decisionFailed', + 'Could not save the permission decision. Try again.' + ) +} diff --git a/src/renderer/src/components/settings/plugin-install-source.test.ts b/src/renderer/src/components/settings/plugin-install-source.test.ts new file mode 100644 index 00000000000..194d091aa62 --- /dev/null +++ b/src/renderer/src/components/settings/plugin-install-source.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { parsePluginInstallSource } from './plugin-install-source' + +describe('parsePluginInstallSource', () => { + it('requires an explicit git hash ref', () => { + expect(parsePluginInstallSource('git', 'https://gitlab.example/acme/plugin')).toEqual({ + ok: false, + reason: 'missing-git-ref' + }) + expect(parsePluginInstallSource('git', 'https://gitlab.example/acme/plugin#')).toEqual({ + ok: false, + reason: 'missing-git-ref' + }) + }) + + it('separates the git URL and ref at the last hash', () => { + expect(parsePluginInstallSource('git', 'https://example.test/plugin.git#v1.2.3')).toEqual({ + ok: true, + source: { kind: 'git', url: 'https://example.test/plugin.git', ref: 'v1.2.3' } + }) + }) + + it('accepts HTTPS and SSH git URLs', () => { + expect(parsePluginInstallSource('git', 'ssh://git@example.test/acme/plugin.git#main')).toEqual({ + ok: true, + source: { kind: 'git', url: 'ssh://git@example.test/acme/plugin.git', ref: 'main' } + }) + expect(parsePluginInstallSource('git', 'git@example.test:acme/plugin.git#main')).toEqual({ + ok: true, + source: { kind: 'git', url: 'git@example.test:acme/plugin.git', ref: 'main' } + }) + }) + + it('rejects executable helpers and embedded HTTPS credentials', () => { + expect(parsePluginInstallSource('git', 'ext::sh -c touch% /tmp/pwned#main')).toEqual({ + ok: false, + reason: 'invalid-git-url' + }) + expect(parsePluginInstallSource('git', 'https://user@example.test/plugin.git#main')).toEqual({ + ok: false, + reason: 'invalid-git-url' + }) + }) + + it('keeps local paths opaque', () => { + expect(parsePluginInstallSource('local-path', ' C:\\plugins\\demo ')).toEqual({ + ok: true, + source: { kind: 'local-path', path: 'C:\\plugins\\demo' } + }) + }) +}) diff --git a/src/renderer/src/components/settings/plugin-install-source.ts b/src/renderer/src/components/settings/plugin-install-source.ts new file mode 100644 index 00000000000..518c0bd21fd --- /dev/null +++ b/src/renderer/src/components/settings/plugin-install-source.ts @@ -0,0 +1,40 @@ +import type { PluginHostInstallSource } from '../../../../preload/api-types' +import { isAllowedPluginGitUrl } from '../../../../shared/plugins/plugin-install-lockfile' + +export type PluginInstallSourceParseResult = + | { ok: true; source: PluginHostInstallSource } + | { + ok: false + reason: 'missing-local-path' | 'missing-git-url' | 'missing-git-ref' | 'invalid-git-url' + } + +export function parsePluginInstallSource( + kind: 'local-path' | 'git', + input: string +): PluginInstallSourceParseResult { + const value = input.trim() + if (kind === 'local-path') { + return value + ? { ok: true, source: { kind: 'local-path', path: value } } + : { ok: false, reason: 'missing-local-path' } + } + if (!value) { + return { ok: false, reason: 'missing-git-url' } + } + const separatorIndex = value.lastIndexOf('#') + if (separatorIndex <= 0 || separatorIndex === value.length - 1) { + return { ok: false, reason: 'missing-git-ref' } + } + const url = value.slice(0, separatorIndex).trim() + const ref = value.slice(separatorIndex + 1).trim() + if (!url) { + return { ok: false, reason: 'missing-git-url' } + } + if (!isAllowedPluginGitUrl(url)) { + return { ok: false, reason: 'invalid-git-url' } + } + if (!ref) { + return { ok: false, reason: 'missing-git-ref' } + } + return { ok: true, source: { kind: 'git', url, ref } } +} diff --git a/src/renderer/src/components/settings/plugins-search.ts b/src/renderer/src/components/settings/plugins-search.ts new file mode 100644 index 00000000000..310b10e8a37 --- /dev/null +++ b/src/renderer/src/components/settings/plugins-search.ts @@ -0,0 +1,19 @@ +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { translate } from '@/i18n/i18n' +import type { SettingsSearchEntry } from './settings-search' + +export const getPluginsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ + { + title: translate('auto.components.settings.plugins.search.title', 'Plugins'), + description: translate( + 'auto.components.settings.plugins.search.description', + 'Install and manage experimental Orca plugins.' + ), + keywords: [ + translate('auto.components.settings.plugins.search.install', 'install plugin'), + translate('auto.components.settings.plugins.search.permissions', 'plugin permissions'), + translate('auto.components.settings.plugins.search.logs', 'plugin logs'), + translate('auto.components.settings.plugins.search.development', 'development plugins') + ] + } +]) diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index 59c2cca3d14..46cb56800a3 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -105,6 +105,20 @@ describe('settings navigation metadata', () => { expect(entry?.targetSectionId).toBe('ephemeral-vms') }) + it('places Plugins under Experimental on desktop and omits it on the web', () => { + const desktopSections = buildSettingsNavigationMetadata({ + isMac: false, + isWindows: false, + isWebClient: false, + repos: [repo] + }) + const desktopIds = desktopSections.map((section) => section.id) + + expect(desktopSections.find((section) => section.id === 'plugins')?.group).toBe('experimental') + expect(desktopIds.indexOf('plugins')).toBe(desktopIds.indexOf('experimental') + 1) + expect(ids({ isWebClient: true })).not.toContain('plugins') + }) + it('omits Windows project runtime search entries when the active host is unsupported', () => { const sections = buildSettingsNavigationMetadata({ isMac: false, diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 03ba64e44f0..4872f57854e 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -70,6 +70,7 @@ import { getAdvancedPaneSearchEntries } from '@/components/settings/advanced-sea import { getShortcutsPaneSearchEntries } from '@/components/settings/shortcuts-search' import { getStatsPaneSearchEntries } from '@/components/stats/stats-search' import { getExperimentalPaneSearchEntries } from '@/components/settings/experimental-search' +import { getPluginsPaneSearchEntries } from '@/components/settings/plugins-search' import { getRepositoryPaneSearchEntries } from '@/components/settings/repository-search' import { isWebClientLocation } from '@/lib/web-client-location' import { @@ -537,6 +538,21 @@ export function buildSettingsNavigationMetadata({ searchEntries: getExperimentalPaneSearchEntries(), group: 'experimental' }, + ...(showDesktopOnlySettings + ? [ + { + id: 'plugins', + title: translate('auto.hooks.useSettingsNavigationMetadata.pluginsTitle', 'Plugins'), + description: translate( + 'auto.hooks.useSettingsNavigationMetadata.pluginsDescription', + 'Install and manage experimental Orca plugins.' + ), + icon: Blocks, + searchEntries: getPluginsPaneSearchEntries(), + group: 'experimental' + } + ] + : []), ...repos.map((repo) => ({ id: `repo-${repo.id}`, title: repo.displayName, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 298dce57f60..2a5a0318c87 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9038,7 +9038,7 @@ "PluginConsentDialog": { "workerTrust": "Background worker — runs its own process", "panelTrust": "Panel only — no worker process", - "decisionFailed": "Could not save the permission decision.", + "decisionFailed": "Could not save the permission decision. Try again.", "title": "Review permissions", "subtitle": "{{value0}} v{{value1}} · {{value2}}", "reconsent": "Permissions or the worker trust tier changed since you last reviewed this plugin. Review them again before it can run.", @@ -9069,13 +9069,14 @@ "remove": "Remove", "placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", "add": "Add path", - "saveFailed": "Could not save development plugin paths." + "saveFailed": "Could not save development plugin paths.", + "pathLabel": "Development plugin folder path" }, "PluginInstallDialog": { "localRequired": "Enter the plugin folder path.", "gitUrlRequired": "Enter a repository URL.", "gitRefRequired": "Add an explicit #ref (tag or commit) so the install is pinned — for example #v0.1.0.", - "installFailed": "Plugin installation failed.", + "installFailed": "Plugin installation failed. Check the source and try again.", "title": "Install plugin", "description": "Installing copies the plugin into Orca and shows its permissions for review. No plugin code runs until you enable it.", "source": "Install source", @@ -9118,7 +9119,10 @@ "remove": "Remove", "enableLabel": "Enable {{value0}}", "restartCount": " · {{value0}} restarts", - "restarting": "Restarting" + "restarting": "Restarting", + "invalidPluginError": "The plugin manifest or installed files are invalid. Fix the plugin, then refresh.", + "runtimeError": "The plugin stopped after an activation or worker error.", + "disableLabel": "Disable {{value0}}" }, "PluginsSettingsSection": { "loadFailed": "Could not load plugins.", @@ -9146,6 +9150,18 @@ "logs": "plugin logs", "development": "development plugins" } + }, + "pluginError": { + "installManifestMissing": "No readable orca-plugin.json was found. Choose the plugin's root folder.", + "installManifestInvalid": "orca-plugin.json is invalid. Ask the plugin author to fix the manifest.", + "incompatible": "This plugin requires a different Orca version.", + "installUnsafePath": "The plugin contains an unsafe file path or symlink and was not installed.", + "installLimit": "The plugin exceeds Orca's install size or file-count limits.", + "installGit": "Orca could not fetch the pinned Git revision. Check the URL, #ref, access, and system Git setup.", + "invalidManifestMissing": "The plugin root is missing orca-plugin.json. Add it, then refresh plugins.", + "invalidManifest": "orca-plugin.json is invalid. Fix it, then refresh plugins.", + "invalidArtifact": "A declared worker or panel file is missing or unsafe. Fix the plugin files, then refresh.", + "consentChanged": "The plugin changed while you were reviewing it. Close this dialog and review the updated permissions." } }, "right": { @@ -10259,6 +10275,9 @@ "workspaceGone": "Couldn't open log — workspace is no longer available.", "notAuthorized": "Couldn't open log — path not authorized.", "alreadyEditable": "Log is already open for editing." + }, + "activityBar": { + "error": "Error" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0f0da1b9d2b..92abfaed039 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9038,7 +9038,7 @@ "PluginConsentDialog": { "workerTrust": "Proceso en segundo plano — se ejecuta en su propio proceso", "panelTrust": "Solo panel — sin proceso en segundo plano", - "decisionFailed": "No se pudo guardar la decisión sobre los permisos.", + "decisionFailed": "No se pudo guardar la decisión sobre los permisos. Inténtalo de nuevo.", "title": "Revisar permisos", "subtitle": "{{value0}} v{{value1}} · {{value2}}", "reconsent": "Los permisos o el nivel de confianza del proceso cambiaron desde la última revisión de este plugin. Revísalos de nuevo antes de ejecutarlo.", @@ -9069,13 +9069,14 @@ "remove": "Eliminar", "placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", "add": "Añadir ruta", - "saveFailed": "No se pudieron guardar las rutas de plugins de desarrollo." + "saveFailed": "No se pudieron guardar las rutas de plugins de desarrollo.", + "pathLabel": "Ruta de la carpeta del plugin de desarrollo" }, "PluginInstallDialog": { "localRequired": "Introduce la ruta de la carpeta del plugin.", "gitUrlRequired": "Introduce una URL de repositorio.", "gitRefRequired": "Añade una #ref explícita (etiqueta o commit) para fijar la instalación; por ejemplo, #v0.1.0.", - "installFailed": "No se pudo instalar el plugin.", + "installFailed": "No se pudo instalar el plugin. Comprueba el origen e inténtalo de nuevo.", "title": "Instalar plugin", "description": "La instalación copia el plugin en Orca y muestra sus permisos para que los revises. No se ejecuta ningún código del plugin hasta que lo actives.", "source": "Origen de instalación", @@ -9118,7 +9119,10 @@ "remove": "Eliminar", "enableLabel": "Activar {{value0}}", "restartCount": " · {{value0}} reinicios", - "restarting": "Reiniciando" + "restarting": "Reiniciando", + "invalidPluginError": "El manifiesto o los archivos instalados del plugin no son válidos. Corrige el plugin y actualiza.", + "runtimeError": "El plugin se detuvo tras un error de activación o del proceso.", + "disableLabel": "Desactivar {{value0}}" }, "PluginsSettingsSection": { "loadFailed": "No se pudieron cargar los plugins.", @@ -9146,6 +9150,18 @@ "logs": "registros de plugins", "development": "plugins de desarrollo" } + }, + "pluginError": { + "installManifestMissing": "No se encontró un orca-plugin.json legible. Elige la carpeta raíz del plugin.", + "installManifestInvalid": "orca-plugin.json no es válido. Pide al autor del plugin que corrija el manifiesto.", + "incompatible": "Este plugin requiere otra versión de Orca.", + "installUnsafePath": "El plugin contiene una ruta de archivo o un enlace simbólico no seguros y no se instaló.", + "installLimit": "El plugin supera los límites de tamaño o cantidad de archivos de Orca.", + "installGit": "Orca no pudo obtener la revisión Git fijada. Comprueba la URL, la #ref, el acceso y la configuración de Git del sistema.", + "invalidManifestMissing": "A la raíz del plugin le falta orca-plugin.json. Añádelo y actualiza los plugins.", + "invalidManifest": "orca-plugin.json no es válido. Corrígelo y actualiza los plugins.", + "invalidArtifact": "Falta un archivo de proceso o panel declarado, o no es seguro. Corrige los archivos del plugin y actualiza.", + "consentChanged": "El plugin cambió mientras lo revisabas. Cierra este diálogo y revisa los permisos actualizados." } }, "right": { @@ -10259,6 +10275,9 @@ "unavailable": "Este panel de plugin ya no está disponible.", "loadFailed": "No se pudo cargar el panel del plugin.", "unresponsive": "El panel del plugin dejó de responder y se suspendió." + }, + "activityBar": { + "error": "Error" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ac145130d6f..731f41b5e19 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9038,7 +9038,7 @@ "PluginConsentDialog": { "workerTrust": "バックグラウンドワーカー — 独自のプロセスで実行", "panelTrust": "パネルのみ — ワーカープロセスなし", - "decisionFailed": "権限の選択を保存できませんでした。", + "decisionFailed": "権限の選択を保存できませんでした。再試行してください。", "title": "権限を確認", "subtitle": "{{value0}} v{{value1}} · {{value2}}", "reconsent": "前回このプラグインを確認してから、権限またはワーカーの信頼レベルが変更されました。実行する前にもう一度確認してください。", @@ -9069,13 +9069,14 @@ "remove": "削除", "placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", "add": "パスを追加", - "saveFailed": "開発用プラグインのパスを保存できませんでした。" + "saveFailed": "開発用プラグインのパスを保存できませんでした。", + "pathLabel": "開発用プラグインのフォルダーパス" }, "PluginInstallDialog": { "localRequired": "プラグインフォルダーのパスを入力してください。", "gitUrlRequired": "リポジトリ URL を入力してください。", "gitRefRequired": "インストールを固定するため、明示的な #ref(タグまたはコミット)を追加してください。例: #v0.1.0。", - "installFailed": "プラグインのインストールに失敗しました。", + "installFailed": "プラグインをインストールできませんでした。ソースを確認して再試行してください。", "title": "プラグインをインストール", "description": "インストールするとプラグインが Orca にコピーされ、確認する権限が表示されます。有効にするまでプラグインのコードは実行されません。", "source": "インストール元", @@ -9118,7 +9119,10 @@ "remove": "削除", "enableLabel": "{{value0}} を有効化", "restartCount": " · {{value0}} 回再起動", - "restarting": "再起動中" + "restarting": "再起動中", + "invalidPluginError": "プラグインのマニフェストまたはインストール済みファイルが無効です。修正してから更新してください。", + "runtimeError": "有効化またはワーカーのエラー後にプラグインが停止しました。", + "disableLabel": "{{value0}}を無効にする" }, "PluginsSettingsSection": { "loadFailed": "プラグインを読み込めませんでした。", @@ -9146,6 +9150,18 @@ "logs": "プラグインのログ", "development": "開発用プラグイン" } + }, + "pluginError": { + "installManifestMissing": "読み取り可能なorca-plugin.jsonが見つかりません。プラグインのルートフォルダーを選択してください。", + "installManifestInvalid": "orca-plugin.jsonが無効です。プラグインの作成者にマニフェストの修正を依頼してください。", + "incompatible": "このプラグインには別のバージョンのOrcaが必要です。", + "installUnsafePath": "プラグインに安全でないファイルパスまたはシンボリックリンクが含まれているため、インストールされませんでした。", + "installLimit": "プラグインがOrcaのインストールサイズまたはファイル数の上限を超えています。", + "installGit": "固定されたGitリビジョンを取得できませんでした。URL、#ref、アクセス権、システムGitの設定を確認してください。", + "invalidManifestMissing": "プラグインのルートにorca-plugin.jsonがありません。追加してからプラグインを更新してください。", + "invalidManifest": "orca-plugin.jsonが無効です。修正してからプラグインを更新してください。", + "invalidArtifact": "宣言されたワーカーまたはパネルのファイルがないか、安全ではありません。ファイルを修正してから更新してください。", + "consentChanged": "確認中にプラグインが変更されました。このダイアログを閉じて、更新された権限を確認してください。" } }, "right": { @@ -10259,6 +10275,9 @@ "unavailable": "このプラグインパネルは利用できなくなりました。", "loadFailed": "プラグインパネルを読み込めませんでした。", "unresponsive": "プラグインパネルが応答しなくなったため停止しました。" + }, + "activityBar": { + "error": "エラー" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e005bf4a594..aa7f2ebd8e2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9038,7 +9038,7 @@ "PluginConsentDialog": { "workerTrust": "백그라운드 워커 — 별도 프로세스에서 실행", "panelTrust": "패널 전용 — 워커 프로세스 없음", - "decisionFailed": "권한 결정을 저장할 수 없습니다.", + "decisionFailed": "권한 결정을 저장할 수 없습니다. 다시 시도하세요.", "title": "권한 검토", "subtitle": "{{value0}} v{{value1}} · {{value2}}", "reconsent": "이 플러그인을 마지막으로 검토한 후 권한 또는 워커 신뢰 수준이 변경되었습니다. 실행하기 전에 다시 검토하세요.", @@ -9069,13 +9069,14 @@ "remove": "제거", "placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", "add": "경로 추가", - "saveFailed": "개발용 플러그인 경로를 저장할 수 없습니다." + "saveFailed": "개발용 플러그인 경로를 저장할 수 없습니다.", + "pathLabel": "개발 플러그인 폴더 경로" }, "PluginInstallDialog": { "localRequired": "플러그인 폴더 경로를 입력하세요.", "gitUrlRequired": "저장소 URL을 입력하세요.", "gitRefRequired": "설치를 고정하려면 명시적인 #ref(태그 또는 커밋)를 추가하세요. 예: #v0.1.0.", - "installFailed": "플러그인 설치에 실패했습니다.", + "installFailed": "플러그인을 설치하지 못했습니다. 소스를 확인하고 다시 시도하세요.", "title": "플러그인 설치", "description": "설치하면 플러그인이 Orca에 복사되고 검토할 권한이 표시됩니다. 활성화하기 전에는 플러그인 코드가 실행되지 않습니다.", "source": "설치 소스", @@ -9118,7 +9119,10 @@ "remove": "제거", "enableLabel": "{{value0}} 활성화", "restartCount": " · {{value0}}회 다시 시작", - "restarting": "다시 시작 중" + "restarting": "다시 시작 중", + "invalidPluginError": "플러그인 매니페스트 또는 설치된 파일이 올바르지 않습니다. 수정한 후 새로 고치세요.", + "runtimeError": "활성화 또는 워커 오류 후 플러그인이 중지되었습니다.", + "disableLabel": "{{value0}} 비활성화" }, "PluginsSettingsSection": { "loadFailed": "플러그인을 불러올 수 없습니다.", @@ -9146,6 +9150,18 @@ "logs": "플러그인 로그", "development": "개발용 플러그인" } + }, + "pluginError": { + "installManifestMissing": "읽을 수 있는 orca-plugin.json을 찾지 못했습니다. 플러그인의 루트 폴더를 선택하세요.", + "installManifestInvalid": "orca-plugin.json이 올바르지 않습니다. 플러그인 작성자에게 매니페스트 수정을 요청하세요.", + "incompatible": "이 플러그인에는 다른 버전의 Orca가 필요합니다.", + "installUnsafePath": "플러그인에 안전하지 않은 파일 경로 또는 심볼릭 링크가 있어 설치하지 않았습니다.", + "installLimit": "플러그인이 Orca의 설치 크기 또는 파일 수 제한을 초과합니다.", + "installGit": "고정된 Git 리비전을 가져오지 못했습니다. URL, #ref, 접근 권한 및 시스템 Git 설정을 확인하세요.", + "invalidManifestMissing": "플러그인 루트에 orca-plugin.json이 없습니다. 추가한 후 플러그인을 새로 고치세요.", + "invalidManifest": "orca-plugin.json이 올바르지 않습니다. 수정한 후 플러그인을 새로 고치세요.", + "invalidArtifact": "선언된 워커 또는 패널 파일이 없거나 안전하지 않습니다. 파일을 수정한 후 새로 고치세요.", + "consentChanged": "검토하는 동안 플러그인이 변경되었습니다. 이 대화상자를 닫고 업데이트된 권한을 검토하세요." } }, "right": { @@ -10259,6 +10275,9 @@ "unavailable": "이 플러그인 패널은 더 이상 사용할 수 없습니다.", "loadFailed": "플러그인 패널을 불러올 수 없습니다.", "unresponsive": "플러그인 패널이 응답하지 않아 일시 중단되었습니다." + }, + "activityBar": { + "error": "오류" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ef81276e276..75276ee5173 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9038,7 +9038,7 @@ "PluginConsentDialog": { "workerTrust": "后台工作进程 — 在独立进程中运行", "panelTrust": "仅面板 — 无工作进程", - "decisionFailed": "无法保存权限决定。", + "decisionFailed": "无法保存权限决定。请重试。", "title": "检查权限", "subtitle": "{{value0}} v{{value1}} · {{value2}}", "reconsent": "自你上次检查此插件以来,权限或工作进程信任级别已更改。请在运行前重新检查。", @@ -9069,13 +9069,14 @@ "remove": "移除", "placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", "add": "添加路径", - "saveFailed": "无法保存开发插件路径。" + "saveFailed": "无法保存开发插件路径。", + "pathLabel": "开发插件文件夹路径" }, "PluginInstallDialog": { "localRequired": "请输入插件文件夹路径。", "gitUrlRequired": "请输入仓库 URL。", "gitRefRequired": "请添加明确的 #ref(标签或提交)以固定安装版本,例如 #v0.1.0。", - "installFailed": "插件安装失败。", + "installFailed": "插件安装失败。请检查来源后重试。", "title": "安装插件", "description": "安装会将插件复制到 Orca,并显示其权限供你检查。启用插件之前,不会运行任何插件代码。", "source": "安装来源", @@ -9118,7 +9119,10 @@ "remove": "移除", "enableLabel": "启用 {{value0}}", "restartCount": " · 重启 {{value0}} 次", - "restarting": "正在重新启动" + "restarting": "正在重新启动", + "invalidPluginError": "插件清单或已安装文件无效。请修复插件后刷新。", + "runtimeError": "插件在激活或工作进程出错后停止。", + "disableLabel": "停用 {{value0}}" }, "PluginsSettingsSection": { "loadFailed": "无法加载插件。", @@ -9146,6 +9150,18 @@ "logs": "插件日志", "development": "开发插件" } + }, + "pluginError": { + "installManifestMissing": "未找到可读取的 orca-plugin.json。请选择插件的根文件夹。", + "installManifestInvalid": "orca-plugin.json 无效。请让插件作者修复清单。", + "incompatible": "此插件需要其他版本的 Orca。", + "installUnsafePath": "插件包含不安全的文件路径或符号链接,因此未安装。", + "installLimit": "插件超出 Orca 的安装大小或文件数量限制。", + "installGit": "Orca 无法获取固定的 Git 修订。请检查 URL、#ref、访问权限和系统 Git 配置。", + "invalidManifestMissing": "插件根目录缺少 orca-plugin.json。请添加后刷新插件。", + "invalidManifest": "orca-plugin.json 无效。请修复后刷新插件。", + "invalidArtifact": "声明的工作进程或面板文件缺失或不安全。请修复插件文件后刷新。", + "consentChanged": "插件在你查看时发生了更改。请关闭此对话框并查看更新后的权限。" } }, "right": { @@ -10259,6 +10275,9 @@ "unavailable": "此插件面板已不可用。", "loadFailed": "无法加载插件面板。", "unresponsive": "插件面板停止响应,已被暂停。" + }, + "activityBar": { + "error": "错误" } } }, diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 765940ffe9e..9ee6088cc8e 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -28,6 +28,7 @@ export type SettingsNavTarget = | 'stats' | 'ssh' | 'experimental' + | 'plugins' | 'agents' | 'orchestration' | 'servers' diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index dd113fa668a..e409299d4ee 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -139,6 +139,70 @@ beforeEach(() => { }) }) +describe('createSettingsSlice checked persistence', () => { + it('stores the authoritative settings after a successful checked update', async () => { + const authoritativeSettings = { + pluginSystemEnabled: true, + notifications: {} + } as unknown as NonNullable + settingsSet.mockResolvedValueOnce(authoritativeSettings) + const store = createTestStore() + store.setState({ + settings: { + pluginSystemEnabled: false, + notifications: {} + } as unknown as AppState['settings'] + }) + + await expect( + store.getState().updateSettingsOrThrow({ pluginSystemEnabled: true }) + ).resolves.toBeUndefined() + + expect(settingsSet).toHaveBeenCalledWith({ pluginSystemEnabled: true }) + expect(store.getState().settings).toBe(authoritativeSettings) + }) + + it('rejects a failed checked update without changing local settings', async () => { + const persistenceError = new Error('settings IPC failed') + const currentSettings = { + pluginSystemEnabled: false, + notifications: {} + } as unknown as NonNullable + settingsSet.mockRejectedValueOnce(persistenceError) + const store = createTestStore() + store.setState({ settings: currentSettings }) + + await expect( + store.getState().updateSettingsOrThrow({ pluginSystemEnabled: true }) + ).rejects.toBe(persistenceError) + + expect(store.getState().settings).toBe(currentSettings) + }) + + it('keeps the existing update action best-effort and logs persistence failures', async () => { + const persistenceError = new Error('settings IPC failed') + const currentSettings = { + pluginSystemEnabled: false, + notifications: {} + } as unknown as NonNullable + settingsSet.mockRejectedValueOnce(persistenceError) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const store = createTestStore() + store.setState({ settings: currentSettings }) + + try { + await expect( + store.getState().updateSettings({ pluginSystemEnabled: true }) + ).resolves.toBeUndefined() + + expect(consoleError).toHaveBeenCalledWith('Failed to update settings:', persistenceError) + expect(store.getState().settings).toBe(currentSettings) + } finally { + consoleError.mockRestore() + } + }) +}) + describe('createSettingsSlice runtime switching', () => { it('repairs drifted task provider settings before sending updates', async () => { settingsSet.mockResolvedValueOnce({ diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index f9d4d4f3699..3c3c4d1f2db 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -28,6 +28,7 @@ export type SettingsSlice = SettingsSearchState & { settings: GlobalSettings | null fetchSettings: () => Promise updateSettings: (updates: Partial) => Promise + updateSettingsOrThrow: (updates: Partial) => Promise switchRuntimeEnvironment: (environmentId: string | null) => Promise } @@ -35,6 +36,8 @@ type LegacyTerminalScrollbackSettingsUpdate = Partial & { terminalScrollbackBytes?: unknown } +type SettingsStateSetter = Parameters>[0] + function normalizeRuntimeEnvironmentId(value: string | null | undefined): string | null { const trimmed = value?.trim() return trimmed ? trimmed : null @@ -47,6 +50,77 @@ function createOpenInApplicationId(): string { ) } +function normalizeSettingsUpdates( + updates: Partial, + currentSettings: GlobalSettings | null +): Partial { + const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedUpdates } = + updates as LegacyTerminalScrollbackSettingsUpdate + void _legacyScrollbackBytes + if ('terminalQuickCommands' in updates) { + sanitizedUpdates.terminalQuickCommands = normalizeTerminalQuickCommands( + updates.terminalQuickCommands + ) + } + if ('terminalCustomThemes' in updates) { + sanitizedUpdates.terminalCustomThemes = normalizeTerminalCustomThemes( + updates.terminalCustomThemes + ) + } + if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { + const taskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: + 'visibleTaskProviders' in updates + ? updates.visibleTaskProviders + : currentSettings?.visibleTaskProviders, + defaultTaskSource: + 'defaultTaskSource' in updates + ? updates.defaultTaskSource + : currentSettings?.defaultTaskSource + }) + sanitizedUpdates.defaultTaskSource = taskProviderSettings.defaultTaskSource + sanitizedUpdates.visibleTaskProviders = taskProviderSettings.visibleTaskProviders + } + if ('openInApplications' in updates) { + sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications, { + createId: createOpenInApplicationId + }) + } + if ('disabledTuiAgents' in updates) { + sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) + } + if ('agentDefaultArgs' in updates) { + sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs) + sanitizedUpdates.agentYoloDefaultsMigrated = true + } + if ('agentDefaultEnv' in updates) { + sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv) + sanitizedUpdates.agentYoloDefaultsMigrated = true + } + if ('uiLanguage' in updates) { + sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage) + } + if ('terminalScrollbackRows' in updates) { + sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( + updates.terminalScrollbackRows + ) + } + return sanitizedUpdates +} + +async function persistSettingsUpdates( + set: SettingsStateSetter, + updates: Partial, + currentSettings: GlobalSettings | null +): Promise { + const nextSettings = await window.api.settings.set( + normalizeSettingsUpdates(updates, currentSettings) + ) + set((state) => ({ + settings: (nextSettings as GlobalSettings | undefined) ?? state.settings + })) +} + async function verifyRuntimeEnvironmentReachable(environmentId: string | null): Promise { if (!environmentId) { return @@ -81,67 +155,16 @@ export const createSettingsSlice: StateCreator updateSettings: async (updates) => { try { - const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedUpdates } = - updates as LegacyTerminalScrollbackSettingsUpdate - void _legacyScrollbackBytes - if ('terminalQuickCommands' in updates) { - sanitizedUpdates.terminalQuickCommands = normalizeTerminalQuickCommands( - updates.terminalQuickCommands - ) - } - if ('terminalCustomThemes' in updates) { - sanitizedUpdates.terminalCustomThemes = normalizeTerminalCustomThemes( - updates.terminalCustomThemes - ) - } - if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { - const taskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: - 'visibleTaskProviders' in updates - ? updates.visibleTaskProviders - : get().settings?.visibleTaskProviders, - defaultTaskSource: - 'defaultTaskSource' in updates - ? updates.defaultTaskSource - : get().settings?.defaultTaskSource - }) - sanitizedUpdates.defaultTaskSource = taskProviderSettings.defaultTaskSource - sanitizedUpdates.visibleTaskProviders = taskProviderSettings.visibleTaskProviders - } - if ('openInApplications' in updates) { - sanitizedUpdates.openInApplications = normalizeOpenInApplications( - updates.openInApplications, - { - createId: createOpenInApplicationId - } - ) - } - if ('disabledTuiAgents' in updates) { - sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) - } - if ('agentDefaultArgs' in updates) { - sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs) - sanitizedUpdates.agentYoloDefaultsMigrated = true - } - if ('agentDefaultEnv' in updates) { - sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv) - sanitizedUpdates.agentYoloDefaultsMigrated = true - } - if ('uiLanguage' in updates) { - sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage) - } - if ('terminalScrollbackRows' in updates) { - sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( - updates.terminalScrollbackRows - ) - } - const nextSettings = await window.api.settings.set(sanitizedUpdates) - set((s) => ({ settings: (nextSettings as GlobalSettings | undefined) ?? s.settings })) + await persistSettingsUpdates(set, updates, get().settings) } catch (err) { console.error('Failed to update settings:', err) } }, + updateSettingsOrThrow: async (updates) => { + await persistSettingsUpdates(set, updates, get().settings) + }, + switchRuntimeEnvironment: async (environmentId) => { const nextId = normalizeRuntimeEnvironmentId(environmentId) const previousId = normalizeRuntimeEnvironmentId(get().settings?.activeRuntimeEnvironmentId) diff --git a/src/shared/plugins/plugin-demo-fixture.test.ts b/src/shared/plugins/plugin-demo-fixture.test.ts new file mode 100644 index 00000000000..d499cb4e279 --- /dev/null +++ b/src/shared/plugins/plugin-demo-fixture.test.ts @@ -0,0 +1,20 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { pluginManifestSchema } from './plugin-manifest' + +describe('hello Orca plugin fixture', () => { + it('uses an ESM entry that remains loadable outside a type-module package', async () => { + const root = join(process.cwd(), 'examples', 'plugins', 'hello-orca') + const manifest = pluginManifestSchema.parse( + JSON.parse(await readFile(join(root, 'orca-plugin.json'), 'utf8')) + ) + + expect(manifest.main).toBe('main.mjs') + const workerModule = (await import(pathToFileURL(join(root, manifest.main!)).href)) as { + default?: unknown + } + expect(workerModule.default).toBeTypeOf('function') + }) +}) diff --git a/tests/e2e/plugin-demo.spec.ts b/tests/e2e/plugin-demo.spec.ts new file mode 100644 index 00000000000..860e1aac1f1 --- /dev/null +++ b/tests/e2e/plugin-demo.spec.ts @@ -0,0 +1,180 @@ +/** + * Invariant: the documented hello-orca plugin stays inert before visible + * consent, then its panel, worker command, and event subscription all work. + */ + +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' + +async function openPluginSettings(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state) { + throw new Error('store unavailable') + } + state.openSettingsTarget({ pane: 'plugins', repoId: null }) + state.openSettingsPage() + }) + await expect(page.locator('[data-settings-section="plugins"]')).toBeVisible() +} + +async function openDemoPanel(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state) { + throw new Error('store unavailable') + } + state.closeSettingsPage() + if (!state.rightSidebarOpen) { + state.toggleRightSidebar() + } + }) + const panelButton = page.getByRole('button', { name: 'Hello Orca', exact: true }) + await expect(panelButton).toBeVisible({ timeout: 15_000 }) + await panelButton.click() + const frame = page.frameLocator('iframe[title="Hello Orca"]') + await expect(frame.getByRole('heading', { name: 'Hello Orca 👋' })).toBeVisible() + await expect(frame.locator('meta[http-equiv="Content-Security-Policy"]')).toHaveAttribute( + 'content', + /default-src 'none'/ + ) +} + +async function createWorktree(page: Page, name: string): Promise { + await page.waitForFunction( + () => { + const state = window.__store?.getState() + return Boolean(state && Object.values(state.worktreesByRepo).flat().length > 0) + }, + undefined, + { timeout: 15_000 } + ) + return page.evaluate(async (worktreeName) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('store unavailable') + } + const worktrees = Object.values(state.worktreesByRepo).flat() + const active = + worktrees.find((worktree) => worktree.id === state.activeWorktreeId) ?? worktrees[0] + if (!active) { + throw new Error('active worktree was not found') + } + const result = await state.createWorktree(active.repoId, worktreeName) + return result.worktree.id + }, name) +} + +test('runs hello-orca panel, command, and event behind visible consent', async ({ orcaPage }) => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-hello-plugin-e2e-')) + const pluginRoot = join(tempRoot, 'hello-orca') + let createdWorktreeId: string | null = null + await cp(join(process.cwd(), 'examples', 'plugins', 'hello-orca'), pluginRoot, { + recursive: true + }) + + try { + const installed = await orcaPage.evaluate(async (sourcePath) => { + const settings = await window.api.settings.set({ pluginSystemEnabled: true }) + window.__store?.setState({ settings }) + const result = await window.api.plugins.install({ kind: 'local-path', path: sourcePath }) + if (!result.ok) { + throw new Error(result.error) + } + const pending = (await window.api.plugins.refresh()).find( + (entry) => entry.pluginKey === result.pluginKey + ) + if (!pending) { + throw new Error('installed plugin was not listed') + } + let blocked = false + try { + await window.api.plugins.invokeCommand({ + pluginKey: result.pluginKey, + commandId: 'hello-ping', + args: { source: 'before-consent' } + }) + } catch { + blocked = true + } + return { pluginKey: result.pluginKey, status: pending.status, blocked } + }, pluginRoot) + + expect(installed.status).toBe('pending') + expect(installed.blocked).toBe(true) + + await openPluginSettings(orcaPage) + const row = orcaPage.locator(`[data-plugin-key="${installed.pluginKey}"]`) + await expect(row).toContainText('Needs review') + await row.getByRole('button', { name: 'Review permissions' }).click() + const consent = orcaPage.getByRole('dialog', { name: 'Review permissions' }) + await expect(consent).toBeVisible() + await expect(consent).toContainText(pluginRoot) + await expect(consent).toContainText('full access to your files, network, and other processes') + await expect(consent.getByRole('button', { name: 'Keep Disabled' })).toBeFocused() + await consent.getByRole('button', { name: 'Enable plugin' }).click() + await expect(consent).toBeHidden() + await expect(row).toContainText('Enabled') + + const commandResults = await orcaPage.evaluate(async (pluginKey) => { + const first = await window.api.plugins.invokeCommand({ + pluginKey, + commandId: 'hello-ping', + args: { source: 'e2e' } + }) + const second = await window.api.plugins.invokeCommand({ + pluginKey, + commandId: 'hello-ping', + args: { source: 'e2e' } + }) + return { first, second } + }, installed.pluginKey) + expect(commandResults.first).toEqual({ pong: true, count: 1, args: { source: 'e2e' } }) + expect(commandResults.second).toEqual({ pong: true, count: 2, args: { source: 'e2e' } }) + + await orcaPage.evaluate(async (sourcePath) => { + const settings = await window.api.settings.set({ devPluginPaths: [sourcePath] }) + window.__store?.setState({ settings }) + await window.api.plugins.refresh() + }, pluginRoot) + + await openDemoPanel(orcaPage) + + const panelPath = join(pluginRoot, 'panel.html') + const panelHtml = await readFile(panelPath, 'utf8') + await writeFile(panelPath, panelHtml.replace('Hello Orca 👋', 'Hello Orca reloaded')) + await expect( + orcaPage.frameLocator('iframe[title="Hello Orca"]').getByRole('heading', { + name: 'Hello Orca reloaded' + }) + ).toBeVisible({ timeout: 15_000 }) + + createdWorktreeId = await createWorktree(orcaPage, `plugin-event-${Date.now()}`) + await expect + .poll( + () => + orcaPage.evaluate( + async ({ pluginKey, worktreeId }) => + (await window.api.plugins.getLogs({ pluginKey })).some( + (entry) => + entry.line.includes('worktree created:') && entry.line.includes(worktreeId) + ), + { pluginKey: installed.pluginKey, worktreeId: createdWorktreeId! } + ), + { timeout: 15_000 } + ) + .toBe(true) + } finally { + if (createdWorktreeId) { + await orcaPage + .evaluate(async (worktreeId) => { + await window.__store?.getState().removeWorktree(worktreeId, true) + }, createdWorktreeId) + .catch(() => undefined) + } + await rm(tempRoot, { recursive: true, force: true }) + } +})