From 0bbec5b2778d6b1bb094945a8814e2bb29420b0f Mon Sep 17 00:00:00 2001 From: Comy Date: Mon, 26 Jan 2026 21:20:26 -0600 Subject: [PATCH] Added Plugins Menu Item --- docs/kando-plugin-system-spec.md | 317 ++++++++++++++++ docs/plugin-item-action.ts | 0 docs/plugin-item-type.ts | 0 docs/plugin-manager.ts | 0 docs/plugin-manifest.ts | 0 docs/zip-handler.ts | 0 locales/en/translation.json | 8 + package-lock.json | 23 ++ package.json | 4 + plugins/README.md | 10 + plugins/hello-world/index.html | 99 +++++ plugins/hello-world/kando-plugin.json | 26 ++ src/common/item-types/item-type-registry.ts | 2 + src/common/item-types/plugin-item-type.ts | 108 ++++++ src/common/plugin-manifest.ts | 332 +++++++++++++++++ src/main/index.ts | 16 + src/main/item-actions/item-action-registry.ts | 3 + src/main/item-actions/plugin-item-action.ts | 176 +++++++++ src/main/plugins/index.ts | 27 ++ src/main/plugins/ipc-handlers.ts | 117 ++++++ src/main/plugins/plugin-config-store.ts | 283 ++++++++++++++ src/main/plugins/plugin-manager.ts | 349 ++++++++++++++++++ src/main/plugins/zip-handler.ts | 198 ++++++++++ .../menu-preview/PreviewFooter.module.scss | 10 +- .../menu-properties/item-configs/index.tsx | 2 + .../item-configs/plugin-item-config.tsx | 178 +++++++++ src/settings-renderer/settings-window-api.ts | 76 ++++ 27 files changed, 2358 insertions(+), 6 deletions(-) create mode 100644 docs/kando-plugin-system-spec.md create mode 100644 docs/plugin-item-action.ts create mode 100644 docs/plugin-item-type.ts create mode 100644 docs/plugin-manager.ts create mode 100644 docs/plugin-manifest.ts create mode 100644 docs/zip-handler.ts create mode 100644 plugins/README.md create mode 100644 plugins/hello-world/index.html create mode 100644 plugins/hello-world/kando-plugin.json create mode 100644 src/common/item-types/plugin-item-type.ts create mode 100644 src/common/plugin-manifest.ts create mode 100644 src/main/item-actions/plugin-item-action.ts create mode 100644 src/main/plugins/index.ts create mode 100644 src/main/plugins/ipc-handlers.ts create mode 100644 src/main/plugins/plugin-config-store.ts create mode 100644 src/main/plugins/plugin-manager.ts create mode 100644 src/main/plugins/zip-handler.ts create mode 100644 src/settings-renderer/components/menu-properties/item-configs/plugin-item-config.tsx 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 @@ + + + + + + Hello World Plugin + + + +
+

🎉 Hello World Plugin

+

Loading...

+

+ +
+ + + + diff --git a/plugins/hello-world/kando-plugin.json b/plugins/hello-world/kando-plugin.json new file mode 100644 index 000000000..d9ee864c2 --- /dev/null +++ b/plugins/hello-world/kando-plugin.json @@ -0,0 +1,26 @@ +{ + "id": "hello-world", + "name": "Hello World", + "description": "A simple test plugin to verify the Kando plugin system.", + "version": "1.0.0", + "author": "Kando Team", + "window": { + "width": 400, + "height": 300, + "frame": true + }, + "parameters": { + "greeting": { + "type": "string", + "title": "Greeting Message", + "description": "The message to display when the plugin runs.", + "default": "Hello from Kando!" + }, + "showTimestamp": { + "type": "boolean", + "title": "Show Timestamp", + "description": "Whether to display the current time.", + "default": true + } + } +} diff --git a/src/common/item-types/item-type-registry.ts b/src/common/item-types/item-type-registry.ts index d68560de2..16e8ba335 100644 --- a/src/common/item-types/item-type-registry.ts +++ b/src/common/item-types/item-type-registry.ts @@ -22,6 +22,7 @@ import { TextItemType } from './text-item-type'; import { URIItemType } from './uri-item-type'; import { RedirectItemType } from './redirect-item-type'; import { SettingsItemType } from './settings-item-type'; +import { PluginItemType } from './plugin-item-type'; /** * This type describes meta information about a menu-item type. Every available type @@ -81,6 +82,7 @@ export class ItemTypeRegistry { this.types.set('uri', new URIItemType()); this.types.set('redirect', new RedirectItemType()); this.types.set('settings', new SettingsItemType()); + this.types.set('plugin', new PluginItemType()); } /** diff --git a/src/common/item-types/plugin-item-type.ts b/src/common/item-types/plugin-item-type.ts new file mode 100644 index 000000000..89f1517c0 --- /dev/null +++ b/src/common/item-types/plugin-item-type.ts @@ -0,0 +1,108 @@ +////////////////////////////////////////////////////////////////////////////////////////// +// _ _ ____ _ _ ___ ____ // +// |_/ |__| |\ | | \ | | 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 + +// 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 + // ========================================================================= }; /**