diff --git a/docs/kando-plugin-system-spec.md b/docs/kando-plugin-system-spec.md new file mode 100644 index 000000000..3c1830985 --- /dev/null +++ b/docs/kando-plugin-system-spec.md @@ -0,0 +1,317 @@ +# Kando Plugin System +## Open Source Contribution Specification + +--- + +## Overview + +A generic plugin loader for Kando that enables external Electron-based apps to integrate as menu items using a simple folder structure, distributed as ZIP files. + +--- + +## Plugin Distribution Format + +Plugins are distributed as **ZIP files** containing a built web application: + +```text +my-plugin.zip +├── kando-plugin.json # Plugin manifest (required) +├── index.html # Entry point (required) +├── assets/ +│ ├── index-[hash].js # Bundled application +│ └── index-[hash].css # Styles +├── preload.js # Electron preload (optional) +└── icon.png # Plugin icon (optional) +``` + +--- + +## Plugin Manifest Schema + +File: `kando-plugin.json` + +```json +{ + "id": "unique-plugin-id", + "name": "Human Readable Name", + "version": "1.0.0", + "description": "What this plugin does", + "author": "Author Name", + "icon": "icon.png", + "window": { + "width": 460, + "height": 600, + "minWidth": 360, + "minHeight": 450, + "frame": false, + "titleBarStyle": "hidden", + "alwaysOnTop": true + }, + "preload": "preload.js", + "parameters": { + "apiEndpoint": { + "type": "string", + "title": "API Endpoint", + "description": "The base URL for API calls", + "default": "https://api.example.com", + "required": true + }, + "refreshInterval": { + "type": "number", + "title": "Refresh Interval (seconds)", + "description": "How often to refresh data", + "default": 60, + "min": 10, + "max": 3600 + }, + "enableNotifications": { + "type": "boolean", + "title": "Enable Notifications", + "description": "Show desktop notifications", + "default": true + }, + "theme": { + "type": "select", + "title": "Theme", + "description": "Visual theme for the plugin", + "options": ["light", "dark", "system"], + "default": "system" + }, + "credentialsPath": { + "type": "file", + "title": "Credentials File", + "description": "Path to service account JSON", + "fileTypes": [".json"], + "required": true + } + } +} +``` + +--- + +## Parameter Types + +| Type | Description | Additional Properties | +|------|-------------|----------------------| +| `string` | Text input | `minLength`, `maxLength`, `pattern` | +| `number` | Numeric input | `min`, `max`, `step` | +| `boolean` | Toggle switch | - | +| `select` | Dropdown menu | `options` (array of choices) | +| `file` | File path picker | `fileTypes` (extensions filter) | +| `folder` | Folder path picker | - | +| `password` | Masked text input | Stored securely | + +--- + +## Plugin Execution Flow + +```text +USER KANDO PLUGIN + | | | + |-- Select plugin | | + | from menu | | + | | | + | |-- Read manifest & user config | + | | | + | |-- Create BrowserWindow | + | | with window settings | + | | | + | |-- loadFile(index.html) | + | | Pass config via preload -->| + | | | + | | [Kando returns to idle] | + | | | + | | [Plugin runs independently] + | | [Plugin closes itself when done] +``` + +--- + +## New Files for Kando Fork + +### 1. Plugin Manifest Parser +**Location:** `src/common/plugin-manifest.ts` + +TypeScript interfaces and validation: +- IPluginManifest interface +- IPluginWindowConfig interface +- IPluginParameter interface (with type discriminator) +- validateManifest(json): Verify required fields and parameter schemas +- Default values for optional fields + +### 2. Plugin Manager +**Location:** `src/main/plugins/plugin-manager.ts` + +Core plugin management: +- getPluginsDirectory(): Platform-specific path + - Windows: %APPDATA%/kando/plugins/ + - macOS: ~/Library/Application Support/kando/plugins/ + - Linux: ~/.config/kando/plugins/ +- scanPlugins(): List all valid plugin folders +- validatePlugin(path): Check manifest and required files +- getPluginManifest(id): Return parsed manifest +- importPluginFromZip(zipPath): Extract and validate ZIP +- removePlugin(id): Delete plugin folder +- getPluginConfig(id): Load user configuration +- savePluginConfig(id, config): Persist user configuration + +### 3. Plugin Config Storage +**Location:** `src/main/plugins/plugin-config-store.ts` + +Stores user-defined parameter values: +- Config stored in: ~/.config/kando/plugin-configs/{plugin-id}.json +- Separate from manifest (user data vs plugin definition) +- Handles migration when plugin updates change parameters +- Validates config against manifest parameter schema + +### 4. Plugin Item Type +**Location:** `src/common/item-types/plugin-item-type.ts` + +Defines the "plugin" item type: +- Icon: puzzle-piece +- Default name: "Plugin" +- Data schema: { pluginId: string } +- No children allowed + +### 5. Plugin Item Action +**Location:** `src/main/item-actions/plugin-item-action.ts` + +Executes plugins when triggered: +- Get plugin path from pluginId +- Read manifest and user config +- Create BrowserWindow with manifest.window settings +- Inject user config into preload context +- Load index.html via loadFile() +- Return immediately (fire-and-forget) + +### 6. Settings UI - Import Button +**Location:** `src/renderer/editor/toolbar/add-items-tab.tsx` (modify) + +Add "Import Plugin" functionality: +- New button with upload/import icon +- Opens file picker filtered to .zip files +- Validates ZIP contains valid kando-plugin.json +- Extracts to plugins directory +- Shows success notification with plugin name +- Refreshes available items list +- Handles errors (invalid manifest, extraction failure) + +### 7. Plugin Properties Editor +**Location:** `src/renderer/editor/properties/plugin-item-props.tsx` + +Configuration UI for plugin menu items: +- Plugin selector dropdown (from installed plugins) +- Plugin info display (name, version, description, icon) +- Dynamic form generated from manifest.parameters: + - string: Text input field + - number: Number input with min/max + - boolean: Toggle switch + - select: Dropdown menu + - file: File picker with button + - folder: Folder picker with button + - password: Masked input +- Save/reset buttons for configuration +- Validation feedback for required fields + +### 8. ZIP Handler Utility +**Location:** `src/main/plugins/zip-handler.ts` + +ZIP file operations: +- extractZip(zipPath, destFolder): Extract ZIP contents +- validateZipContents(zipPath): Check for manifest before extracting +- Uses built-in Node.js zlib or lightweight library +- Handles nested folder structures in ZIP + +--- + +## Registration Changes + +**item-type-registry.ts** +```typescript +import { PluginItemType } from './plugin-item-type'; +// Add to registry: +this.types.set('plugin', new PluginItemType()); +``` + +**item-action-registry.ts** +```typescript +import { PluginItemAction } from './plugin-item-action'; +// Add to registry: +this.actions.set('plugin', new PluginItemAction(this.pluginManager)); +``` + +--- + +## Implementation Phases + +### Phase 1: Core Infrastructure +1. Fork Kando repository +2. Create plugin-manifest.ts with interfaces +3. Implement PluginManager class +4. Implement zip-handler.ts for ZIP extraction +5. Create PluginItemType class +6. Create PluginItemAction class +7. Register new item type in registries +8. Test with minimal hello-world.zip plugin + +### Phase 2: Settings UI (Import & Configure) +1. Add "Import Plugin" button to add-items-tab.tsx +2. Implement file picker for .zip files +3. Create plugin-item-props.tsx for configuration +4. Build dynamic form renderer for parameters +5. Implement plugin-config-store.ts for persistence +6. Add validation and error handling + +--- + +## Security Considerations + +- Plugins explicitly installed by user (trust model) +- Each plugin runs in isolated BrowserWindow +- nodeIntegration: false by default +- contextIsolation: true by default +- File/folder pickers use Electron's secure dialogs +- Password parameters stored with OS keychain integration + +--- + +## Future Optimizations + +### 1. ASAR Packaging Option +For tamper-resistant distribution: +```bash +npx asar pack plugin-folder/ plugin.asar +``` +- Single file instead of ZIP +- Read-only archive +- Slightly faster loading + +### 2. Plugin Marketplace +- Central repository of verified plugins +- One-click install from within Kando +- Version management and updates +- Rating and review system + +### 3. Plugin Sandboxing +- More granular permissions system +- Limit file system access +- Network access controls +- Resource usage limits + +--- + +## File Summary + +| File | Type | Purpose | +|------|------|---------| +| src/common/plugin-manifest.ts | New | TypeScript interfaces for manifest | +| src/main/plugins/plugin-manager.ts | New | Plugin discovery and management | +| src/main/plugins/plugin-config-store.ts | New | User config persistence | +| src/main/plugins/zip-handler.ts | New | ZIP extraction utility | +| src/common/item-types/plugin-item-type.ts | New | Plugin item type definition | +| src/main/item-actions/plugin-item-action.ts | New | Plugin execution logic | +| src/renderer/editor/properties/plugin-item-props.tsx | New | Config UI with dynamic form | +| src/renderer/editor/toolbar/add-items-tab.tsx | Modify | Add "Import Plugin" button | +| src/common/item-type-registry.ts | Modify | Register plugin type | +| src/main/item-action-registry.ts | Modify | Register plugin action | diff --git a/docs/plugin-item-action.ts b/docs/plugin-item-action.ts new file mode 100644 index 000000000..e69de29bb diff --git a/docs/plugin-item-type.ts b/docs/plugin-item-type.ts new file mode 100644 index 000000000..e69de29bb diff --git a/docs/plugin-manager.ts b/docs/plugin-manager.ts new file mode 100644 index 000000000..e69de29bb diff --git a/docs/plugin-manifest.ts b/docs/plugin-manifest.ts new file mode 100644 index 000000000..e69de29bb diff --git a/docs/zip-handler.ts b/docs/zip-handler.ts new file mode 100644 index 000000000..e69de29bb diff --git a/locales/en/translation.json b/locales/en/translation.json index 30b83e1fc..998026b51 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -368,6 +368,14 @@ "common": { "delayed-option-info": "Useful if the action targets a window that needs to be focused.", "delayed-option": "Wait for fade-out animation" + }, + "plugin": { + "name": "Plugin", + "description": "Execute an external plugin application.", + "placeholder": "Select a plugin...", + "loading": "Loading plugins...", + "no-plugins": "No plugins installed. Import a plugin ZIP file to get started.", + "tip-1": "Plugins are external applications that run in their own window when triggered from the pie menu." } }, "achievements": { diff --git a/package-lock.json b/package-lock.json index 45f2863fb..5198a62fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "2.2.0", "hasInstallScript": true, "license": "MIT", + "dependencies": { + "adm-zip": "^0.5.16" + }, "devDependencies": { "@electron-forge/cli": "^7.9.0", "@electron-forge/maker-deb": "^7.9.0", @@ -20,6 +23,7 @@ "@eslint/compat": "^2.0.0", "@eslint/js": "^9.39.2", "@formkit/auto-animate": "0.8.2", + "@types/adm-zip": "^0.5.7", "@types/chai": "^5.2.2", "@types/chroma-js": "^3.1.1", "@types/howler": "^2.2.12", @@ -2905,6 +2909,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/appdmg": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/@types/appdmg/-/appdmg-0.5.5.tgz", @@ -4008,6 +4022,15 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", diff --git a/package.json b/package.json index d08c76830..df76703f2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@eslint/compat": "^2.0.0", "@eslint/js": "^9.39.2", "@formkit/auto-animate": "0.8.2", + "@types/adm-zip": "^0.5.7", "@types/chai": "^5.2.2", "@types/chroma-js": "^3.1.1", "@types/howler": "^2.2.12", @@ -110,5 +111,8 @@ "zod": "^4.3.5", "zundo": "^2.1.0", "zustand": "^5.0.9" + }, + "dependencies": { + "adm-zip": "^0.5.16" } } diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 000000000..ddcab62e9 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,10 @@ +Paste this hello-world directory in + +%APPDATA%\\Kando\\plugins\\ + + + +In the future the Kando app will open zip files and extract them to %APPDATA%\\Kando\\plugins\\ + + + diff --git a/plugins/hello-world/index.html b/plugins/hello-world/index.html new file mode 100644 index 000000000..5301edd70 --- /dev/null +++ b/plugins/hello-world/index.html @@ -0,0 +1,99 @@ + + +
+ + +Loading...
+ + +
+// SPDX-License-Identifier: MIT
+
+// Note: In the actual Kando fork, you would import i18next:
+// import i18next from 'i18next';
+
+// Note: In the actual Kando fork, import from the existing registry:
+// import { ItemType } from './item-type-registry';
+
+/**
+ * This interface matches Kando's ItemType interface.
+ * In the actual fork, import from item-type-registry.ts instead.
+ */
+export interface ItemType {
+ /** Whether this type of menu item has children. */
+ hasChildren: boolean;
+
+ /** The default name for new menu items of this kind. */
+ defaultName: string;
+
+ /** The default icon for new menu items of this kind. */
+ defaultIcon: string;
+
+ /** The default icon theme for new menu items of this kind. */
+ defaultIconTheme: string;
+
+ /** The default data for new menu items of this kind. */
+ defaultData: unknown;
+
+ /**
+ * This should return a human-readable description of this kind of menu item.
+ * It will be shown in the add-new-item tab of the toolbar.
+ */
+ genericDescription: string;
+}
+
+/**
+ * Data schema for plugin menu items.
+ * This is stored in the menu item's data field.
+ */
+export type PluginItemData = {
+ /** The unique identifier of the plugin to execute */
+ pluginId: string;
+};
+
+/**
+ * This class provides meta information for menu items that execute plugins.
+ * Plugins are external applications that run in their own BrowserWindow when triggered.
+ */
+export class PluginItemType implements ItemType {
+ /**
+ * Plugin items do not have children - they execute an external plugin.
+ */
+ get hasChildren(): boolean {
+ return false;
+ }
+
+ /**
+ * The default name shown when creating a new plugin item.
+ */
+ get defaultName(): string {
+ // In actual implementation, use i18next for localization:
+ // return i18next.t('menu-items.plugin.name');
+ return 'Plugin';
+ }
+
+ /**
+ * The default icon for plugin items.
+ * Uses 'extension' which is a standard Material Design icon for plugins/extensions.
+ */
+ get defaultIcon(): string {
+ return 'extension';
+ }
+
+ /**
+ * The icon theme containing the default icon.
+ * Uses 'material-symbols-rounded' to match Kando's Material icon system.
+ */
+ get defaultIconTheme(): string {
+ return 'material-symbols-rounded';
+ }
+
+ /**
+ * Default data for new plugin items.
+ * The pluginId must be configured by the user.
+ */
+ get defaultData(): PluginItemData {
+ return {
+ pluginId: '',
+ };
+ }
+
+ /**
+ * Description shown in the add-new-item toolbar tab.
+ */
+ get genericDescription(): string {
+ // In actual implementation, use i18next for localization:
+ // return i18next.t('menu-items.plugin.description');
+ return 'Execute an external plugin application.';
+ }
+}
diff --git a/src/common/plugin-manifest.ts b/src/common/plugin-manifest.ts
new file mode 100644
index 000000000..e9eaf125c
--- /dev/null
+++ b/src/common/plugin-manifest.ts
@@ -0,0 +1,332 @@
+//////////////////////////////////////////////////////////////////////////////////////////
+// _ _ ____ _ _ ___ ____ //
+// |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform //
+// | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando //
+// //
+//////////////////////////////////////////////////////////////////////////////////////////
+
+// SPDX-FileCopyrightText: Simon Schneegans
+// SPDX-License-Identifier: MIT
+
+/**
+ * Configuration for the plugin's BrowserWindow. These settings are used when
+ * Kando creates a window to host the plugin.
+ */
+export interface IPluginWindowConfig {
+ /** Width of the window in pixels. Default: 460 */
+ width?: number;
+
+ /** Height of the window in pixels. Default: 600 */
+ height?: number;
+
+ /** Minimum width of the window in pixels. Default: 360 */
+ minWidth?: number;
+
+ /** Minimum height of the window in pixels. Default: 450 */
+ minHeight?: number;
+
+ /** Whether the window has a frame. Default: false */
+ frame?: boolean;
+
+ /** Title bar style. Default: 'hidden' */
+ titleBarStyle?: 'default' | 'hidden' | 'hiddenInset';
+
+ /** Whether the window is always on top. Default: true */
+ alwaysOnTop?: boolean;
+}
+
+/**
+ * Base interface for plugin parameters. All parameter types extend this.
+ */
+export interface IPluginParameterBase {
+ /** The display title for this parameter */
+ title: string;
+
+ /** A description explaining what this parameter does */
+ description?: string;
+
+ /** Whether this parameter is required. Default: false */
+ required?: boolean;
+}
+
+/**
+ * String parameter type - for text input fields.
+ */
+export interface IPluginParameterString extends IPluginParameterBase {
+ type: 'string';
+ default?: string;
+ minLength?: number;
+ maxLength?: number;
+ pattern?: string;
+}
+
+/**
+ * Number parameter type - for numeric input fields.
+ */
+export interface IPluginParameterNumber extends IPluginParameterBase {
+ type: 'number';
+ default?: number;
+ min?: number;
+ max?: number;
+ step?: number;
+}
+
+/**
+ * Boolean parameter type - for toggle switches.
+ */
+export interface IPluginParameterBoolean extends IPluginParameterBase {
+ type: 'boolean';
+ default?: boolean;
+}
+
+/**
+ * Select parameter type - for dropdown menus.
+ */
+export interface IPluginParameterSelect extends IPluginParameterBase {
+ type: 'select';
+ options: string[];
+ default?: string;
+}
+
+/**
+ * File parameter type - for file path pickers.
+ */
+export interface IPluginParameterFile extends IPluginParameterBase {
+ type: 'file';
+ default?: string;
+ fileTypes?: string[];
+}
+
+/**
+ * Folder parameter type - for folder path pickers.
+ */
+export interface IPluginParameterFolder extends IPluginParameterBase {
+ type: 'folder';
+ default?: string;
+}
+
+/**
+ * Password parameter type - for masked text input fields.
+ * Values are stored securely.
+ */
+export interface IPluginParameterPassword extends IPluginParameterBase {
+ type: 'password';
+ default?: string;
+}
+
+/**
+ * Union type for all parameter types.
+ */
+export type IPluginParameter =
+ | IPluginParameterString
+ | IPluginParameterNumber
+ | IPluginParameterBoolean
+ | IPluginParameterSelect
+ | IPluginParameterFile
+ | IPluginParameterFolder
+ | IPluginParameterPassword;
+
+/**
+ * The plugin manifest schema. This is read from the kando-plugin.json file
+ * in each plugin's directory.
+ */
+export interface IPluginManifest {
+ /** Unique identifier for the plugin. Should be kebab-case. */
+ id: string;
+
+ /** Human-readable name of the plugin. */
+ name: string;
+
+ /** Semantic version string (e.g., "1.0.0"). */
+ version: string;
+
+ /** Short description of what the plugin does. */
+ description?: string;
+
+ /** Author name or organization. */
+ author?: string;
+
+ /** Path to the plugin icon file (relative to plugin directory). */
+ icon?: string;
+
+ /** Window configuration for the plugin. */
+ window?: IPluginWindowConfig;
+
+ /** Path to the Electron preload script (relative to plugin directory). */
+ preload?: string;
+
+ /**
+ * Parameters that can be configured by the user.
+ * Keys are parameter IDs, values are parameter definitions.
+ */
+ parameters?: Record;
+}
+
+/**
+ * Default window configuration applied when manifest doesn't specify values.
+ */
+export const DEFAULT_WINDOW_CONFIG: Required = {
+ width: 460,
+ height: 600,
+ minWidth: 360,
+ minHeight: 450,
+ frame: false,
+ titleBarStyle: 'hidden',
+ alwaysOnTop: true,
+};
+
+/**
+ * Result of validating a plugin manifest.
+ */
+export interface IManifestValidationResult {
+ valid: boolean;
+ errors: string[];
+ manifest?: IPluginManifest;
+}
+
+/**
+ * Validates a parsed JSON object as a plugin manifest.
+ *
+ * @param json The parsed JSON object to validate.
+ * @returns A validation result containing the manifest or errors.
+ */
+export function validateManifest(json: unknown): IManifestValidationResult {
+ const errors: string[] = [];
+
+ if (!json || typeof json !== 'object') {
+ return { valid: false, errors: ['Manifest must be a valid JSON object'] };
+ }
+
+ const manifest = json as Record;
+
+ // Required fields
+ if (!manifest.id || typeof manifest.id !== 'string') {
+ errors.push('Missing or invalid "id" field (required, must be a string)');
+ }
+
+ if (!manifest.name || typeof manifest.name !== 'string') {
+ errors.push('Missing or invalid "name" field (required, must be a string)');
+ }
+
+ if (!manifest.version || typeof manifest.version !== 'string') {
+ errors.push('Missing or invalid "version" field (required, must be a string)');
+ }
+
+ // Optional string fields
+ const optionalStrings = ['description', 'author', 'icon', 'preload'];
+ for (const field of optionalStrings) {
+ if (manifest[field] !== undefined && typeof manifest[field] !== 'string') {
+ errors.push(`Invalid "${field}" field (must be a string if provided)`);
+ }
+ }
+
+ // Validate window config if provided
+ if (manifest.window !== undefined) {
+ if (typeof manifest.window !== 'object' || manifest.window === null) {
+ errors.push('Invalid "window" field (must be an object if provided)');
+ } else {
+ const windowErrors = validateWindowConfig(manifest.window as Record);
+ errors.push(...windowErrors);
+ }
+ }
+
+ // Validate parameters if provided
+ if (manifest.parameters !== undefined) {
+ if (typeof manifest.parameters !== 'object' || manifest.parameters === null) {
+ errors.push('Invalid "parameters" field (must be an object if provided)');
+ } else {
+ const params = manifest.parameters as Record;
+ for (const [key, param] of Object.entries(params)) {
+ const paramErrors = validateParameter(key, param);
+ errors.push(...paramErrors);
+ }
+ }
+ }
+
+ if (errors.length > 0) {
+ return { valid: false, errors };
+ }
+
+ return { valid: true, errors: [], manifest: json as IPluginManifest };
+}
+
+/**
+ * Validates window configuration.
+ */
+function validateWindowConfig(config: Record): string[] {
+ const errors: string[] = [];
+ const numericFields = ['width', 'height', 'minWidth', 'minHeight'];
+
+ for (const field of numericFields) {
+ if (config[field] !== undefined && typeof config[field] !== 'number') {
+ errors.push(`Invalid window.${field} (must be a number if provided)`);
+ }
+ }
+
+ if (config.frame !== undefined && typeof config.frame !== 'boolean') {
+ errors.push('Invalid window.frame (must be a boolean if provided)');
+ }
+
+ if (config.alwaysOnTop !== undefined && typeof config.alwaysOnTop !== 'boolean') {
+ errors.push('Invalid window.alwaysOnTop (must be a boolean if provided)');
+ }
+
+ if (config.titleBarStyle !== undefined) {
+ const validStyles = ['default', 'hidden', 'hiddenInset'];
+ if (!validStyles.includes(config.titleBarStyle as string)) {
+ errors.push(`Invalid window.titleBarStyle (must be one of: ${validStyles.join(', ')})`);
+ }
+ }
+
+ return errors;
+}
+
+/**
+ * Validates a parameter definition.
+ */
+function validateParameter(key: string, param: unknown): string[] {
+ const errors: string[] = [];
+
+ if (!param || typeof param !== 'object') {
+ errors.push(`Parameter "${key}": must be an object`);
+ return errors;
+ }
+
+ const p = param as Record;
+
+ if (!p.type || typeof p.type !== 'string') {
+ errors.push(`Parameter "${key}": missing or invalid "type" field`);
+ return errors;
+ }
+
+ if (!p.title || typeof p.title !== 'string') {
+ errors.push(`Parameter "${key}": missing or invalid "title" field`);
+ }
+
+ const validTypes = ['string', 'number', 'boolean', 'select', 'file', 'folder', 'password'];
+ if (!validTypes.includes(p.type as string)) {
+ errors.push(`Parameter "${key}": invalid type "${p.type}" (must be one of: ${validTypes.join(', ')})`);
+ }
+
+ // Type-specific validation
+ if (p.type === 'select') {
+ if (!Array.isArray(p.options) || p.options.length === 0) {
+ errors.push(`Parameter "${key}": select type requires non-empty "options" array`);
+ }
+ }
+
+ return errors;
+}
+
+/**
+ * Applies default values to a manifest's window configuration.
+ *
+ * @param manifest The plugin manifest.
+ * @returns Complete window configuration with defaults applied.
+ */
+export function getWindowConfigWithDefaults(manifest: IPluginManifest): Required {
+ return {
+ ...DEFAULT_WINDOW_CONFIG,
+ ...manifest.window,
+ };
+}
diff --git a/src/main/index.ts b/src/main/index.ts
index 0bb6b67b9..3103bb394 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -81,6 +81,8 @@ import { Notification } from './utils/notification';
import { getBackend } from './backends';
import { KandoApp } from './app';
import { getGeneralSettings, getMenuSettings, getConfigDirectory } from './settings';
+// PLUGIN SYSTEM IMPORT - Add this line
+import { PluginManager, registerPluginIPCHandlers } from './plugins';
// Initialize the notification system. This will queue notifications until the app is
// ready so that we can even show notifications before the app is fully initialized. This
@@ -236,6 +238,20 @@ try {
})
.then(() => backend.init(generalSettings))
.then(() => kando.init())
+ // PLUGIN SYSTEM INITIALIZATION - Add this block
+ .then(() => {
+ // Initialize plugin system: scan for installed plugins and register IPC handlers
+ const pluginManager = PluginManager.getInstance();
+ const plugins = pluginManager.scanPlugins();
+ console.log(
+ `[Kando] Discovered ${plugins.length} plugin(s):`,
+ plugins.map((p) => p.id)
+ );
+
+ // Register IPC handlers so the settings UI can communicate with the plugin system
+ registerPluginIPCHandlers();
+ })
+ // END PLUGIN SYSTEM INITIALIZATION
.then(() => {
// Show a nifty message when the app is about to quit.
app.on('will-quit', async () => {
diff --git a/src/main/item-actions/item-action-registry.ts b/src/main/item-actions/item-action-registry.ts
index e00b60b86..64736e73f 100644
--- a/src/main/item-actions/item-action-registry.ts
+++ b/src/main/item-actions/item-action-registry.ts
@@ -19,6 +19,8 @@ import { TextItemAction } from './text-item-action';
import { URIItemAction } from './uri-item-action';
import { RedirectItemAction } from './redirect-item-action';
import { SettingsItemAction } from './settings-item-action';
+import { PluginItemAction } from './plugin-item-action';
+import { PluginManager } from '../plugins';
/**
* This type describes the action of a menu item. The action is what happens when the menu
@@ -73,6 +75,7 @@ export class ItemActionRegistry {
this.actions.set('uri', new URIItemAction());
this.actions.set('redirect', new RedirectItemAction());
this.actions.set('settings', new SettingsItemAction());
+ this.actions.set('plugin', new PluginItemAction(PluginManager.getInstance()));
}
/**
diff --git a/src/main/item-actions/plugin-item-action.ts b/src/main/item-actions/plugin-item-action.ts
new file mode 100644
index 000000000..9ee50f76b
--- /dev/null
+++ b/src/main/item-actions/plugin-item-action.ts
@@ -0,0 +1,176 @@
+//////////////////////////////////////////////////////////////////////////////////////////
+// _ _ ____ _ _ ___ ____ //
+// |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform //
+// | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando //
+// //
+//////////////////////////////////////////////////////////////////////////////////////////
+
+// SPDX-FileCopyrightText: Simon Schneegans
+// SPDX-License-Identifier: MIT
+
+import * as path from 'path';
+import { BrowserWindow } from 'electron';
+
+import { PluginManager } from '../plugins/plugin-manager';
+import {
+ IPluginManifest,
+ getWindowConfigWithDefaults,
+} from '../../common/plugin-manifest';
+
+// Imports for Kando's types - adjust path as needed for your fork
+// import { IMenuItem } from '../menu/menu-item';
+// import { KandoApp } from '../kando-app';
+
+/**
+ * Data stored in plugin menu items.
+ */
+export interface IPluginItemData {
+ pluginId: string;
+}
+
+/**
+ * Executes plugins when a plugin menu item is triggered.
+ *
+ * This action:
+ * 1. Gets the plugin info from PluginManager
+ * 2. Reads the manifest and user configuration
+ * 3. Creates a new BrowserWindow with the manifest's window settings
+ * 4. Injects the configuration via a preload script
+ * 5. Loads the plugin's index.html
+ * 6. Returns immediately (fire-and-forget)
+ */
+export class PluginItemAction {
+ private pluginManager: PluginManager;
+
+ constructor(pluginManager?: PluginManager) {
+ this.pluginManager = pluginManager || PluginManager.getInstance();
+ }
+
+ /**
+ * Plugins execute immediately (fire-and-forget), so no delayed execution needed.
+ * @param _item The menu item (unused for plugins).
+ * @returns false - plugins don't need delayed execution.
+ */
+ public delayedExecution(_item: unknown): boolean {
+ return false;
+ }
+
+ /**
+ * Executes the plugin action.
+ *
+ * @param item The menu item containing plugin data.
+ * @param _app The Kando application instance (unused).
+ * @returns A promise that resolves when the plugin window is created.
+ */
+ public async execute(item: { data?: unknown }, _app?: unknown): Promise {
+ const data = item.data as IPluginItemData | undefined;
+ const pluginId = data?.pluginId;
+
+ if (!pluginId) {
+ console.error('Plugin action: No plugin ID specified');
+ return;
+ }
+
+ // Get plugin info
+ const plugin = this.pluginManager.getPlugin(pluginId);
+ if (!plugin) {
+ console.error(`Plugin action: Plugin "${pluginId}" not found`);
+ return;
+ }
+
+ // Get user configuration
+ const config = this.pluginManager.getPluginConfig(pluginId);
+
+ // Create and show the plugin window
+ await this.createPluginWindow(plugin.manifest, plugin.path, config);
+ }
+
+ /**
+ * Creates a BrowserWindow for the plugin.
+ *
+ * @param manifest The plugin manifest.
+ * @param pluginPath Absolute path to the plugin directory.
+ * @param config User configuration for the plugin.
+ */
+ private async createPluginWindow(
+ manifest: IPluginManifest,
+ pluginPath: string,
+ config: Record
+ ): Promise {
+ const windowConfig = getWindowConfigWithDefaults(manifest);
+
+ // Determine preload script path
+ let preloadPath: string | undefined;
+ if (manifest.preload) {
+ preloadPath = path.join(pluginPath, manifest.preload);
+ }
+
+ // Create the BrowserWindow
+ const window = new BrowserWindow({
+ width: windowConfig.width,
+ height: windowConfig.height,
+ minWidth: windowConfig.minWidth,
+ minHeight: windowConfig.minHeight,
+ frame: windowConfig.frame,
+ titleBarStyle: windowConfig.titleBarStyle,
+ alwaysOnTop: windowConfig.alwaysOnTop,
+ show: false, // Don't show until ready
+ webPreferences: {
+ nodeIntegration: false,
+ contextIsolation: true,
+ sandbox: true,
+ preload: preloadPath,
+ // Pass config through additional data (accessed in preload)
+ additionalArguments: [
+ `--plugin-config=${encodeURIComponent(JSON.stringify(config))}`,
+ `--plugin-id=${manifest.id}`,
+ ],
+ },
+ });
+
+ // Load the plugin's index.html
+ const indexPath = path.join(pluginPath, 'index.html');
+ await window.loadFile(indexPath);
+
+ // Show the window once it's ready
+ window.once('ready-to-show', () => {
+ window.show();
+ });
+
+ // Handle window close - clean up
+ window.on('closed', () => {
+ // Window is automatically garbage collected
+ console.log(`Plugin window closed: ${manifest.id}`);
+ });
+
+ return window;
+ }
+
+ /**
+ * Gets description text for the action.
+ * Used in the Kando settings UI.
+ *
+ * @param data The plugin item data.
+ * @returns A human-readable description.
+ */
+ public getDescription(data: IPluginItemData): string {
+ if (!data.pluginId) {
+ return 'No plugin selected';
+ }
+
+ const manifest = this.pluginManager.getPluginManifest(data.pluginId);
+ if (!manifest) {
+ return `Plugin not found: ${data.pluginId}`;
+ }
+
+ return manifest.description || manifest.name;
+ }
+}
+
+/**
+ * Factory function to create a PluginItemAction.
+ * Used when registering with Kando's action registry.
+ */
+export function createPluginItemAction(): PluginItemAction {
+ return new PluginItemAction();
+}
diff --git a/src/main/plugins/index.ts b/src/main/plugins/index.ts
new file mode 100644
index 000000000..59498b3f3
--- /dev/null
+++ b/src/main/plugins/index.ts
@@ -0,0 +1,27 @@
+//////////////////////////////////////////////////////////////////////////////////////////
+// _ _ ____ _ _ ___ ____ //
+// |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform //
+// | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando //
+// //
+//////////////////////////////////////////////////////////////////////////////////////////
+
+// SPDX-FileCopyrightText: Simon Schneegans
+// SPDX-License-Identifier: MIT
+
+/**
+ * Plugin system exports for the main process.
+ */
+
+export { PluginManager } from './plugin-manager';
+export type { IPluginInfo, IPluginImportResult } from './plugin-manager';
+export { PluginConfigStore } from './plugin-config-store';
+export {
+ extractZip,
+ validateZipContents,
+ listZipContents,
+ readFileFromZip,
+} from './zip-handler';
+export type { IZipValidationResult } from './zip-handler';
+
+export { registerPluginIPCHandlers } from './ipc-handlers';
+export type { IPluginListItem } from './ipc-handlers';
diff --git a/src/main/plugins/ipc-handlers.ts b/src/main/plugins/ipc-handlers.ts
new file mode 100644
index 000000000..e7f2e33f9
--- /dev/null
+++ b/src/main/plugins/ipc-handlers.ts
@@ -0,0 +1,117 @@
+//////////////////////////////////////////////////////////////////////////////////////////
+// _ _ ____ _ _ ___ ____ //
+// |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform //
+// | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando //
+// //
+//////////////////////////////////////////////////////////////////////////////////////////
+
+// SPDX-FileCopyrightText: Simon Schneegans
+// SPDX-License-Identifier: MIT
+
+import { ipcMain, dialog, BrowserWindow } from 'electron';
+import { PluginManager } from './plugin-manager';
+
+/**
+ * Simplified plugin info returned to the renderer process.
+ */
+export interface IPluginListItem {
+ id: string;
+ name: string;
+ description?: string;
+ version: string;
+ author?: string;
+}
+
+/**
+ * Registers all IPC handlers for the plugin system.
+ * Call this function during app initialization after the PluginManager is ready.
+ */
+export function registerPluginIPCHandlers(): void {
+ const pluginManager = PluginManager.getInstance();
+
+ /**
+ * Handler: Get list of all installed plugins.
+ * Used by the settings UI to populate the plugin dropdown.
+ */
+ ipcMain.handle('plugins:get-list', async (): Promise => {
+ const plugins = pluginManager.getAllPlugins();
+ return plugins.map((plugin) => ({
+ id: plugin.id,
+ name: plugin.manifest.name,
+ description: plugin.manifest.description,
+ version: plugin.manifest.version,
+ author: plugin.manifest.author,
+ }));
+ });
+
+ /**
+ * Handler: Get a specific plugin's manifest by ID.
+ */
+ ipcMain.handle('plugins:get-manifest', async (_event, pluginId: string) => {
+ return pluginManager.getPluginManifest(pluginId);
+ });
+
+ /**
+ * Handler: Import a plugin from a ZIP file.
+ * Opens a file dialog and imports the selected plugin.
+ */
+ ipcMain.handle('plugins:import', async (event) => {
+ // Get the browser window from which the request originated
+ const webContents = event.sender;
+ const browserWindow = BrowserWindow.fromWebContents(webContents);
+
+ const result = await dialog.showOpenDialog(browserWindow!, {
+ title: 'Import Plugin',
+ filters: [{ name: 'Plugin Archive', extensions: ['zip'] }],
+ properties: ['openFile'],
+ });
+
+ if (result.canceled || result.filePaths.length === 0) {
+ return { success: false, error: 'Import cancelled' };
+ }
+
+ const zipPath = result.filePaths[0];
+ return await pluginManager.importPluginFromZip(zipPath);
+ });
+
+ /**
+ * Handler: Remove an installed plugin.
+ */
+ ipcMain.handle('plugins:remove', async (_event, pluginId: string) => {
+ const success = pluginManager.removePlugin(pluginId);
+ return { success, pluginId };
+ });
+
+ /**
+ * Handler: Get plugin configuration (user settings).
+ */
+ ipcMain.handle('plugins:get-config', async (_event, pluginId: string) => {
+ return pluginManager.getPluginConfig(pluginId);
+ });
+
+ /**
+ * Handler: Save plugin configuration.
+ */
+ ipcMain.handle(
+ 'plugins:save-config',
+ async (_event, pluginId: string, config: Record) => {
+ pluginManager.savePluginConfig(pluginId, config);
+ return { success: true };
+ }
+ );
+
+ /**
+ * Handler: Rescan plugins directory.
+ * Useful after manually adding plugins.
+ */
+ ipcMain.handle('plugins:rescan', async () => {
+ const plugins = pluginManager.scanPlugins();
+ return plugins.map((plugin) => ({
+ id: plugin.id,
+ name: plugin.manifest.name,
+ version: plugin.manifest.version,
+ }));
+ });
+
+ console.log('[Kando] Plugin IPC handlers registered');
+}
diff --git a/src/main/plugins/plugin-config-store.ts b/src/main/plugins/plugin-config-store.ts
new file mode 100644
index 000000000..231820df2
--- /dev/null
+++ b/src/main/plugins/plugin-config-store.ts
@@ -0,0 +1,283 @@
+//////////////////////////////////////////////////////////////////////////////////////////
+// _ _ ____ _ _ ___ ____ //
+// |_/ |__| |\\ | | \\ | | This file belongs to Kando, the cross-platform //
+// | \\_ | | | \\| |__/ |__| pie menu. Read more on github.com/kando-menu/kando //
+// //
+//////////////////////////////////////////////////////////////////////////////////////////
+
+// SPDX-FileCopyrightText: Simon Schneegans
+// SPDX-License-Identifier: MIT
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+
+import { IPluginManifest, IPluginParameter } from '../../common/plugin-manifest';
+
+/**
+ * Stores user-defined plugin configuration values.
+ *
+ * Each plugin's configuration is stored in a separate JSON file at:
+ * - Windows: %APPDATA%/kando/plugin-configs/{plugin-id}.json
+ * - macOS: ~/Library/Application Support/kando/plugin-configs/{plugin-id}.json
+ * - Linux: ~/.config/kando/plugin-configs/{plugin-id}.json
+ *
+ * Configuration is separate from the manifest (user data vs plugin definition).
+ */
+export class PluginConfigStore {
+ /** In-memory cache of configs */
+ private configs: Map> = new Map();
+
+ /**
+ * Returns the platform-specific plugin configs directory.
+ */
+ public getConfigDirectory(): string {
+ let baseDir: string;
+
+ switch (process.platform) {
+ case 'win32':
+ baseDir = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
+ break;
+ case 'darwin':
+ baseDir = path.join(os.homedir(), 'Library', 'Application Support');
+ break;
+ default: // Linux and others
+ baseDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
+ break;
+ }
+
+ return path.join(baseDir, 'kando', 'plugin-configs');
+ }
+
+ /**
+ * Ensures the config directory exists.
+ */
+ private ensureConfigDirectory(): void {
+ const dir = this.getConfigDirectory();
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ }
+
+ /**
+ * Gets the config file path for a plugin.
+ */
+ private getConfigPath(pluginId: string): string {
+ return path.join(this.getConfigDirectory(), `${pluginId}.json`);
+ }
+
+ /**
+ * Gets the configuration for a plugin.
+ * Returns cached config or loads from disk.
+ *
+ * @param pluginId The unique plugin identifier.
+ * @returns The configuration object (may be empty).
+ */
+ public getConfig(pluginId: string): Record {
+ // Check cache first
+ if (this.configs.has(pluginId)) {
+ return this.configs.get(pluginId)!;
+ }
+
+ // Try to load from disk
+ const configPath = this.getConfigPath(pluginId);
+
+ try {
+ if (fs.existsSync(configPath)) {
+ const content = fs.readFileSync(configPath, 'utf-8');
+ const config = JSON.parse(content);
+ this.configs.set(pluginId, config);
+ return config;
+ }
+ } catch (error) {
+ console.error(`Failed to load config for plugin ${pluginId}:`, error);
+ }
+
+ // Return empty config
+ const emptyConfig: Record = {};
+ this.configs.set(pluginId, emptyConfig);
+ return emptyConfig;
+ }
+
+ /**
+ * Saves configuration for a plugin.
+ *
+ * @param pluginId The unique plugin identifier.
+ * @param config The configuration to save.
+ */
+ public saveConfig(pluginId: string, config: Record): void {
+ this.ensureConfigDirectory();
+
+ // Update cache
+ this.configs.set(pluginId, config);
+
+ // Write to disk
+ const configPath = this.getConfigPath(pluginId);
+ try {
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
+ } catch (error) {
+ console.error(`Failed to save config for plugin ${pluginId}:`, error);
+ }
+ }
+
+ /**
+ * Deletes configuration for a plugin.
+ *
+ * @param pluginId The unique plugin identifier.
+ */
+ public deleteConfig(pluginId: string): void {
+ // Remove from cache
+ this.configs.delete(pluginId);
+
+ // Delete from disk
+ const configPath = this.getConfigPath(pluginId);
+ try {
+ if (fs.existsSync(configPath)) {
+ fs.unlinkSync(configPath);
+ }
+ } catch (error) {
+ console.error(`Failed to delete config for plugin ${pluginId}:`, error);
+ }
+ }
+
+ /**
+ * Gets the configuration for a plugin with default values applied
+ * from the manifest's parameter definitions.
+ *
+ * @param pluginId The unique plugin identifier.
+ * @param manifest The plugin manifest containing parameter definitions.
+ * @returns Config with defaults applied for any missing values.
+ */
+ public getConfigWithDefaults(
+ pluginId: string,
+ manifest: IPluginManifest
+ ): Record {
+ const config = this.getConfig(pluginId);
+ const result: Record = { ...config };
+
+ if (manifest.parameters) {
+ for (const [key, param] of Object.entries(manifest.parameters)) {
+ if (result[key] === undefined && 'default' in param) {
+ result[key] = (param as IPluginParameter & { default?: unknown }).default;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Validates configuration against a manifest's parameter schema.
+ * Returns an array of validation error messages.
+ *
+ * @param config The configuration to validate.
+ * @param manifest The plugin manifest with parameter definitions.
+ * @returns Array of error messages (empty if valid).
+ */
+ public validateConfig(
+ config: Record,
+ manifest: IPluginManifest
+ ): string[] {
+ const errors: string[] = [];
+
+ if (!manifest.parameters) {
+ return errors;
+ }
+
+ for (const [key, param] of Object.entries(manifest.parameters)) {
+ const value = config[key];
+
+ // Check required fields
+ if (param.required && (value === undefined || value === null || value === '')) {
+ errors.push(`Parameter "${param.title}" is required`);
+ continue;
+ }
+
+ // Skip validation for undefined optional fields
+ if (value === undefined) continue;
+
+ // Type-specific validation
+ switch (param.type) {
+ case 'string':
+ case 'password':
+ if (typeof value !== 'string') {
+ errors.push(`Parameter "${param.title}" must be a string`);
+ } else {
+ if (param.type === 'string') {
+ if (param.minLength !== undefined && value.length < param.minLength) {
+ errors.push(`Parameter "${param.title}" must be at least ${param.minLength} characters`);
+ }
+ if (param.maxLength !== undefined && value.length > param.maxLength) {
+ errors.push(`Parameter "${param.title}" must be at most ${param.maxLength} characters`);
+ }
+ if (param.pattern !== undefined && !new RegExp(param.pattern).test(value)) {
+ errors.push(`Parameter "${param.title}" does not match required pattern`);
+ }
+ }
+ }
+ break;
+
+ case 'number':
+ if (typeof value !== 'number') {
+ errors.push(`Parameter "${param.title}" must be a number`);
+ } else {
+ if (param.min !== undefined && value < param.min) {
+ errors.push(`Parameter "${param.title}" must be at least ${param.min}`);
+ }
+ if (param.max !== undefined && value > param.max) {
+ errors.push(`Parameter "${param.title}" must be at most ${param.max}`);
+ }
+ }
+ break;
+
+ case 'boolean':
+ if (typeof value !== 'boolean') {
+ errors.push(`Parameter "${param.title}" must be a boolean`);
+ }
+ break;
+
+ case 'select':
+ if (typeof value !== 'string' || !param.options.includes(value)) {
+ errors.push(`Parameter "${param.title}" must be one of: ${param.options.join(', ')}`);
+ }
+ break;
+
+ case 'file':
+ case 'folder':
+ if (typeof value !== 'string') {
+ errors.push(`Parameter "${param.title}" must be a valid path`);
+ }
+ break;
+ }
+ }
+
+ return errors;
+ }
+
+ /**
+ * Migrates configuration when a plugin is updated and parameters change.
+ * Removes values for parameters that no longer exist and adds defaults
+ * for new parameters.
+ *
+ * @param pluginId The unique plugin identifier.
+ * @param manifest The updated plugin manifest.
+ */
+ public migrateConfig(pluginId: string, manifest: IPluginManifest): void {
+ const config = this.getConfig(pluginId);
+ const newConfig: Record = {};
+
+ if (manifest.parameters) {
+ for (const [key, param] of Object.entries(manifest.parameters)) {
+ if (config[key] !== undefined) {
+ // Keep existing value
+ newConfig[key] = config[key];
+ } else if ('default' in param) {
+ // Use default for new parameter
+ newConfig[key] = (param as IPluginParameter & { default?: unknown }).default;
+ }
+ }
+ }
+
+ this.saveConfig(pluginId, newConfig);
+ }
+}
diff --git a/src/main/plugins/plugin-manager.ts b/src/main/plugins/plugin-manager.ts
new file mode 100644
index 000000000..d0659ab2f
--- /dev/null
+++ b/src/main/plugins/plugin-manager.ts
@@ -0,0 +1,349 @@
+//////////////////////////////////////////////////////////////////////////////////////////
+// _ _ ____ _ _ ___ ____ //
+// |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform //
+// | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando //
+// //
+//////////////////////////////////////////////////////////////////////////////////////////
+
+// SPDX-FileCopyrightText: Simon Schneegans
+// SPDX-License-Identifier: MIT
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { app } from 'electron';
+
+import {
+ IPluginManifest,
+ validateManifest,
+ IManifestValidationResult,
+} from '../../common/plugin-manifest';
+import { extractZip, validateZipContents } from './zip-handler';
+import { PluginConfigStore } from './plugin-config-store';
+
+/**
+ * Information about an installed plugin, including its path and manifest.
+ */
+export interface IPluginInfo {
+ /** Unique plugin ID from manifest */
+ id: string;
+
+ /** Absolute path to the plugin directory */
+ path: string;
+
+ /** Parsed and validated manifest */
+ manifest: IPluginManifest;
+}
+
+/**
+ * Result of a plugin import operation.
+ */
+export interface IPluginImportResult {
+ success: boolean;
+ pluginId?: string;
+ error?: string;
+}
+
+/**
+ * The PluginManager handles discovering, loading, and managing plugins.
+ * Plugins are stored in a platform-specific directory and can be imported
+ * from ZIP files.
+ */
+export class PluginManager {
+ /** Singleton instance */
+ private static instance: PluginManager | null = null;
+
+ /** Cache of loaded plugins */
+ private plugins: Map = new Map();
+
+ /** Config store for user settings */
+ private configStore: PluginConfigStore;
+
+ /**
+ * Private constructor for singleton pattern.
+ */
+ private constructor() {
+ this.configStore = new PluginConfigStore();
+ }
+
+ /**
+ * Get the singleton instance of PluginManager.
+ */
+ public static getInstance(): PluginManager {
+ if (PluginManager.instance === null) {
+ PluginManager.instance = new PluginManager();
+ }
+ return PluginManager.instance;
+ }
+
+ /**
+ * Returns the platform-specific plugins directory.
+ *
+ * - Windows: %APPDATA%/kando/plugins/
+ * - macOS: ~/Library/Application Support/kando/plugins/
+ * - Linux: ~/.config/kando/plugins/
+ */
+ public getPluginsDirectory(): string {
+ let baseDir: string;
+
+ switch (process.platform) {
+ case 'win32':
+ baseDir = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
+ break;
+ case 'darwin':
+ baseDir = path.join(os.homedir(), 'Library', 'Application Support');
+ break;
+ default: // Linux and others
+ baseDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
+ break;
+ }
+
+ return path.join(baseDir, 'kando', 'plugins');
+ }
+
+ /**
+ * Ensures the plugins directory exists, creating it if necessary.
+ */
+ public ensurePluginsDirectory(): void {
+ const dir = this.getPluginsDirectory();
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ }
+
+ /**
+ * Scans the plugins directory and returns a list of valid plugins.
+ * Invalid plugins are logged but not included in the result.
+ */
+ public scanPlugins(): IPluginInfo[] {
+ this.ensurePluginsDirectory();
+ const pluginsDir = this.getPluginsDirectory();
+ const plugins: IPluginInfo[] = [];
+
+ try {
+ const entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+
+ const pluginPath = path.join(pluginsDir, entry.name);
+ const validation = this.validatePlugin(pluginPath);
+
+ if (validation.valid && validation.manifest) {
+ const pluginInfo: IPluginInfo = {
+ id: validation.manifest.id,
+ path: pluginPath,
+ manifest: validation.manifest,
+ };
+
+ plugins.push(pluginInfo);
+ this.plugins.set(validation.manifest.id, pluginInfo);
+ } else {
+ console.warn(
+ `Invalid plugin at ${pluginPath}:`,
+ validation.errors.join(', ')
+ );
+ }
+ }
+ } catch (error) {
+ console.error('Error scanning plugins directory:', error);
+ }
+
+ return plugins;
+ }
+
+ /**
+ * Validates a plugin directory by checking for required files and
+ * parsing the manifest.
+ *
+ * @param pluginPath Absolute path to the plugin directory.
+ * @returns Validation result with manifest or errors.
+ */
+ public validatePlugin(pluginPath: string): IManifestValidationResult {
+ const manifestPath = path.join(pluginPath, 'kando-plugin.json');
+ const indexPath = path.join(pluginPath, 'index.html');
+
+ // Check required files exist
+ if (!fs.existsSync(manifestPath)) {
+ return {
+ valid: false,
+ errors: ['Missing kando-plugin.json manifest file'],
+ };
+ }
+
+ if (!fs.existsSync(indexPath)) {
+ return {
+ valid: false,
+ errors: ['Missing index.html entry point'],
+ };
+ }
+
+ // Parse and validate manifest
+ try {
+ const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
+ const json = JSON.parse(manifestContent);
+ return validateManifest(json);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return {
+ valid: false,
+ errors: [`Failed to parse manifest: ${message}`],
+ };
+ }
+ }
+
+ /**
+ * Gets the manifest for a specific plugin by ID.
+ *
+ * @param pluginId The unique plugin identifier.
+ * @returns The plugin manifest or undefined if not found.
+ */
+ public getPluginManifest(pluginId: string): IPluginManifest | undefined {
+ const plugin = this.plugins.get(pluginId);
+ return plugin?.manifest;
+ }
+
+ /**
+ * Gets full plugin info by ID.
+ *
+ * @param pluginId The unique plugin identifier.
+ * @returns The plugin info or undefined if not found.
+ */
+ public getPlugin(pluginId: string): IPluginInfo | undefined {
+ return this.plugins.get(pluginId);
+ }
+
+ /**
+ * Gets all loaded plugins.
+ *
+ * @returns Array of all plugin infos.
+ */
+ public getAllPlugins(): IPluginInfo[] {
+ return Array.from(this.plugins.values());
+ }
+
+ /**
+ * Imports a plugin from a ZIP file. The ZIP is validated, extracted
+ * to the plugins directory, and the manifest is parsed.
+ *
+ * @param zipPath Absolute path to the ZIP file.
+ * @returns Import result with success status or error message.
+ */
+ public async importPluginFromZip(zipPath: string): Promise {
+ // Validate ZIP contents before extraction
+ const zipValidation = await validateZipContents(zipPath);
+ if (!zipValidation.valid) {
+ return {
+ success: false,
+ error: zipValidation.error || 'Invalid plugin ZIP file',
+ };
+ }
+
+ // Extract to a temporary directory first
+ const pluginsDir = this.getPluginsDirectory();
+ this.ensurePluginsDirectory();
+
+ // Use the plugin ID from the manifest for the folder name
+ const tempDir = path.join(pluginsDir, `_temp_${Date.now()}`);
+
+ try {
+ await extractZip(zipPath, tempDir);
+
+ // Validate the extracted plugin
+ const validation = this.validatePlugin(tempDir);
+ if (!validation.valid || !validation.manifest) {
+ // Clean up temp directory
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ return {
+ success: false,
+ error: validation.errors.join(', '),
+ };
+ }
+
+ // Move to final location using plugin ID
+ const finalDir = path.join(pluginsDir, validation.manifest.id);
+
+ // Remove existing plugin with same ID if present
+ if (fs.existsSync(finalDir)) {
+ fs.rmSync(finalDir, { recursive: true, force: true });
+ }
+
+ fs.renameSync(tempDir, finalDir);
+
+ // Add to cache
+ const pluginInfo: IPluginInfo = {
+ id: validation.manifest.id,
+ path: finalDir,
+ manifest: validation.manifest,
+ };
+ this.plugins.set(validation.manifest.id, pluginInfo);
+
+ return {
+ success: true,
+ pluginId: validation.manifest.id,
+ };
+ } catch (error) {
+ // Clean up on error
+ if (fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+
+ const message = error instanceof Error ? error.message : String(error);
+ return {
+ success: false,
+ error: `Failed to import plugin: ${message}`,
+ };
+ }
+ }
+
+ /**
+ * Removes an installed plugin.
+ *
+ * @param pluginId The unique plugin identifier.
+ * @returns True if the plugin was removed, false if not found.
+ */
+ public removePlugin(pluginId: string): boolean {
+ const plugin = this.plugins.get(pluginId);
+ if (!plugin) {
+ return false;
+ }
+
+ try {
+ // Remove plugin directory
+ if (fs.existsSync(plugin.path)) {
+ fs.rmSync(plugin.path, { recursive: true, force: true });
+ }
+
+ // Remove from cache
+ this.plugins.delete(pluginId);
+
+ // Remove config
+ this.configStore.deleteConfig(pluginId);
+
+ return true;
+ } catch (error) {
+ console.error(`Failed to remove plugin ${pluginId}:`, error);
+ return false;
+ }
+ }
+
+ /**
+ * Gets the user configuration for a plugin.
+ *
+ * @param pluginId The unique plugin identifier.
+ * @returns The user's config values or an empty object.
+ */
+ public getPluginConfig(pluginId: string): Record {
+ return this.configStore.getConfig(pluginId);
+ }
+
+ /**
+ * Saves user configuration for a plugin.
+ *
+ * @param pluginId The unique plugin identifier.
+ * @param config The configuration values to save.
+ */
+ public savePluginConfig(pluginId: string, config: Record): void {
+ this.configStore.saveConfig(pluginId, config);
+ }
+}
diff --git a/src/main/plugins/zip-handler.ts b/src/main/plugins/zip-handler.ts
new file mode 100644
index 000000000..222b18174
--- /dev/null
+++ b/src/main/plugins/zip-handler.ts
@@ -0,0 +1,198 @@
+//////////////////////////////////////////////////////////////////////////////////////////
+// _ _ ____ _ _ ___ ____ //
+// |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform //
+// | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando //
+// //
+//////////////////////////////////////////////////////////////////////////////////////////
+
+// SPDX-FileCopyrightText: Simon Schneegans
+// SPDX-License-Identifier: MIT
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as zlib from 'zlib';
+
+// Note: In a real implementation, you would use a library like 'adm-zip' or 'yauzl'
+// for proper ZIP handling. This is a simplified implementation for reference.
+// For the actual Kando fork, we recommend using 'adm-zip' as it's lightweight
+// and has no native dependencies.
+
+/**
+ * Result of validating a ZIP file's contents.
+ */
+export interface IZipValidationResult {
+ valid: boolean;
+ error?: string;
+ manifestFound?: boolean;
+ indexFound?: boolean;
+}
+
+/**
+ * Validates that a ZIP file contains the required plugin files
+ * without fully extracting it.
+ *
+ * @param zipPath Absolute path to the ZIP file.
+ * @returns Validation result.
+ */
+export async function validateZipContents(zipPath: string): Promise {
+ // Check file exists
+ if (!fs.existsSync(zipPath)) {
+ return {
+ valid: false,
+ error: 'ZIP file not found',
+ };
+ }
+
+ // Check it's a file, not a directory
+ const stats = fs.statSync(zipPath);
+ if (!stats.isFile()) {
+ return {
+ valid: false,
+ error: 'Path is not a file',
+ };
+ }
+
+ // Check file extension
+ if (!zipPath.toLowerCase().endsWith('.zip')) {
+ return {
+ valid: false,
+ error: 'File is not a ZIP archive',
+ };
+ }
+
+ // In a real implementation, we would use a ZIP library to inspect contents.
+ // For this reference implementation, we'll do a basic check using the
+ // ZIP file's local file headers.
+
+ try {
+ const buffer = fs.readFileSync(zipPath);
+
+ // Check ZIP signature (PK\x03\x04)
+ if (buffer.length < 4 || buffer[0] !== 0x50 || buffer[1] !== 0x4B) {
+ return {
+ valid: false,
+ error: 'Invalid ZIP file format',
+ };
+ }
+
+ // Search for required files in the ZIP
+ // This is a simplified check - real implementation would use proper ZIP parsing
+ const content = buffer.toString('binary');
+ const hasManifest = content.includes('kando-plugin.json');
+ const hasIndex = content.includes('index.html');
+
+ if (!hasManifest) {
+ return {
+ valid: false,
+ error: 'Missing kando-plugin.json manifest',
+ manifestFound: false,
+ indexFound: hasIndex,
+ };
+ }
+
+ if (!hasIndex) {
+ return {
+ valid: false,
+ error: 'Missing index.html entry point',
+ manifestFound: true,
+ indexFound: false,
+ };
+ }
+
+ return {
+ valid: true,
+ manifestFound: true,
+ indexFound: true,
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return {
+ valid: false,
+ error: `Failed to read ZIP file: ${message}`,
+ };
+ }
+}
+
+/**
+ * Extracts a ZIP file to a destination folder.
+ *
+ * NOTE: This is a placeholder implementation. In the actual Kando fork,
+ * use a proper ZIP library like 'adm-zip':
+ *
+ * ```typescript
+ * import AdmZip from 'adm-zip';
+ *
+ * export async function extractZip(zipPath: string, destFolder: string): Promise {
+ * const zip = new AdmZip(zipPath);
+ * zip.extractAllTo(destFolder, true);
+ * }
+ * ```
+ *
+ * @param zipPath Absolute path to the ZIP file.
+ * @param destFolder Absolute path to the destination folder.
+ */
+export async function extractZip(zipPath: string, destFolder: string): Promise {
+ // Ensure destination exists
+ if (!fs.existsSync(destFolder)) {
+ fs.mkdirSync(destFolder, { recursive: true });
+ }
+
+ // This is a placeholder - actual implementation would use a ZIP library.
+ // For now, we'll throw an error indicating that a real library is needed.
+
+ // In the actual Kando fork, install adm-zip:
+ // npm install adm-zip
+ // npm install -D @types/adm-zip
+ //
+ // Then implement as:
+ // import AdmZip from 'adm-zip';
+ // const zip = new AdmZip(zipPath);
+ // zip.extractAllTo(destFolder, true);
+
+ throw new Error(
+ 'ZIP extraction requires a proper library. ' +
+ 'Please install adm-zip and update this implementation. ' +
+ 'See the comments in zip-handler.ts for details.'
+ );
+}
+
+/**
+ * Lists files in a ZIP archive.
+ *
+ * @param zipPath Absolute path to the ZIP file.
+ * @returns Array of file paths within the ZIP.
+ */
+export async function listZipContents(zipPath: string): Promise {
+ // Placeholder - would use a ZIP library to list actual contents
+ // In real implementation:
+ // import AdmZip from 'adm-zip';
+ // const zip = new AdmZip(zipPath);
+ // return zip.getEntries().map(entry => entry.entryName);
+
+ throw new Error(
+ 'ZIP listing requires a proper library. ' +
+ 'Please install adm-zip and update this implementation.'
+ );
+}
+
+/**
+ * Reads a specific file from a ZIP archive without extracting everything.
+ *
+ * @param zipPath Absolute path to the ZIP file.
+ * @param filePath Path to the file within the ZIP.
+ * @returns The file contents as a string.
+ */
+export async function readFileFromZip(zipPath: string, filePath: string): Promise {
+ // Placeholder - would use a ZIP library to read the file
+ // In real implementation:
+ // import AdmZip from 'adm-zip';
+ // const zip = new AdmZip(zipPath);
+ // const entry = zip.getEntry(filePath);
+ // if (!entry) throw new Error(`File not found in ZIP: ${filePath}`);
+ // return entry.getData().toString('utf-8');
+
+ throw new Error(
+ 'ZIP reading requires a proper library. ' +
+ 'Please install adm-zip and update this implementation.'
+ );
+}
diff --git a/src/settings-renderer/components/menu-preview/PreviewFooter.module.scss b/src/settings-renderer/components/menu-preview/PreviewFooter.module.scss
index 38062ce6c..f9885fb91 100644
--- a/src/settings-renderer/components/menu-preview/PreviewFooter.module.scss
+++ b/src/settings-renderer/components/menu-preview/PreviewFooter.module.scss
@@ -60,12 +60,10 @@
}
.newItems {
- word-wrap: break-word;
- text-align: center;
+ display: flex; /* Change from inline-block children */
+ flex-wrap: wrap; /* Allow wrapping */
+ justify-content: center; /* Center the items */
+ gap: 8px; /* Add consistent spacing */
padding: 10px;
-
- & > * {
- display: inline-block;
- }
}
}
diff --git a/src/settings-renderer/components/menu-properties/item-configs/index.tsx b/src/settings-renderer/components/menu-properties/item-configs/index.tsx
index 01cfb74a9..081a42a84 100644
--- a/src/settings-renderer/components/menu-properties/item-configs/index.tsx
+++ b/src/settings-renderer/components/menu-properties/item-configs/index.tsx
@@ -19,6 +19,7 @@ import TextItemConfig from './text-item-config';
import URIItemConfig from './uri-item-config';
import RedirectItemConfig from './redirect-item-config';
import SettingsItemConfig from './settings-item-config';
+import PluginItemConfig from './plugin-item-config';
/**
* This method returns a config component for the given menu item type.
@@ -37,6 +38,7 @@ export function getConfigComponent(type: string): React.ReactElement {
uri: ,
redirect: ,
settings: ,
+ plugin: ,
};
return components[type] || null;
diff --git a/src/settings-renderer/components/menu-properties/item-configs/plugin-item-config.tsx b/src/settings-renderer/components/menu-properties/item-configs/plugin-item-config.tsx
new file mode 100644
index 000000000..d383c5765
--- /dev/null
+++ b/src/settings-renderer/components/menu-properties/item-configs/plugin-item-config.tsx
@@ -0,0 +1,178 @@
+//////////////////////////////////////////////////////////////////////////////////////////
+// _ _ ____ _ _ ___ ____ //
+// |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform //
+// | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando //
+// //
+//////////////////////////////////////////////////////////////////////////////////////////
+
+// SPDX-FileCopyrightText: Simon Schneegans
+// SPDX-License-Identifier: MIT
+
+import React, { useEffect, useState } from 'react';
+import i18next from 'i18next';
+
+import { useAppState, useMenuSettings, getSelectedChild } from '../../../state';
+import { Dropdown } from '../../common';
+import { PluginItemData } from '../../../../common/item-types/plugin-item-type';
+
+// Import the window API type for accessing plugin list
+import { WindowWithAPIs } from '../../../settings-window-api';
+declare const window: WindowWithAPIs;
+
+/**
+ * Simple tip display component for plugin configuration.
+ * This replaces RandomTip to avoid crashes when tip translations are missing.
+ */
+function PluginTip({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Represents a plugin option for the dropdown selector.
+ */
+interface IPluginOption {
+ value: string;
+ label: string;
+}
+
+/**
+ * The configuration component for plugin items displays a dropdown to select
+ * which installed plugin should be executed when this menu item is activated.
+ */
+export default function PluginItemConfig() {
+ // Get state from Kando's state management
+ const menus = useMenuSettings((state) => state.menus);
+ const selectedMenu = useAppState((state) => state.selectedMenu);
+ const selectedChildPath = useAppState((state) => state.selectedChildPath);
+ const editMenuItem = useMenuSettings((state) => state.editMenuItem);
+ const { selectedItem } = getSelectedChild(menus, selectedMenu, selectedChildPath);
+
+ // Local state for plugin options - ALL HOOKS MUST BE CALLED BEFORE ANY RETURNS
+ const [pluginOptions, setPluginOptions] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Fetch available plugins from the main process on mount
+ useEffect(() => {
+ // Guard: Don't fetch if no valid plugin item is selected
+ if (!selectedItem || selectedItem.type !== 'plugin') {
+ setIsLoading(false);
+ return;
+ }
+
+ const loadPlugins = async () => {
+ try {
+ // First check if API exists
+ if (!window.settingsAPI) {
+ console.error('[PluginItemConfig] settingsAPI not available on window');
+ setError('Settings API not available');
+ setIsLoading(false);
+ return;
+ }
+
+ if (!window.settingsAPI.getPlugins) {
+ console.error('[PluginItemConfig] getPlugins method not available');
+ setError('Plugin API not available');
+ setIsLoading(false);
+ return;
+ }
+
+ console.log('[PluginItemConfig] Calling getPlugins...');
+ const plugins = await window.settingsAPI.getPlugins();
+ console.log('[PluginItemConfig] Got plugins:', plugins);
+
+ const options = plugins.map((plugin) => ({
+ value: plugin.id,
+ label: plugin.name,
+ }));
+ setPluginOptions(options);
+ } catch (err) {
+ console.error('[PluginItemConfig] Failed:', err);
+ setError(String(err));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ loadPlugins();
+ }, [selectedItem]);
+
+ // ============================================================================
+ // CONDITIONAL RETURNS - Only after all hooks have been called
+ // ============================================================================
+
+ // Sanity check: Return null if no valid plugin item is selected
+ if (!selectedItem || selectedItem.type !== 'plugin') {
+ return null;
+ }
+
+ // Now it's safe to access selectedItem.data
+ const data = selectedItem.data as PluginItemData;
+
+ // Show error state if something went wrong
+ if (error) {
+ return (
+
+ Error: {error}
+
+ );
+ }
+
+ // Show loading state while fetching plugins
+ if (isLoading) {
+ return (
+
+ {i18next.t('menu-items.plugin.loading', 'Loading plugins...')}
+
+ );
+ }
+
+ // Show message if no plugins are installed
+ if (pluginOptions.length === 0) {
+ return (
+
+ {i18next.t(
+ 'menu-items.plugin.no-plugins',
+ 'No plugins installed. Import a plugin ZIP file to get started.'
+ )}
+
+ );
+ }
+
+ // Render the plugin selector dropdown
+ return (
+ <>
+ {
+ // Find the selected plugin to update the menu item's display name
+ const selectedPlugin = pluginOptions.find((p) => p.value === pluginId);
+
+ editMenuItem(selectedMenu, selectedChildPath, (item) => {
+ // Update the plugin ID in the item data
+ (item.data as PluginItemData).pluginId = pluginId;
+
+ // Optionally update the item name to match the plugin name
+ if (selectedPlugin && !item.name) {
+ item.name = selectedPlugin.label;
+ }
+
+ return item;
+ });
+ }}
+ />
+
+ {i18next.t(
+ 'menu-items.plugin.tip',
+ 'Plugins extend Kando with custom functionality. Select which plugin to run when this menu item is activated.'
+ )}
+
+ >
+ );
+}
diff --git a/src/settings-renderer/settings-window-api.ts b/src/settings-renderer/settings-window-api.ts
index 8105e73c4..f1922a39e 100644
--- a/src/settings-renderer/settings-window-api.ts
+++ b/src/settings-renderer/settings-window-api.ts
@@ -186,6 +186,82 @@ export const SETTINGS_WINDOW_API = {
restoreMenuSettings: () => {
ipcRenderer.send('settings-window.restore-menu-settings');
},
+
+ // =========================================================================
+ // PLUGIN SYSTEM METHODS - START
+ // =========================================================================
+
+ /**
+ * Get list of all installed plugins for the dropdown selector.
+ */
+ getPlugins: (): Promise<
+ Array<{
+ id: string;
+ name: string;
+ description?: string;
+ version: string;
+ author?: string;
+ }>
+ > => {
+ return ipcRenderer.invoke('plugins:get-list');
+ },
+
+ /**
+ * Get a specific plugin's full manifest.
+ */
+ getPluginManifest: (pluginId: string): Promise => {
+ return ipcRenderer.invoke('plugins:get-manifest', pluginId);
+ },
+
+ /**
+ * Open file dialog and import a plugin from a ZIP file.
+ */
+ importPlugin: (): Promise<{
+ success: boolean;
+ pluginId?: string;
+ error?: string;
+ }> => {
+ return ipcRenderer.invoke('plugins:import');
+ },
+
+ /**
+ * Remove an installed plugin.
+ */
+ removePlugin: (
+ pluginId: string
+ ): Promise<{ success: boolean; pluginId: string }> => {
+ return ipcRenderer.invoke('plugins:remove', pluginId);
+ },
+
+ /**
+ * Get user configuration for a specific plugin.
+ */
+ getPluginConfig: (pluginId: string): Promise> => {
+ return ipcRenderer.invoke('plugins:get-config', pluginId);
+ },
+
+ /**
+ * Save user configuration for a specific plugin.
+ */
+ savePluginConfig: (
+ pluginId: string,
+ config: Record
+ ): Promise<{ success: boolean }> => {
+ return ipcRenderer.invoke('plugins:save-config', pluginId, config);
+ },
+
+ /**
+ * Force rescan of the plugins directory.
+ */
+ rescanPlugins: (): Promise<
+ Array<{ id: string; name: string; version: string }>
+ > => {
+ return ipcRenderer.invoke('plugins:rescan');
+ },
+
+ // =========================================================================
+ // PLUGIN SYSTEM METHODS - END
+ // =========================================================================
};
/**