refactor: jan extensions (#799)

* refactor: rename plugin to extension
This commit is contained in:
Louis 2023-12-01 11:30:29 +07:00 committed by GitHub
parent e6de39dcb1
commit 1143bd3846
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
134 changed files with 1755 additions and 1868 deletions

9
.gitignore vendored
View File

@ -14,11 +14,10 @@ electron/renderer
package-lock.json package-lock.json
*.log *.log
plugin-core/lib
core/lib/** core/lib/**
# Nitro binary files # Nitro binary files
plugins/inference-plugin/nitro/*/nitro extensions/inference-extension/nitro/*/nitro
plugins/inference-plugin/nitro/*/*.exe extensions/inference-extension/nitro/*/*.exe
plugins/inference-plugin/nitro/*/*.dll extensions/inference-extension/nitro/*/*.dll
plugins/inference-plugin/nitro/*/*.metal extensions/inference-extension/nitro/*/*.metal

View File

@ -12,14 +12,14 @@ else
cd uikit && yarn install && yarn build cd uikit && yarn install && yarn build
endif endif
# Installs yarn dependencies and builds core and plugins # Installs yarn dependencies and builds core and extensions
install-and-build: build-uikit install-and-build: build-uikit
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)
yarn config set network-timeout 300000 yarn config set network-timeout 300000
endif endif
yarn build:core yarn build:core
yarn install yarn install
yarn build:plugins yarn build:extensions
dev: install-and-build dev: install-and-build
yarn dev yarn dev

View File

@ -110,7 +110,6 @@ Contributions are welcome! Please read the [CONTRIBUTING.md](CONTRIBUTING.md) fi
``` ```
This will start the development server and open the desktop app. This will start the development server and open the desktop app.
In this step, there are a few notification about installing base plugin, just click `OK` and `Next` to continue.
### For production build ### For production build

View File

@ -1,14 +1,13 @@
{ {
"name": "@janhq/core", "name": "@janhq/core",
"version": "0.1.10", "version": "0.1.10",
"description": "Plugin core lib", "description": "Jan app core lib",
"keywords": [ "keywords": [
"jan", "jan",
"plugin",
"core" "core"
], ],
"homepage": "https://github.com/janhq", "homepage": "https://jan.ai",
"license": "MIT", "license": "AGPL-3.0",
"main": "lib/index.js", "main": "lib/index.js",
"types": "lib/index.d.ts", "types": "lib/index.d.ts",
"directories": { "directories": {
@ -16,8 +15,7 @@
"test": "__tests__" "test": "__tests__"
}, },
"exports": { "exports": {
".": "./lib/index.js", ".": "./lib/index.js"
"./plugin": "./lib/plugins/index.js"
}, },
"files": [ "files": [
"lib", "lib",

View File

@ -1,13 +1,7 @@
export {}; export {};
declare global { declare global {
interface CorePlugin {
store?: any | undefined;
events?: any | undefined;
}
interface Window { interface Window {
corePlugin?: CorePlugin; core?: any;
coreAPI?: any | undefined;
electronAPI?: any | undefined;
} }
} }

View File

@ -1,18 +1,18 @@
/** /**
* Execute a plugin module function in main process * Execute a extension module function in main process
* *
* @param plugin plugin name to import * @param extension extension name to import
* @param method function name to execute * @param method function name to execute
* @param args arguments to pass to the function * @param args arguments to pass to the function
* @returns Promise<any> * @returns Promise<any>
* *
*/ */
const executeOnMain: ( const executeOnMain: (
plugin: string, extension: string,
method: string, method: string,
...args: any[] ...args: any[]
) => Promise<any> = (plugin, method, ...args) => ) => Promise<any> = (extension, method, ...args) =>
window.coreAPI?.invokePluginFunc(plugin, method, ...args) window.core?.api?.invokeExtensionFunc(extension, method, ...args);
/** /**
* Downloads a file from a URL and saves it to the local file system. * Downloads a file from a URL and saves it to the local file system.
@ -23,7 +23,7 @@ const executeOnMain: (
const downloadFile: (url: string, fileName: string) => Promise<any> = ( const downloadFile: (url: string, fileName: string) => Promise<any> = (
url, url,
fileName fileName
) => window.coreAPI?.downloadFile(url, fileName); ) => window.core?.api?.downloadFile(url, fileName);
/** /**
* Aborts the download of a specific file. * Aborts the download of a specific file.
@ -31,20 +31,20 @@ const downloadFile: (url: string, fileName: string) => Promise<any> = (
* @returns {Promise<any>} A promise that resolves when the download has been aborted. * @returns {Promise<any>} A promise that resolves when the download has been aborted.
*/ */
const abortDownload: (fileName: string) => Promise<any> = (fileName) => const abortDownload: (fileName: string) => Promise<any> = (fileName) =>
window.coreAPI?.abortDownload(fileName); window.core.api?.abortDownload(fileName);
/** /**
* Retrieves the path to the app data directory using the `coreAPI` object. * Retrieves the path to the app data directory using the `coreAPI` object.
* If the `coreAPI` object is not available, the function returns `undefined`. * If the `coreAPI` object is not available, the function returns `undefined`.
* @returns A Promise that resolves with the path to the app data directory, or `undefined` if the `coreAPI` object is not available. * @returns A Promise that resolves with the path to the app data directory, or `undefined` if the `coreAPI` object is not available.
*/ */
const appDataPath: () => Promise<any> = () => window.coreAPI?.appDataPath(); const appDataPath: () => Promise<any> = () => window.core.api?.appDataPath();
/** /**
* Gets the user space path. * Gets the user space path.
* @returns {Promise<any>} A Promise that resolves with the user space path. * @returns {Promise<any>} A Promise that resolves with the user space path.
*/ */
const getUserSpace = (): Promise<string> => window.coreAPI?.getUserSpace(); const getUserSpace = (): Promise<string> => window.core.api?.getUserSpace();
/** /**
* Opens the file explorer at a specific path. * Opens the file explorer at a specific path.
@ -52,7 +52,7 @@ const getUserSpace = (): Promise<string> => window.coreAPI?.getUserSpace();
* @returns {Promise<any>} A promise that resolves when the file explorer is opened. * @returns {Promise<any>} A promise that resolves when the file explorer is opened.
*/ */
const openFileExplorer: (path: string) => Promise<any> = (path) => const openFileExplorer: (path: string) => Promise<any> = (path) =>
window.coreAPI?.openFileExplorer(path); window.core.api?.openFileExplorer(path);
/** /**
* Register extension point function type definition * Register extension point function type definition

View File

@ -2,14 +2,12 @@
* The `EventName` enumeration contains the names of all the available events in the Jan platform. * The `EventName` enumeration contains the names of all the available events in the Jan platform.
*/ */
export enum EventName { export enum EventName {
OnNewConversation = "onNewConversation", /** The `OnMessageSent` event is emitted when a message is sent. */
OnNewMessageRequest = "onNewMessageRequest", OnMessageSent = "OnMessageSent",
OnNewMessageResponse = "onNewMessageResponse", /** The `OnMessageResponse` event is emitted when a message is received. */
OnMessageResponseUpdate = "onMessageResponseUpdate", OnMessageResponse = "OnMessageResponse",
OnMessageResponseFinished = "onMessageResponseFinished", /** The `OnMessageUpdate` event is emitted when a message is updated. */
OnDownloadUpdate = "onDownloadUpdate", OnMessageUpdate = "OnMessageUpdate",
OnDownloadSuccess = "onDownloadSuccess",
OnDownloadError = "onDownloadError",
} }
/** /**
@ -22,7 +20,7 @@ const on: (eventName: string, handler: Function) => void = (
eventName, eventName,
handler handler
) => { ) => {
window.corePlugin?.events?.on(eventName, handler); window.core?.events?.on(eventName, handler);
}; };
/** /**
@ -35,7 +33,7 @@ const off: (eventName: string, handler: Function) => void = (
eventName, eventName,
handler handler
) => { ) => {
window.corePlugin?.events?.off(eventName, handler); window.core?.events?.off(eventName, handler);
}; };
/** /**
@ -45,7 +43,7 @@ const off: (eventName: string, handler: Function) => void = (
* @param object The object to pass to the event callback. * @param object The object to pass to the event callback.
*/ */
const emit: (eventName: string, object: any) => void = (eventName, object) => { const emit: (eventName: string, object: any) => void = (eventName, object) => {
window.corePlugin?.events?.emit(eventName, object); window.core?.events?.emit(eventName, object);
}; };
export const events = { export const events = {

30
core/src/extension.ts Normal file
View File

@ -0,0 +1,30 @@
export enum ExtensionType {
Assistant = "assistant",
Conversational = "conversational",
Inference = "inference",
Model = "model",
SystemMonitoring = "systemMonitoring",
}
/**
* Represents a base extension.
* This class should be extended by any class that represents an extension.
*/
export abstract class BaseExtension {
/**
* Returns the type of the extension.
* @returns {ExtensionType} The type of the extension
* Undefined means its not extending any known extension by the application.
*/
abstract type(): ExtensionType | undefined;
/**
* Called when the extension is loaded.
* Any initialization logic for the extension should be put here.
*/
abstract onLoad(): void;
/**
* Called when the extension is unloaded.
* Any cleanup logic for the extension should be put here.
*/
abstract onUnload(): void;
}

View File

@ -1,11 +1,11 @@
import { Assistant } from "../index"; import { Assistant } from "../index";
import { JanPlugin } from "../plugin"; import { BaseExtension } from "../extension";
/** /**
* Abstract class for assistant plugins. * Assistant extension for managing assistants.
* @extends JanPlugin * @extends BaseExtension
*/ */
export abstract class AssistantPlugin extends JanPlugin { export abstract class AssistantExtension extends BaseExtension {
/** /**
* Creates a new assistant. * Creates a new assistant.
* @param {Assistant} assistant - The assistant object to be created. * @param {Assistant} assistant - The assistant object to be created.

View File

@ -1,12 +1,12 @@
import { Thread, ThreadMessage } from "../index"; import { Thread, ThreadMessage } from "../index";
import { JanPlugin } from "../plugin"; import { BaseExtension } from "../extension";
/** /**
* Abstract class for Thread plugins. * Conversational extension. Persists and retrieves conversations.
* @abstract * @abstract
* @extends JanPlugin * @extends BaseExtension
*/ */
export abstract class ConversationalPlugin extends JanPlugin { export abstract class ConversationalExtension extends BaseExtension {
/** /**
* Returns a list of thread. * Returns a list of thread.
* @abstract * @abstract

View File

@ -0,0 +1,25 @@
/**
* Conversational extension. Persists and retrieves conversations.
* @module
*/
export { ConversationalExtension } from "./conversational";
/**
* Inference extension. Start, stop and inference models.
*/
export { InferenceExtension } from "./inference";
/**
* Monitoring extension for system monitoring.
*/
export { MonitoringExtension } from "./monitoring";
/**
* Assistant extension for managing assistants.
*/
export { AssistantExtension } from "./assistant";
/**
* Model extension for managing models.
*/
export { ModelExtension } from "./model";

View File

@ -1,18 +1,18 @@
import { MessageRequest, ModelSettingParams, ThreadMessage } from "../index"; import { MessageRequest, ModelSettingParams, ThreadMessage } from "../index";
import { JanPlugin } from "../plugin"; import { BaseExtension } from "../extension";
/** /**
* An abstract class representing an Inference Plugin for Jan. * Inference extension. Start, stop and inference models.
*/ */
export abstract class InferencePlugin extends JanPlugin { export abstract class InferenceExtension extends BaseExtension {
/** /**
* Initializes the model for the plugin. * Initializes the model for the extension.
* @param modelId - The ID of the model to initialize. * @param modelId - The ID of the model to initialize.
*/ */
abstract initModel(modelId: string, settings?: ModelSettingParams): Promise<void>; abstract initModel(modelId: string, settings?: ModelSettingParams): Promise<void>;
/** /**
* Stops the model for the plugin. * Stops the model for the extension.
*/ */
abstract stopModel(): Promise<void>; abstract stopModel(): Promise<void>;

View File

@ -1,14 +1,10 @@
/** import { BaseExtension } from "../extension";
* Represents a plugin for managing machine learning models.
* @abstract
*/
import { JanPlugin } from "../plugin";
import { Model, ModelCatalog } from "../types/index"; import { Model, ModelCatalog } from "../types/index";
/** /**
* An abstract class representing a plugin for managing machine learning models. * Model extension for managing models.
*/ */
export abstract class ModelPlugin extends JanPlugin { export abstract class ModelExtension extends BaseExtension {
/** /**
* Downloads a model. * Downloads a model.
* @param model - The model to download. * @param model - The model to download.

View File

@ -1,10 +1,10 @@
import { JanPlugin } from "../plugin"; import { BaseExtension } from "../extension";
/** /**
* Abstract class for monitoring plugins. * Monitoring extension for system monitoring.
* @extends JanPlugin * @extends BaseExtension
*/ */
export abstract class MonitoringPlugin extends JanPlugin { export abstract class MonitoringExtension extends BaseExtension {
/** /**
* Returns information about the system resources. * Returns information about the system resources.
* @returns {Promise<any>} A promise that resolves with the system resources information. * @returns {Promise<any>} A promise that resolves with the system resources information.

View File

@ -5,7 +5,7 @@
* @returns {Promise<any>} A Promise that resolves when the file is written successfully. * @returns {Promise<any>} A Promise that resolves when the file is written successfully.
*/ */
const writeFile: (path: string, data: string) => Promise<any> = (path, data) => const writeFile: (path: string, data: string) => Promise<any> = (path, data) =>
window.coreAPI?.writeFile(path, data); window.core.api?.writeFile(path, data);
/** /**
* Checks whether the path is a directory. * Checks whether the path is a directory.
@ -13,7 +13,7 @@ const writeFile: (path: string, data: string) => Promise<any> = (path, data) =>
* @returns {boolean} A boolean indicating whether the path is a directory. * @returns {boolean} A boolean indicating whether the path is a directory.
*/ */
const isDirectory = (path: string): Promise<boolean> => const isDirectory = (path: string): Promise<boolean> =>
window.coreAPI?.isDirectory(path); window.core.api?.isDirectory(path);
/** /**
* Reads the contents of a file at the specified path. * Reads the contents of a file at the specified path.
@ -21,7 +21,7 @@ const isDirectory = (path: string): Promise<boolean> =>
* @returns {Promise<any>} A Promise that resolves with the contents of the file. * @returns {Promise<any>} A Promise that resolves with the contents of the file.
*/ */
const readFile: (path: string) => Promise<any> = (path) => const readFile: (path: string) => Promise<any> = (path) =>
window.coreAPI?.readFile(path); window.core.api?.readFile(path);
/** /**
* List the directory files * List the directory files
@ -29,7 +29,7 @@ const readFile: (path: string) => Promise<any> = (path) =>
* @returns {Promise<any>} A Promise that resolves with the contents of the directory. * @returns {Promise<any>} A Promise that resolves with the contents of the directory.
*/ */
const listFiles: (path: string) => Promise<any> = (path) => const listFiles: (path: string) => Promise<any> = (path) =>
window.coreAPI?.listFiles(path); window.core.api?.listFiles(path);
/** /**
* Creates a directory at the specified path. * Creates a directory at the specified path.
@ -37,7 +37,7 @@ const listFiles: (path: string) => Promise<any> = (path) =>
* @returns {Promise<any>} A Promise that resolves when the directory is created successfully. * @returns {Promise<any>} A Promise that resolves when the directory is created successfully.
*/ */
const mkdir: (path: string) => Promise<any> = (path) => const mkdir: (path: string) => Promise<any> = (path) =>
window.coreAPI?.mkdir(path); window.core.api?.mkdir(path);
/** /**
* Removes a directory at the specified path. * Removes a directory at the specified path.
@ -45,14 +45,14 @@ const mkdir: (path: string) => Promise<any> = (path) =>
* @returns {Promise<any>} A Promise that resolves when the directory is removed successfully. * @returns {Promise<any>} A Promise that resolves when the directory is removed successfully.
*/ */
const rmdir: (path: string) => Promise<any> = (path) => const rmdir: (path: string) => Promise<any> = (path) =>
window.coreAPI?.rmdir(path); window.core.api?.rmdir(path);
/** /**
* Deletes a file from the local file system. * Deletes a file from the local file system.
* @param {string} path - The path of the file to delete. * @param {string} path - The path of the file to delete.
* @returns {Promise<any>} A Promise that resolves when the file is deleted. * @returns {Promise<any>} A Promise that resolves when the file is deleted.
*/ */
const deleteFile: (path: string) => Promise<any> = (path) => const deleteFile: (path: string) => Promise<any> = (path) =>
window.coreAPI?.deleteFile(path); window.core.api?.deleteFile(path);
/** /**
* Appends data to a file at the specified path. * Appends data to a file at the specified path.
@ -60,7 +60,7 @@ const deleteFile: (path: string) => Promise<any> = (path) =>
* @param data data to append * @param data data to append
*/ */
const appendFile: (path: string, data: string) => Promise<any> = (path, data) => const appendFile: (path: string, data: string) => Promise<any> = (path, data) =>
window.coreAPI?.appendFile(path, data); window.core.api?.appendFile(path, data);
/** /**
* Reads a file line by line. * Reads a file line by line.
@ -68,7 +68,7 @@ const appendFile: (path: string, data: string) => Promise<any> = (path, data) =>
* @returns {Promise<any>} A promise that resolves to the lines of the file. * @returns {Promise<any>} A promise that resolves to the lines of the file.
*/ */
const readLineByLine: (path: string) => Promise<any> = (path) => const readLineByLine: (path: string) => Promise<any> = (path) =>
window.coreAPI?.readLineByLine(path); window.core.api?.readLineByLine(path);
export const fs = { export const fs = {
isDirectory, isDirectory,

View File

@ -1,29 +1,35 @@
/** /**
* Core module exports. * Export all types.
* @module
*/
export * from "./core";
/**
* Events events exports.
* @module
*/
export * from "./events";
/**
* Events types exports.
* @module * @module
*/ */
export * from "./types/index"; export * from "./types/index";
/** /**
* Filesystem module exports. * Export Core module
* @module
*/
export * from "./core";
/**
* Export Event module.
* @module
*/
export * from "./events";
/**
* Export Filesystem module.
* @module * @module
*/ */
export * from "./fs"; export * from "./fs";
/** /**
* Plugin base module export. * Export Extension module.
* @module * @module
*/ */
export * from "./plugin"; export * from "./extension";
/**
* Export all base extensions.
* @module
*/
export * from "./extensions/index";

View File

@ -1,14 +0,0 @@
export enum PluginType {
Conversational = "conversational",
Inference = "inference",
Preference = "preference",
SystemMonitoring = "systemMonitoring",
Model = "model",
Assistant = "assistant",
}
export abstract class JanPlugin {
abstract type(): PluginType;
abstract onLoad(): void;
abstract onUnload(): void;
}

View File

@ -1,25 +0,0 @@
/**
* Conversational plugin. Persists and retrieves conversations.
* @module
*/
export { ConversationalPlugin } from "./conversational";
/**
* Inference plugin. Start, stop and inference models.
*/
export { InferencePlugin } from "./inference";
/**
* Monitoring plugin for system monitoring.
*/
export { MonitoringPlugin } from "./monitoring";
/**
* Assistant plugin for managing assistants.
*/
export { AssistantPlugin } from "./assistant";
/**
* Model plugin for managing models.
*/
export { ModelPlugin } from "./model";

View File

@ -1,44 +1,38 @@
module.exports = { module.exports = {
root: true, root: true,
parser: "@typescript-eslint/parser", parser: '@typescript-eslint/parser',
plugins: ["@typescript-eslint"], plugins: ['@typescript-eslint'],
env: { env: {
node: true, node: true,
}, },
extends: [ extends: [
"eslint:recommended", 'eslint:recommended',
"plugin:@typescript-eslint/recommended", 'plugin:@typescript-eslint/recommended',
"plugin:react/recommended", 'plugin:react/recommended',
], ],
rules: { rules: {
"@typescript-eslint/no-non-null-assertion": "off", '@typescript-eslint/no-non-null-assertion': 'off',
"react/prop-types": "off", // In favor of strong typing - no need to dedupe 'react/prop-types': 'off', // In favor of strong typing - no need to dedupe
"@typescript-eslint/no-var-requires": "off", '@typescript-eslint/no-var-requires': 'off',
"@typescript-eslint/ban-ts-comment": "off", '@typescript-eslint/ban-ts-comment': 'off',
"@typescript-eslint/no-unused-vars": "off", '@typescript-eslint/no-unused-vars': 'off',
"@typescript-eslint/no-explicit-any": "off", '@typescript-eslint/no-explicit-any': 'off',
}, },
settings: { settings: {
react: { react: {
createClass: "createReactClass", // Regex for Component Factory to use, createClass: 'createReactClass', // Regex for Component Factory to use,
// default to "createReactClass" // default to "createReactClass"
pragma: "React", // Pragma to use, default to "React" pragma: 'React', // Pragma to use, default to "React"
version: "detect", // React version. "detect" automatically picks the version you have installed. version: 'detect', // React version. "detect" automatically picks the version you have installed.
// You can also use `16.0`, `16.3`, etc, if you want to override the detected value. // You can also use `16.0`, `16.3`, etc, if you want to override the detected value.
// default to latest and warns if missing // default to latest and warns if missing
// It will default to "detect" in the future // It will default to "detect" in the future
}, },
linkComponents: [ linkComponents: [
// Components used as alternatives to <a> for linking, eg. <Link to={ url } /> // Components used as alternatives to <a> for linking, eg. <Link to={ url } />
"Hyperlink", 'Hyperlink',
{ name: "Link", linkAttribute: "to" }, { name: 'Link', linkAttribute: 'to' },
], ],
}, },
ignorePatterns: [ ignorePatterns: ['build', 'renderer', 'node_modules'],
"build", }
"renderer",
"node_modules",
"core/plugins",
"core/**/*.test.js",
],
};

View File

@ -1,30 +0,0 @@
const { ipcRenderer, contextBridge } = require("electron");
export function useFacade() {
const interfaces = {
install(plugins: any[]) {
return ipcRenderer.invoke("pluggable:install", plugins);
},
uninstall(plugins: any[], reload: boolean) {
return ipcRenderer.invoke("pluggable:uninstall", plugins, reload);
},
getActive() {
return ipcRenderer.invoke("pluggable:getActivePlugins");
},
update(plugins: any[], reload: boolean) {
return ipcRenderer.invoke("pluggable:update", plugins, reload);
},
updatesAvailable(plugin: any) {
return ipcRenderer.invoke("pluggable:updatesAvailable", plugin);
},
toggleActive(plugin: any, active: boolean) {
return ipcRenderer.invoke("pluggable:togglePluginActive", plugin, active);
},
};
if (contextBridge) {
contextBridge.exposeInMainWorld("pluggableElectronIpc", interfaces);
}
return interfaces;
}

View File

@ -1,36 +0,0 @@
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { join, resolve } from "path";
export let pluginsPath: string | undefined = undefined;
/**
* @private
* Set path to plugins directory and create the directory if it does not exist.
* @param {string} plgPath path to plugins directory
*/
export function setPluginsPath(plgPath: string) {
// Create folder if it does not exist
let plgDir;
try {
plgDir = resolve(plgPath);
if (plgDir.length < 2) throw new Error();
if (!existsSync(plgDir)) mkdirSync(plgDir);
const pluginsJson = join(plgDir, "plugins.json");
if (!existsSync(pluginsJson)) writeFileSync(pluginsJson, "{}", "utf8");
pluginsPath = plgDir;
} catch (error) {
throw new Error("Invalid path provided to the plugins folder");
}
}
/**
* @private
* Get the path to the plugins.json file.
* @returns location of plugins.json
*/
export function getPluginsFile() {
return join(pluginsPath ?? "", "plugins.json");
}

View File

@ -1,149 +0,0 @@
import { readFileSync } from "fs";
import { protocol } from "electron";
import { normalize } from "path";
import Plugin from "./plugin";
import {
getAllPlugins,
removePlugin,
persistPlugins,
installPlugins,
getPlugin,
getActivePlugins,
addPlugin,
} from "./store";
import {
pluginsPath as storedPluginsPath,
setPluginsPath,
getPluginsFile,
} from "./globals";
import router from "./router";
/**
* Sets up the required communication between the main and renderer processes.
* Additionally sets the plugins up using {@link usePlugins} if a pluginsPath is provided.
* @param {Object} options configuration for setting up the renderer facade.
* @param {confirmInstall} [options.confirmInstall] Function to validate that a plugin should be installed.
* @param {Boolean} [options.useFacade=true] Whether to make a facade to the plugins available in the renderer.
* @param {string} [options.pluginsPath] Optional path to the plugins folder.
* @returns {pluginManager|Object} A set of functions used to manage the plugin lifecycle if usePlugins is provided.
* @function
*/
export function init(options: any) {
if (
!Object.prototype.hasOwnProperty.call(options, "useFacade") ||
options.useFacade
) {
// Enable IPC to be used by the facade
router();
}
// Create plugins protocol to serve plugins to renderer
registerPluginProtocol();
// perform full setup if pluginsPath is provided
if (options.pluginsPath) {
return usePlugins(options.pluginsPath);
}
return {};
}
/**
* Create plugins protocol to provide plugins to renderer
* @private
* @returns {boolean} Whether the protocol registration was successful
*/
function registerPluginProtocol() {
return protocol.registerFileProtocol("plugin", (request, callback) => {
const entry = request.url.substr(8);
const url = normalize(storedPluginsPath + entry);
callback({ path: url });
});
}
/**
* Set Pluggable Electron up to run from the pluginPath folder if it is provided and
* load plugins persisted in that folder.
* @param {string} pluginsPath Path to the plugins folder. Required if not yet set up.
* @returns {pluginManager} A set of functions used to manage the plugin lifecycle.
*/
export function usePlugins(pluginsPath: string) {
if (!pluginsPath)
throw Error(
"A path to the plugins folder is required to use Pluggable Electron"
);
// Store the path to the plugins folder
setPluginsPath(pluginsPath);
// Remove any registered plugins
for (const plugin of getAllPlugins()) {
if (plugin.name) removePlugin(plugin.name, false);
}
// Read plugin list from plugins folder
const plugins = JSON.parse(readFileSync(getPluginsFile(), "utf-8"));
try {
// Create and store a Plugin instance for each plugin in list
for (const p in plugins) {
loadPlugin(plugins[p]);
}
persistPlugins();
} catch (error) {
// Throw meaningful error if plugin loading fails
throw new Error(
"Could not successfully rebuild list of installed plugins.\n" +
error +
"\nPlease check the plugins.json file in the plugins folder."
);
}
// Return the plugin lifecycle functions
return getStore();
}
/**
* Check the given plugin object. If it is marked for uninstalling, the plugin files are removed.
* Otherwise a Plugin instance for the provided object is created and added to the store.
* @private
* @param {Object} plg Plugin info
*/
function loadPlugin(plg: any) {
// Create new plugin, populate it with plg details and save it to the store
const plugin = new Plugin();
for (const key in plg) {
if (Object.prototype.hasOwnProperty.call(plg, key)) {
// Use Object.defineProperty to set the properties as writable
Object.defineProperty(plugin, key, {
value: plg[key],
writable: true,
enumerable: true,
configurable: true,
});
}
}
addPlugin(plugin, false);
plugin.subscribe("pe-persist", persistPlugins);
}
/**
* Returns the publicly available store functions.
* @returns {pluginManager} A set of functions used to manage the plugin lifecycle.
*/
export function getStore() {
if (!storedPluginsPath) {
throw new Error(
"The plugin path has not yet been set up. Please run usePlugins before accessing the store"
);
}
return {
installPlugins,
getPlugin,
getAllPlugins,
getActivePlugins,
removePlugin,
};
}

View File

@ -1,213 +0,0 @@
import { rmdir } from "fs/promises";
import { resolve, join } from "path";
import { manifest, extract } from "pacote";
import * as Arborist from "@npmcli/arborist";
import { pluginsPath } from "./globals";
/**
* An NPM package that can be used as a Pluggable Electron plugin.
* Used to hold all the information and functions necessary to handle the plugin lifecycle.
*/
class Plugin {
/**
* @property {string} origin Original specification provided to fetch the package.
* @property {Object} installOptions Options provided to pacote when fetching the manifest.
* @property {name} name The name of the plugin as defined in the manifest.
* @property {string} url Electron URL where the package can be accessed.
* @property {string} version Version of the package as defined in the manifest.
* @property {Array<string>} activationPoints List of {@link ./Execution-API#activationPoints|activation points}.
* @property {string} main The entry point as defined in the main entry of the manifest.
* @property {string} description The description of plugin as defined in the manifest.
* @property {string} icon The icon of plugin as defined in the manifest.
*/
origin?: string;
installOptions: any;
name?: string;
url?: string;
version?: string;
activationPoints?: Array<string>;
main?: string;
description?: string;
icon?: string;
/** @private */
_active = false;
/**
* @private
* @property {Object.<string, Function>} #listeners A list of callbacks to be executed when the Plugin is updated.
*/
listeners: Record<string, (obj: any) => void> = {};
/**
* Set installOptions with defaults for options that have not been provided.
* @param {string} [origin] Original specification provided to fetch the package.
* @param {Object} [options] Options provided to pacote when fetching the manifest.
*/
constructor(origin?: string, options = {}) {
const defaultOpts = {
version: false,
fullMetadata: false,
Arborist,
};
this.origin = origin;
this.installOptions = { ...defaultOpts, ...options };
}
/**
* Package name with version number.
* @type {string}
*/
get specifier() {
return (
this.origin +
(this.installOptions.version ? "@" + this.installOptions.version : "")
);
}
/**
* Whether the plugin should be registered with its activation points.
* @type {boolean}
*/
get active() {
return this._active;
}
/**
* Set Package details based on it's manifest
* @returns {Promise.<Boolean>} Resolves to true when the action completed
*/
async getManifest() {
// Get the package's manifest (package.json object)
try {
const mnf = await manifest(this.specifier, this.installOptions);
// set the Package properties based on the it's manifest
this.name = mnf.name;
this.version = mnf.version;
this.activationPoints = mnf.activationPoints
? (mnf.activationPoints as string[])
: undefined;
this.main = mnf.main;
this.description = mnf.description;
this.icon = mnf.icon as any;
} catch (error) {
throw new Error(
`Package ${this.origin} does not contain a valid manifest: ${error}`
);
}
return true;
}
/**
* Extract plugin to plugins folder.
* @returns {Promise.<Plugin>} This plugin
* @private
*/
async _install() {
try {
// import the manifest details
await this.getManifest();
// Install the package in a child folder of the given folder
await extract(
this.specifier,
join(pluginsPath ?? "", this.name ?? ""),
this.installOptions
);
if (!Array.isArray(this.activationPoints))
throw new Error("The plugin does not contain any activation points");
// Set the url using the custom plugins protocol
this.url = `plugin://${this.name}/${this.main}`;
this.emitUpdate();
} catch (err) {
// Ensure the plugin is not stored and the folder is removed if the installation fails
this.setActive(false);
throw err;
}
return [this];
}
/**
* Subscribe to updates of this plugin
* @param {string} name name of the callback to register
* @param {callback} cb The function to execute on update
*/
subscribe(name: string, cb: () => void) {
this.listeners[name] = cb;
}
/**
* Remove subscription
* @param {string} name name of the callback to remove
*/
unsubscribe(name: string) {
delete this.listeners[name];
}
/**
* Execute listeners
*/
emitUpdate() {
for (const cb in this.listeners) {
this.listeners[cb].call(null, this);
}
}
/**
* Check for updates and install if available.
* @param {string} version The version to update to.
* @returns {boolean} Whether an update was performed.
*/
async update(version = false) {
if (await this.isUpdateAvailable()) {
this.installOptions.version = version;
await this._install();
return true;
}
return false;
}
/**
* Check if a new version of the plugin is available at the origin.
* @returns the latest available version if a new version is available or false if not.
*/
async isUpdateAvailable() {
if (this.origin) {
const mnf = await manifest(this.origin);
return mnf.version !== this.version ? mnf.version : false;
}
}
/**
* Remove plugin and refresh renderers.
* @returns {Promise}
*/
async uninstall() {
const plgPath = resolve(pluginsPath ?? "", this.name ?? "");
await rmdir(plgPath, { recursive: true });
this.emitUpdate();
}
/**
* Set a plugin's active state. This determines if a plugin should be loaded on initialisation.
* @param {boolean} active State to set _active to
* @returns {Plugin} This plugin
*/
setActive(active: boolean) {
this._active = active;
this.emitUpdate();
return this;
}
}
export default Plugin;

View File

@ -1,97 +0,0 @@
import { ipcMain, webContents } from "electron";
import {
getPlugin,
getActivePlugins,
installPlugins,
removePlugin,
getAllPlugins,
} from "./store";
import { pluginsPath } from "./globals";
import Plugin from "./plugin";
// Throw an error if pluginsPath has not yet been provided by usePlugins.
const checkPluginsPath = () => {
if (!pluginsPath)
throw Error("Path to plugins folder has not yet been set up.");
};
let active = false;
/**
* Provide the renderer process access to the plugins.
**/
export default function () {
if (active) return;
// Register IPC route to install a plugin
ipcMain.handle("pluggable:install", async (e, plugins) => {
checkPluginsPath();
// Install and activate all provided plugins
const installed = await installPlugins(plugins);
return JSON.parse(JSON.stringify(installed));
});
// Register IPC route to uninstall a plugin
ipcMain.handle("pluggable:uninstall", async (e, plugins, reload) => {
checkPluginsPath();
// Uninstall all provided plugins
for (const plg of plugins) {
const plugin = getPlugin(plg);
await plugin.uninstall();
if (plugin.name) removePlugin(plugin.name);
}
// Reload all renderer pages if needed
reload && webContents.getAllWebContents().forEach((wc) => wc.reload());
return true;
});
// Register IPC route to update a plugin
ipcMain.handle("pluggable:update", async (e, plugins, reload) => {
checkPluginsPath();
// Update all provided plugins
const updated: Plugin[] = [];
for (const plg of plugins) {
const plugin = getPlugin(plg);
const res = await plugin.update();
if (res) updated.push(plugin);
}
// Reload all renderer pages if needed
if (updated.length && reload)
webContents.getAllWebContents().forEach((wc) => wc.reload());
return JSON.parse(JSON.stringify(updated));
});
// Register IPC route to check if updates are available for a plugin
ipcMain.handle("pluggable:updatesAvailable", (e, names) => {
checkPluginsPath();
const plugins = names
? names.map((name: string) => getPlugin(name))
: getAllPlugins();
const updates: Record<string, Plugin> = {};
for (const plugin of plugins) {
updates[plugin.name] = plugin.isUpdateAvailable();
}
return updates;
});
// Register IPC route to get the list of active plugins
ipcMain.handle("pluggable:getActivePlugins", () => {
checkPluginsPath();
return JSON.parse(JSON.stringify(getActivePlugins()));
});
// Register IPC route to toggle the active state of a plugin
ipcMain.handle("pluggable:togglePluginActive", (e, plg, active) => {
checkPluginsPath();
const plugin = getPlugin(plg);
return JSON.parse(JSON.stringify(plugin.setActive(active)));
});
active = true;
}

View File

@ -1,131 +0,0 @@
/**
* Provides access to the plugins stored by Pluggable Electron
* @typedef {Object} pluginManager
* @prop {getPlugin} getPlugin
* @prop {getAllPlugins} getAllPlugins
* @prop {getActivePlugins} getActivePlugins
* @prop {installPlugins} installPlugins
* @prop {removePlugin} removePlugin
*/
import { writeFileSync } from "fs";
import Plugin from "./plugin";
import { getPluginsFile } from "./globals";
/**
* @module store
* @private
*/
/**
* Register of installed plugins
* @type {Object.<string, Plugin>} plugin - List of installed plugins
*/
const plugins: Record<string, Plugin> = {};
/**
* Get a plugin from the stored plugins.
* @param {string} name Name of the plugin to retrieve
* @returns {Plugin} Retrieved plugin
* @alias pluginManager.getPlugin
*/
export function getPlugin(name: string) {
if (!Object.prototype.hasOwnProperty.call(plugins, name)) {
throw new Error(`Plugin ${name} does not exist`);
}
return plugins[name];
}
/**
* Get list of all plugin objects.
* @returns {Array.<Plugin>} All plugin objects
* @alias pluginManager.getAllPlugins
*/
export function getAllPlugins() {
return Object.values(plugins);
}
/**
* Get list of active plugin objects.
* @returns {Array.<Plugin>} Active plugin objects
* @alias pluginManager.getActivePlugins
*/
export function getActivePlugins() {
return Object.values(plugins).filter((plugin) => plugin.active);
}
/**
* Remove plugin from store and maybe save stored plugins to file
* @param {string} name Name of the plugin to remove
* @param {boolean} persist Whether to save the changes to plugins to file
* @returns {boolean} Whether the delete was successful
* @alias pluginManager.removePlugin
*/
export function removePlugin(name: string, persist = true) {
const del = delete plugins[name];
if (persist) persistPlugins();
return del;
}
/**
* Add plugin to store and maybe save stored plugins to file
* @param {Plugin} plugin Plugin to add to store
* @param {boolean} persist Whether to save the changes to plugins to file
* @returns {void}
*/
export function addPlugin(plugin: Plugin, persist = true) {
if (plugin.name) plugins[plugin.name] = plugin;
if (persist) {
persistPlugins();
plugin.subscribe("pe-persist", persistPlugins);
}
}
/**
* Save stored plugins to file
* @returns {void}
*/
export function persistPlugins() {
const persistData: Record<string, Plugin> = {};
for (const name in plugins) {
persistData[name] = plugins[name];
}
writeFileSync(getPluginsFile(), JSON.stringify(persistData), "utf8");
}
/**
* Create and install a new plugin for the given specifier.
* @param {Array.<installOptions | string>} plugins A list of NPM specifiers, or installation configuration objects.
* @param {boolean} [store=true] Whether to store the installed plugins in the store
* @returns {Promise.<Array.<Plugin>>} New plugin
* @alias pluginManager.installPlugins
*/
export async function installPlugins(plugins: any, store = true) {
const installed: Plugin[] = [];
for (const plg of plugins) {
// Set install options and activation based on input type
const isObject = typeof plg === "object";
const spec = isObject ? [plg.specifier, plg] : [plg];
const activate = isObject ? plg.activate !== false : true;
// Install and possibly activate plugin
const plugin = new Plugin(...spec);
await plugin._install();
if (activate) plugin.setActive(true);
// Add plugin to store if needed
if (store) addPlugin(plugin);
installed.push(plugin);
}
// Return list of all installed plugins
return installed;
}
/**
* @typedef {Object.<string, any>} installOptions The {@link https://www.npmjs.com/package/pacote|pacote}
* options used to install the plugin with some extra options.
* @param {string} specifier the NPM specifier that identifies the package.
* @param {boolean} [activate] Whether this plugin should be activated after installation. Defaults to true.
*/

View File

@ -0,0 +1,204 @@
import { rmdir } from 'fs/promises'
import { resolve, join } from 'path'
import { manifest, extract } from 'pacote'
import * as Arborist from '@npmcli/arborist'
import { ExtensionManager } from './../managers/extension'
/**
* An NPM package that can be used as an extension.
* Used to hold all the information and functions necessary to handle the extension lifecycle.
*/
class Extension {
/**
* @property {string} origin Original specification provided to fetch the package.
* @property {Object} installOptions Options provided to pacote when fetching the manifest.
* @property {name} name The name of the extension as defined in the manifest.
* @property {string} url Electron URL where the package can be accessed.
* @property {string} version Version of the package as defined in the manifest.
* @property {string} main The entry point as defined in the main entry of the manifest.
* @property {string} description The description of extension as defined in the manifest.
*/
origin?: string
installOptions: any
name?: string
url?: string
version?: string
main?: string
description?: string
/** @private */
_active = false
/**
* @private
* @property {Object.<string, Function>} #listeners A list of callbacks to be executed when the Extension is updated.
*/
listeners: Record<string, (obj: any) => void> = {}
/**
* Set installOptions with defaults for options that have not been provided.
* @param {string} [origin] Original specification provided to fetch the package.
* @param {Object} [options] Options provided to pacote when fetching the manifest.
*/
constructor(origin?: string, options = {}) {
const defaultOpts = {
version: false,
fullMetadata: false,
Arborist,
}
this.origin = origin
this.installOptions = { ...defaultOpts, ...options }
}
/**
* Package name with version number.
* @type {string}
*/
get specifier() {
return (
this.origin +
(this.installOptions.version ? '@' + this.installOptions.version : '')
)
}
/**
* Whether the extension should be registered with its activation points.
* @type {boolean}
*/
get active() {
return this._active
}
/**
* Set Package details based on it's manifest
* @returns {Promise.<Boolean>} Resolves to true when the action completed
*/
async getManifest() {
// Get the package's manifest (package.json object)
try {
const mnf = await manifest(this.specifier, this.installOptions)
// set the Package properties based on the it's manifest
this.name = mnf.name
this.version = mnf.version
this.main = mnf.main
this.description = mnf.description
} catch (error) {
throw new Error(
`Package ${this.origin} does not contain a valid manifest: ${error}`
)
}
return true
}
/**
* Extract extension to extensions folder.
* @returns {Promise.<Extension>} This extension
* @private
*/
async _install() {
try {
// import the manifest details
await this.getManifest()
// Install the package in a child folder of the given folder
await extract(
this.specifier,
join(ExtensionManager.instance.extensionsPath ?? '', this.name ?? ''),
this.installOptions
)
// Set the url using the custom extensions protocol
this.url = `extension://${this.name}/${this.main}`
this.emitUpdate()
} catch (err) {
// Ensure the extension is not stored and the folder is removed if the installation fails
this.setActive(false)
throw err
}
return [this]
}
/**
* Subscribe to updates of this extension
* @param {string} name name of the callback to register
* @param {callback} cb The function to execute on update
*/
subscribe(name: string, cb: () => void) {
this.listeners[name] = cb
}
/**
* Remove subscription
* @param {string} name name of the callback to remove
*/
unsubscribe(name: string) {
delete this.listeners[name]
}
/**
* Execute listeners
*/
emitUpdate() {
for (const cb in this.listeners) {
this.listeners[cb].call(null, this)
}
}
/**
* Check for updates and install if available.
* @param {string} version The version to update to.
* @returns {boolean} Whether an update was performed.
*/
async update(version = false) {
if (await this.isUpdateAvailable()) {
this.installOptions.version = version
await this._install()
return true
}
return false
}
/**
* Check if a new version of the extension is available at the origin.
* @returns the latest available version if a new version is available or false if not.
*/
async isUpdateAvailable() {
if (this.origin) {
const mnf = await manifest(this.origin)
return mnf.version !== this.version ? mnf.version : false
}
}
/**
* Remove extension and refresh renderers.
* @returns {Promise}
*/
async uninstall() {
const extPath = resolve(
ExtensionManager.instance.extensionsPath ?? '',
this.name ?? ''
)
await rmdir(extPath, { recursive: true })
this.emitUpdate()
}
/**
* Set a extension's active state. This determines if a extension should be loaded on initialisation.
* @param {boolean} active State to set _active to
* @returns {Extension} This extension
*/
setActive(active: boolean) {
this._active = active
this.emitUpdate()
return this
}
}
export default Extension

137
electron/extension/index.ts Normal file
View File

@ -0,0 +1,137 @@
import { readFileSync } from 'fs'
import { protocol } from 'electron'
import { normalize } from 'path'
import Extension from './extension'
import {
getAllExtensions,
removeExtension,
persistExtensions,
installExtensions,
getExtension,
getActiveExtensions,
addExtension,
} from './store'
import { ExtensionManager } from './../managers/extension'
/**
* Sets up the required communication between the main and renderer processes.
* Additionally sets the extensions up using {@link useExtensions} if a extensionsPath is provided.
* @param {Object} options configuration for setting up the renderer facade.
* @param {confirmInstall} [options.confirmInstall] Function to validate that a extension should be installed.
* @param {Boolean} [options.useFacade=true] Whether to make a facade to the extensions available in the renderer.
* @param {string} [options.extensionsPath] Optional path to the extensions folder.
* @returns {extensionManager|Object} A set of functions used to manage the extension lifecycle if useExtensions is provided.
* @function
*/
export function init(options: any) {
// Create extensions protocol to serve extensions to renderer
registerExtensionProtocol()
// perform full setup if extensionsPath is provided
if (options.extensionsPath) {
return useExtensions(options.extensionsPath)
}
return {}
}
/**
* Create extensions protocol to provide extensions to renderer
* @private
* @returns {boolean} Whether the protocol registration was successful
*/
function registerExtensionProtocol() {
return protocol.registerFileProtocol('extension', (request, callback) => {
const entry = request.url.substr('extension://'.length - 1)
const url = normalize(ExtensionManager.instance.extensionsPath + entry)
callback({ path: url })
})
}
/**
* Set extensions up to run from the extensionPath folder if it is provided and
* load extensions persisted in that folder.
* @param {string} extensionsPath Path to the extensions folder. Required if not yet set up.
* @returns {extensionManager} A set of functions used to manage the extension lifecycle.
*/
export function useExtensions(extensionsPath: string) {
if (!extensionsPath)
throw Error('A path to the extensions folder is required to use extensions')
// Store the path to the extensions folder
ExtensionManager.instance.setExtensionsPath(extensionsPath)
// Remove any registered extensions
for (const extension of getAllExtensions()) {
if (extension.name) removeExtension(extension.name, false)
}
// Read extension list from extensions folder
const extensions = JSON.parse(
readFileSync(ExtensionManager.instance.getExtensionsFile(), 'utf-8')
)
try {
// Create and store a Extension instance for each extension in list
for (const p in extensions) {
loadExtension(extensions[p])
}
persistExtensions()
} catch (error) {
// Throw meaningful error if extension loading fails
throw new Error(
'Could not successfully rebuild list of installed extensions.\n' +
error +
'\nPlease check the extensions.json file in the extensions folder.'
)
}
// Return the extension lifecycle functions
return getStore()
}
/**
* Check the given extension object. If it is marked for uninstalling, the extension files are removed.
* Otherwise a Extension instance for the provided object is created and added to the store.
* @private
* @param {Object} ext Extension info
*/
function loadExtension(ext: any) {
// Create new extension, populate it with ext details and save it to the store
const extension = new Extension()
for (const key in ext) {
if (Object.prototype.hasOwnProperty.call(ext, key)) {
// Use Object.defineProperty to set the properties as writable
Object.defineProperty(extension, key, {
value: ext[key],
writable: true,
enumerable: true,
configurable: true,
})
}
}
addExtension(extension, false)
extension.subscribe('pe-persist', persistExtensions)
}
/**
* Returns the publicly available store functions.
* @returns {extensionManager} A set of functions used to manage the extension lifecycle.
*/
export function getStore() {
if (!ExtensionManager.instance.extensionsPath) {
throw new Error(
'The extension path has not yet been set up. Please run useExtensions before accessing the store'
)
}
return {
installExtensions,
getExtension,
getAllExtensions,
getActiveExtensions,
removeExtension,
}
}

135
electron/extension/store.ts Normal file
View File

@ -0,0 +1,135 @@
/**
* Provides access to the extensions stored by Extension Store
* @typedef {Object} extensionManager
* @prop {getExtension} getExtension
* @prop {getAllExtensions} getAllExtensions
* @prop {getActiveExtensions} getActiveExtensions
* @prop {installExtensions} installExtensions
* @prop {removeExtension} removeExtension
*/
import { writeFileSync } from 'fs'
import Extension from './extension'
import { ExtensionManager } from './../managers/extension'
/**
* @module store
* @private
*/
/**
* Register of installed extensions
* @type {Object.<string, Extension>} extension - List of installed extensions
*/
const extensions: Record<string, Extension> = {}
/**
* Get a extension from the stored extensions.
* @param {string} name Name of the extension to retrieve
* @returns {Extension} Retrieved extension
* @alias extensionManager.getExtension
*/
export function getExtension(name: string) {
if (!Object.prototype.hasOwnProperty.call(extensions, name)) {
throw new Error(`Extension ${name} does not exist`)
}
return extensions[name]
}
/**
* Get list of all extension objects.
* @returns {Array.<Extension>} All extension objects
* @alias extensionManager.getAllExtensions
*/
export function getAllExtensions() {
return Object.values(extensions)
}
/**
* Get list of active extension objects.
* @returns {Array.<Extension>} Active extension objects
* @alias extensionManager.getActiveExtensions
*/
export function getActiveExtensions() {
return Object.values(extensions).filter((extension) => extension.active)
}
/**
* Remove extension from store and maybe save stored extensions to file
* @param {string} name Name of the extension to remove
* @param {boolean} persist Whether to save the changes to extensions to file
* @returns {boolean} Whether the delete was successful
* @alias extensionManager.removeExtension
*/
export function removeExtension(name: string, persist = true) {
const del = delete extensions[name]
if (persist) persistExtensions()
return del
}
/**
* Add extension to store and maybe save stored extensions to file
* @param {Extension} extension Extension to add to store
* @param {boolean} persist Whether to save the changes to extensions to file
* @returns {void}
*/
export function addExtension(extension: Extension, persist = true) {
if (extension.name) extensions[extension.name] = extension
if (persist) {
persistExtensions()
extension.subscribe('pe-persist', persistExtensions)
}
}
/**
* Save stored extensions to file
* @returns {void}
*/
export function persistExtensions() {
const persistData: Record<string, Extension> = {}
for (const name in extensions) {
persistData[name] = extensions[name]
}
writeFileSync(
ExtensionManager.instance.getExtensionsFile(),
JSON.stringify(persistData),
'utf8'
)
}
/**
* Create and install a new extension for the given specifier.
* @param {Array.<installOptions | string>} extensions A list of NPM specifiers, or installation configuration objects.
* @param {boolean} [store=true] Whether to store the installed extensions in the store
* @returns {Promise.<Array.<Extension>>} New extension
* @alias extensionManager.installExtensions
*/
export async function installExtensions(extensions: any, store = true) {
const installed: Extension[] = []
for (const ext of extensions) {
// Set install options and activation based on input type
const isObject = typeof ext === 'object'
const spec = isObject ? [ext.specifier, ext] : [ext]
const activate = isObject ? ext.activate !== false : true
// Install and possibly activate extension
const extension = new Extension(...spec)
await extension._install()
if (activate) extension.setActive(true)
// Add extension to store if needed
if (store) addExtension(extension)
installed.push(extension)
}
// Return list of all installed extensions
return installed
}
/**
* @typedef {Object.<string, any>} installOptions The {@link https://www.npmjs.com/package/pacote|pacote}
* options used to install the extension with some extra options.
* @param {string} specifier the NPM specifier that identifies the package.
* @param {boolean} [activate] Whether this extension should be activated after installation. Defaults to true.
*/

View File

@ -1,8 +1,9 @@
import { app, ipcMain, shell } from "electron"; import { app, ipcMain, shell } from 'electron'
import { ModuleManager } from "../managers/module"; import { ModuleManager } from '../managers/module'
import { join } from "path"; import { join } from 'path'
import { PluginManager } from "../managers/plugin"; import { ExtensionManager } from '../managers/extension'
import { WindowManager } from "../managers/window"; import { WindowManager } from '../managers/window'
import { userSpacePath } from '../utils/path'
export function handleAppIPCs() { export function handleAppIPCs() {
/** /**
@ -10,57 +11,58 @@ export function handleAppIPCs() {
* If the `coreAPI` object is not available, the function returns `undefined`. * If the `coreAPI` object is not available, the function returns `undefined`.
* @returns A Promise that resolves with the path to the app data directory, or `undefined` if the `coreAPI` object is not available. * @returns A Promise that resolves with the path to the app data directory, or `undefined` if the `coreAPI` object is not available.
*/ */
ipcMain.handle("appDataPath", async (_event) => { ipcMain.handle('appDataPath', async (_event) => {
return app.getPath("userData"); return app.getPath('userData')
}); })
/** /**
* Returns the version of the app. * Returns the version of the app.
* @param _event - The IPC event object. * @param _event - The IPC event object.
* @returns The version of the app. * @returns The version of the app.
*/ */
ipcMain.handle("appVersion", async (_event) => { ipcMain.handle('appVersion', async (_event) => {
return app.getVersion(); return app.getVersion()
}); })
/** /**
* Handles the "openAppDirectory" IPC message by opening the app's user data directory. * Handles the "openAppDirectory" IPC message by opening the app's user data directory.
* The `shell.openPath` method is used to open the directory in the user's default file explorer. * The `shell.openPath` method is used to open the directory in the user's default file explorer.
* @param _event - The IPC event object. * @param _event - The IPC event object.
*/ */
ipcMain.handle("openAppDirectory", async (_event) => { ipcMain.handle('openAppDirectory', async (_event) => {
const userSpacePath = join(app.getPath('home'), 'jan') shell.openPath(userSpacePath)
shell.openPath(userSpacePath); })
});
/** /**
* Opens a URL in the user's default browser. * Opens a URL in the user's default browser.
* @param _event - The IPC event object. * @param _event - The IPC event object.
* @param url - The URL to open. * @param url - The URL to open.
*/ */
ipcMain.handle("openExternalUrl", async (_event, url) => { ipcMain.handle('openExternalUrl', async (_event, url) => {
shell.openExternal(url); shell.openExternal(url)
}); })
/** /**
* Relaunches the app in production - reload window in development. * Relaunches the app in production - reload window in development.
* @param _event - The IPC event object. * @param _event - The IPC event object.
* @param url - The URL to reload. * @param url - The URL to reload.
*/ */
ipcMain.handle("relaunch", async (_event, url) => { ipcMain.handle('relaunch', async (_event, url) => {
ModuleManager.instance.clearImportedModules(); ModuleManager.instance.clearImportedModules()
if (app.isPackaged) { if (app.isPackaged) {
app.relaunch(); app.relaunch()
app.exit(); app.exit()
} else { } else {
for (const modulePath in ModuleManager.instance.requiredModules) { for (const modulePath in ModuleManager.instance.requiredModules) {
delete require.cache[ delete require.cache[
require.resolve(join(app.getPath("userData"), "plugins", modulePath)) require.resolve(
]; join(userSpacePath, 'extensions', modulePath)
)
]
} }
PluginManager.instance.setupPlugins(); ExtensionManager.instance.setupExtensions()
WindowManager.instance.currentWindow?.reload(); WindowManager.instance.currentWindow?.reload()
} }
}); })
} }

View File

@ -0,0 +1,131 @@
import { app, ipcMain, webContents } from 'electron'
import { readdirSync, rmdir, writeFileSync } from 'fs'
import { ModuleManager } from '../managers/module'
import { join, extname } from 'path'
import { ExtensionManager } from '../managers/extension'
import { WindowManager } from '../managers/window'
import { manifest, tarball } from 'pacote'
import {
getActiveExtensions,
getAllExtensions,
installExtensions,
} from '../extension/store'
import { getExtension } from '../extension/store'
import { removeExtension } from '../extension/store'
import Extension from '../extension/extension'
import { userSpacePath } from '../utils/path'
export function handleExtensionIPCs() {
/**MARK: General handlers */
/**
* Invokes a function from a extension module in main node process.
* @param _event - The IPC event object.
* @param modulePath - The path to the extension module.
* @param method - The name of the function to invoke.
* @param args - The arguments to pass to the function.
* @returns The result of the invoked function.
*/
ipcMain.handle(
'extension:invokeExtensionFunc',
async (_event, modulePath, method, ...args) => {
const module = require(
/* webpackIgnore: true */ join(userSpacePath, 'extensions', modulePath)
)
ModuleManager.instance.setModule(modulePath, module)
if (typeof module[method] === 'function') {
return module[method](...args)
} else {
console.debug(module[method])
console.error(`Function "${method}" does not exist in the module.`)
}
}
)
/**
* Returns the paths of the base extensions.
* @param _event - The IPC event object.
* @returns An array of paths to the base extensions.
*/
ipcMain.handle('extension:baseExtensions', async (_event) => {
const baseExtensionPath = join(
__dirname,
'../',
app.isPackaged ? '../../app.asar.unpacked/pre-install' : '../pre-install'
)
return readdirSync(baseExtensionPath)
.filter((file) => extname(file) === '.tgz')
.map((file) => join(baseExtensionPath, file))
})
/**
* Returns the path to the user's extension directory.
* @param _event - The IPC event extension.
* @returns The path to the user's extension directory.
*/
ipcMain.handle('extension:extensionPath', async (_event) => {
return join(userSpacePath, 'extensions')
})
/**MARK: Extension Manager handlers */
ipcMain.handle('extension:install', async (e, extensions) => {
// Install and activate all provided extensions
const installed = await installExtensions(extensions)
return JSON.parse(JSON.stringify(installed))
})
// Register IPC route to uninstall a extension
ipcMain.handle('extension:uninstall', async (e, extensions, reload) => {
// Uninstall all provided extensions
for (const ext of extensions) {
const extension = getExtension(ext)
await extension.uninstall()
if (extension.name) removeExtension(extension.name)
}
// Reload all renderer pages if needed
reload && webContents.getAllWebContents().forEach((wc) => wc.reload())
return true
})
// Register IPC route to update a extension
ipcMain.handle('extension:update', async (e, extensions, reload) => {
// Update all provided extensions
const updated: Extension[] = []
for (const ext of extensions) {
const extension = getExtension(ext)
const res = await extension.update()
if (res) updated.push(extension)
}
// Reload all renderer pages if needed
if (updated.length && reload)
webContents.getAllWebContents().forEach((wc) => wc.reload())
return JSON.parse(JSON.stringify(updated))
})
// Register IPC route to check if updates are available for a extension
ipcMain.handle('extension:updatesAvailable', (e, names) => {
const extensions = names
? names.map((name: string) => getExtension(name))
: getAllExtensions()
const updates: Record<string, Extension> = {}
for (const extension of extensions) {
updates[extension.name] = extension.isUpdateAvailable()
}
return updates
})
// Register IPC route to get the list of active extensions
ipcMain.handle('extension:getActiveExtensions', () => {
return JSON.parse(JSON.stringify(getActiveExtensions()))
})
// Register IPC route to toggle the active state of a extension
ipcMain.handle('extension:toggleExtensionActive', (e, plg, active) => {
const extension = getExtension(plg)
return JSON.parse(JSON.stringify(extension.setActive(active)))
})
}

View File

@ -2,13 +2,12 @@ import { app, ipcMain } from 'electron'
import * as fs from 'fs' import * as fs from 'fs'
import { join } from 'path' import { join } from 'path'
import readline from 'readline' import readline from 'readline'
import { userSpacePath } from '../utils/path'
/** /**
* Handles file system operations. * Handles file system operations.
*/ */
export function handleFsIPCs() { export function handleFsIPCs() {
const userSpacePath = join(app.getPath('home'), 'jan')
/** /**
* Gets the path to the user data directory. * Gets the path to the user data directory.
* @param event - The event object. * @param event - The event object.

View File

@ -1,118 +0,0 @@
import { app, ipcMain } from "electron";
import { readdirSync, rmdir, writeFileSync } from "fs";
import { ModuleManager } from "../managers/module";
import { join, extname } from "path";
import { PluginManager } from "../managers/plugin";
import { WindowManager } from "../managers/window";
import { manifest, tarball } from "pacote";
export function handlePluginIPCs() {
/**
* Invokes a function from a plugin module in main node process.
* @param _event - The IPC event object.
* @param modulePath - The path to the plugin module.
* @param method - The name of the function to invoke.
* @param args - The arguments to pass to the function.
* @returns The result of the invoked function.
*/
ipcMain.handle(
"invokePluginFunc",
async (_event, modulePath, method, ...args) => {
const module = require(
/* webpackIgnore: true */ join(
app.getPath("userData"),
"plugins",
modulePath
)
);
ModuleManager.instance.setModule(modulePath, module);
if (typeof module[method] === "function") {
return module[method](...args);
} else {
console.debug(module[method]);
console.error(`Function "${method}" does not exist in the module.`);
}
}
);
/**
* Returns the paths of the base plugins.
* @param _event - The IPC event object.
* @returns An array of paths to the base plugins.
*/
ipcMain.handle("basePlugins", async (_event) => {
const basePluginPath = join(
__dirname,
"../",
app.isPackaged
? "../../app.asar.unpacked/core/pre-install"
: "../core/pre-install"
);
return readdirSync(basePluginPath)
.filter((file) => extname(file) === ".tgz")
.map((file) => join(basePluginPath, file));
});
/**
* Returns the path to the user's plugin directory.
* @param _event - The IPC event object.
* @returns The path to the user's plugin directory.
*/
ipcMain.handle("pluginPath", async (_event) => {
return join(app.getPath("userData"), "plugins");
});
/**
* Deletes the `plugins` directory in the user data path and disposes of required modules.
* If the app is packaged, the function relaunches the app and exits.
* Otherwise, the function deletes the cached modules and sets up the plugins and reloads the main window.
* @param _event - The IPC event object.
* @param url - The URL to reload.
*/
ipcMain.handle("reloadPlugins", async (_event, url) => {
const userDataPath = app.getPath("userData");
const fullPath = join(userDataPath, "plugins");
rmdir(fullPath, { recursive: true }, function (err) {
if (err) console.error(err);
ModuleManager.instance.clearImportedModules();
// just relaunch if packaged, should launch manually in development mode
if (app.isPackaged) {
app.relaunch();
app.exit();
} else {
for (const modulePath in ModuleManager.instance.requiredModules) {
delete require.cache[
require.resolve(
join(app.getPath("userData"), "plugins", modulePath)
)
];
}
PluginManager.instance.setupPlugins();
WindowManager.instance.currentWindow?.reload();
}
});
});
/**
* Installs a remote plugin by downloading its tarball and writing it to a tgz file.
* @param _event - The IPC event object.
* @param pluginName - The name of the remote plugin to install.
* @returns A Promise that resolves to the path of the installed plugin file.
*/
ipcMain.handle("installRemotePlugin", async (_event, pluginName) => {
const destination = join(
app.getPath("userData"),
pluginName.replace(/^@.*\//, "") + ".tgz"
);
return manifest(pluginName)
.then(async (manifest: any) => {
await tarball(manifest._resolved).then((data: Buffer) => {
writeFileSync(destination, data);
});
})
.then(() => destination);
});
}

62
electron/invokers/app.ts Normal file
View File

@ -0,0 +1,62 @@
import { shell } from 'electron'
const { ipcRenderer } = require('electron')
export function appInvokers() {
const interfaces = {
/**
* Sets the native theme to light.
*/
setNativeThemeLight: () => ipcRenderer.invoke('setNativeThemeLight'),
/**
* Sets the native theme to dark.
*/
setNativeThemeDark: () => ipcRenderer.invoke('setNativeThemeDark'),
/**
* Sets the native theme to system default.
*/
setNativeThemeSystem: () => ipcRenderer.invoke('setNativeThemeSystem'),
/**
* Retrieves the application data path.
* @returns {Promise<string>} A promise that resolves to the application data path.
*/
appDataPath: () => ipcRenderer.invoke('appDataPath'),
/**
* Retrieves the application version.
* @returns {Promise<string>} A promise that resolves to the application version.
*/
appVersion: () => ipcRenderer.invoke('appVersion'),
/**
* Opens an external URL.
* @param {string} url - The URL to open.
* @returns {Promise<void>} A promise that resolves when the URL has been opened.
*/
openExternalUrl: (url: string) =>
ipcRenderer.invoke('openExternalUrl', url),
/**
* Relaunches the application.
* @returns {Promise<void>} A promise that resolves when the application has been relaunched.
*/
relaunch: () => ipcRenderer.invoke('relaunch'),
/**
* Opens the application directory.
* @returns {Promise<void>} A promise that resolves when the application directory has been opened.
*/
openAppDirectory: () => ipcRenderer.invoke('openAppDirectory'),
/**
* Opens the file explorer at a specific path.
* @param {string} path - The path to open in the file explorer.
*/
openFileExplorer: (path: string) => shell.openPath(path),
}
return interfaces
}

View File

@ -0,0 +1,77 @@
const { ipcRenderer } = require('electron')
export function downloadInvokers() {
const interfaces = {
/**
* Opens the file explorer at a specific path.
* @param {string} path - The path to open in the file explorer.
*/
downloadFile: (url: string, path: string) =>
ipcRenderer.invoke('downloadFile', url, path),
/**
* Pauses the download of a file.
* @param {string} fileName - The name of the file whose download should be paused.
*/
pauseDownload: (fileName: string) =>
ipcRenderer.invoke('pauseDownload', fileName),
/**
* Pauses the download of a file.
* @param {string} fileName - The name of the file whose download should be paused.
*/
resumeDownload: (fileName: string) =>
ipcRenderer.invoke('resumeDownload', fileName),
/**
* Pauses the download of a file.
* @param {string} fileName - The name of the file whose download should be paused.
*/
abortDownload: (fileName: string) =>
ipcRenderer.invoke('abortDownload', fileName),
/**
* Pauses the download of a file.
* @param {string} fileName - The name of the file whose download should be paused.
*/
onFileDownloadUpdate: (callback: any) =>
ipcRenderer.on('FILE_DOWNLOAD_UPDATE', callback),
/**
* Listens for errors on file downloads.
* @param {Function} callback - The function to call when there is an error.
*/
onFileDownloadError: (callback: any) =>
ipcRenderer.on('FILE_DOWNLOAD_ERROR', callback),
/**
* Listens for the successful completion of file downloads.
* @param {Function} callback - The function to call when a download is complete.
*/
onFileDownloadSuccess: (callback: any) =>
ipcRenderer.on('FILE_DOWNLOAD_COMPLETE', callback),
/**
* Listens for updates on app update downloads.
* @param {Function} callback - The function to call when there is an update.
*/
onAppUpdateDownloadUpdate: (callback: any) =>
ipcRenderer.on('APP_UPDATE_PROGRESS', callback),
/**
* Listens for errors on app update downloads.
* @param {Function} callback - The function to call when there is an error.
*/
onAppUpdateDownloadError: (callback: any) =>
ipcRenderer.on('APP_UPDATE_ERROR', callback),
/**
* Listens for the successful completion of app update downloads.
* @param {Function} callback - The function to call when an update download is complete.
*/
onAppUpdateDownloadSuccess: (callback: any) =>
ipcRenderer.on('APP_UPDATE_COMPLETE', callback),
}
return interfaces
}

View File

@ -0,0 +1,78 @@
const { ipcRenderer } = require('electron')
export function extensionInvokers() {
const interfaces = {
/**
* Installs the given extensions.
* @param {any[]} extensions - The extensions to install.
*/
install(extensions: any[]) {
return ipcRenderer.invoke('extension:install', extensions)
},
/**
* Uninstalls the given extensions.
* @param {any[]} extensions - The extensions to uninstall.
* @param {boolean} reload - Whether to reload after uninstalling.
*/
uninstall(extensions: any[], reload: boolean) {
return ipcRenderer.invoke('extension:uninstall', extensions, reload)
},
/**
* Retrieves the active extensions.
*/
getActive() {
return ipcRenderer.invoke('extension:getActiveExtensions')
},
/**
* Updates the given extensions.
* @param {any[]} extensions - The extensions to update.
* @param {boolean} reload - Whether to reload after updating.
*/
update(extensions: any[], reload: boolean) {
return ipcRenderer.invoke('extension:update', extensions, reload)
},
/**
* Checks if updates are available for the given extension.
* @param {any} extension - The extension to check for updates.
*/
updatesAvailable(extension: any) {
return ipcRenderer.invoke('extension:updatesAvailable', extension)
},
/**
* Toggles the active state of the given extension.
* @param {any} extension - The extension to toggle.
* @param {boolean} active - The new active state.
*/
toggleActive(extension: any, active: boolean) {
return ipcRenderer.invoke(
'extension:toggleExtensionActive',
extension,
active
)
},
/**
* Invokes a function of the given extension.
* @param {any} extension - The extension whose function should be invoked.
* @param {any} method - The function to invoke.
* @param {any[]} args - The arguments to pass to the function.
*/
invokeExtensionFunc: (extension: any, method: any, ...args: any[]) =>
ipcRenderer.invoke(
'extension:invokeExtensionFunc',
extension,
method,
...args
),
/**
* Retrieves the base extensions.
*/
baseExtensions: () => ipcRenderer.invoke('extension:baseExtensions'),
/**
* Retrieves the extension path.
*/
extensionPath: () => ipcRenderer.invoke('extension:extensionPath'),
}
return interfaces
}

73
electron/invokers/fs.ts Normal file
View File

@ -0,0 +1,73 @@
const { ipcRenderer } = require('electron')
export function fsInvokers() {
const interfaces = {
/**
* Deletes a file at the specified path.
* @param {string} filePath - The path of the file to delete.
*/
deleteFile: (filePath: string) =>
ipcRenderer.invoke('deleteFile', filePath),
/**
* Checks if the path points to a directory.
* @param {string} filePath - The path to check.
*/
isDirectory: (filePath: string) =>
ipcRenderer.invoke('isDirectory', filePath),
/**
* Retrieves the user's space.
*/
getUserSpace: () => ipcRenderer.invoke('getUserSpace'),
/**
* Reads a file at the specified path.
* @param {string} path - The path of the file to read.
*/
readFile: (path: string) => ipcRenderer.invoke('readFile', path),
/**
* Writes data to a file at the specified path.
* @param {string} path - The path of the file to write to.
* @param {string} data - The data to write.
*/
writeFile: (path: string, data: string) =>
ipcRenderer.invoke('writeFile', path, data),
/**
* Lists the files in a directory at the specified path.
* @param {string} path - The path of the directory to list files from.
*/
listFiles: (path: string) => ipcRenderer.invoke('listFiles', path),
/**
* Appends data to a file at the specified path.
* @param {string} path - The path of the file to append to.
* @param {string} data - The data to append.
*/
appendFile: (path: string, data: string) =>
ipcRenderer.invoke('appendFile', path, data),
/**
* Reads a file line by line at the specified path.
* @param {string} path - The path of the file to read.
*/
readLineByLine: (path: string) =>
ipcRenderer.invoke('readLineByLine', path),
/**
* Creates a directory at the specified path.
* @param {string} path - The path where the directory should be created.
*/
mkdir: (path: string) => ipcRenderer.invoke('mkdir', path),
/**
* Removes a directory at the specified path.
* @param {string} path - The path of the directory to remove.
*/
rmdir: (path: string) => ipcRenderer.invoke('rmdir', path),
}
return interfaces
}

View File

@ -8,21 +8,21 @@ import { handleFsIPCs } from './handlers/fs'
**/ **/
import { WindowManager } from './managers/window' import { WindowManager } from './managers/window'
import { ModuleManager } from './managers/module' import { ModuleManager } from './managers/module'
import { PluginManager } from './managers/plugin' import { ExtensionManager } from './managers/extension'
/** /**
* IPC Handlers * IPC Handlers
**/ **/
import { handleDownloaderIPCs } from './handlers/download' import { handleDownloaderIPCs } from './handlers/download'
import { handleThemesIPCs } from './handlers/theme' import { handleThemesIPCs } from './handlers/theme'
import { handlePluginIPCs } from './handlers/plugin' import { handleExtensionIPCs } from './handlers/extension'
import { handleAppIPCs } from './handlers/app' import { handleAppIPCs } from './handlers/app'
import { handleAppUpdates } from './handlers/update' import { handleAppUpdates } from './handlers/update'
app app
.whenReady() .whenReady()
.then(PluginManager.instance.migratePlugins) .then(ExtensionManager.instance.migrateExtensions)
.then(PluginManager.instance.setupPlugins) .then(ExtensionManager.instance.setupExtensions)
.then(setupMenu) .then(setupMenu)
.then(handleIPCs) .then(handleIPCs)
.then(handleAppUpdates) .then(handleAppUpdates)
@ -78,6 +78,6 @@ function handleIPCs() {
handleFsIPCs() handleFsIPCs()
handleDownloaderIPCs() handleDownloaderIPCs()
handleThemesIPCs() handleThemesIPCs()
handlePluginIPCs() handleExtensionIPCs()
handleAppIPCs() handleAppIPCs()
} }

View File

@ -0,0 +1,85 @@
import { app } from 'electron'
import { init } from '../extension'
import { join, resolve } from 'path'
import { rmdir } from 'fs'
import Store from 'electron-store'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { userSpacePath } from '../utils/path'
/**
* Manages extension installation and migration.
*/
export class ExtensionManager {
public static instance: ExtensionManager = new ExtensionManager()
extensionsPath: string | undefined = undefined
constructor() {
if (ExtensionManager.instance) {
return ExtensionManager.instance
}
}
/**
* Sets up the extensions by initializing the `extensions` module with the `confirmInstall` and `extensionsPath` options.
* The `confirmInstall` function always returns `true` to allow extension installation.
* The `extensionsPath` option specifies the path to install extensions to.
*/
setupExtensions() {
init({
// Function to check from the main process that user wants to install a extension
confirmInstall: async (_extensions: string[]) => {
return true
},
// Path to install extension to
extensionsPath: join(userSpacePath, 'extensions'),
})
}
/**
* Migrates the extensions by deleting the `extensions` directory in the user data path.
* If the `migrated_version` key in the `Store` object does not match the current app version,
* the function deletes the `extensions` directory and sets the `migrated_version` key to the current app version.
* @returns A Promise that resolves when the migration is complete.
*/
migrateExtensions() {
return new Promise((resolve) => {
const store = new Store()
if (store.get('migrated_version') !== app.getVersion()) {
console.debug('start migration:', store.get('migrated_version'))
const fullPath = join(userSpacePath, 'extensions')
rmdir(fullPath, { recursive: true }, function (err) {
if (err) console.error(err)
store.set('migrated_version', app.getVersion())
console.debug('migrate extensions done')
resolve(undefined)
})
} else {
resolve(undefined)
}
})
}
setExtensionsPath(extPath: string) {
// Create folder if it does not exist
let extDir
try {
extDir = resolve(extPath)
if (extDir.length < 2) throw new Error()
if (!existsSync(extDir)) mkdirSync(extDir)
const extensionsJson = join(extDir, 'extensions.json')
if (!existsSync(extensionsJson))
writeFileSync(extensionsJson, '{}', 'utf8')
this.extensionsPath = extDir
} catch (error) {
throw new Error('Invalid path provided to the extensions folder')
}
}
getExtensionsFile() {
return join(this.extensionsPath ?? '', 'extensions.json')
}
}

View File

@ -1,60 +0,0 @@
import { app } from "electron";
import { init } from "../core/plugin/index";
import { join } from "path";
import { rmdir } from "fs";
import Store from "electron-store";
/**
* Manages plugin installation and migration.
*/
export class PluginManager {
public static instance: PluginManager = new PluginManager();
constructor() {
if (PluginManager.instance) {
return PluginManager.instance;
}
}
/**
* Sets up the plugins by initializing the `plugins` module with the `confirmInstall` and `pluginsPath` options.
* The `confirmInstall` function always returns `true` to allow plugin installation.
* The `pluginsPath` option specifies the path to install plugins to.
*/
setupPlugins() {
init({
// Function to check from the main process that user wants to install a plugin
confirmInstall: async (_plugins: string[]) => {
return true;
},
// Path to install plugin to
pluginsPath: join(app.getPath("userData"), "plugins"),
});
}
/**
* Migrates the plugins by deleting the `plugins` directory in the user data path.
* If the `migrated_version` key in the `Store` object does not match the current app version,
* the function deletes the `plugins` directory and sets the `migrated_version` key to the current app version.
* @returns A Promise that resolves when the migration is complete.
*/
migratePlugins() {
return new Promise((resolve) => {
const store = new Store();
if (store.get("migrated_version") !== app.getVersion()) {
console.debug("start migration:", store.get("migrated_version"));
const userDataPath = app.getPath("userData");
const fullPath = join(userDataPath, "plugins");
rmdir(fullPath, { recursive: true }, function (err) {
if (err) console.error(err);
store.set("migrated_version", app.getVersion());
console.debug("migrate plugins done");
resolve(undefined);
});
} else {
resolve(undefined);
}
});
}
}

View File

@ -13,10 +13,10 @@
"renderer/**/*", "renderer/**/*",
"build/*.{js,map}", "build/*.{js,map}",
"build/**/*.{js,map}", "build/**/*.{js,map}",
"core/pre-install" "pre-install"
], ],
"asarUnpack": [ "asarUnpack": [
"core/pre-install" "pre-install"
], ],
"publish": [ "publish": [
{ {

View File

@ -2,7 +2,6 @@ import { PlaywrightTestConfig } from "@playwright/test";
const config: PlaywrightTestConfig = { const config: PlaywrightTestConfig = {
testDir: "./tests", testDir: "./tests",
testIgnore: "./core/**",
retries: 0, retries: 0,
timeout: 120000, timeout: 120000,
}; };

View File

@ -1,147 +1,21 @@
/** /**
* Exposes a set of APIs to the renderer process via the contextBridge object. * Exposes a set of APIs to the renderer process via the contextBridge object.
* @remarks
* This module is used to make Pluggable Electron's facade available to the renderer on window.plugins.
* @module preload * @module preload
*/ */
/** // TODO: Refactor this file for less dependencies and more modularity
* Exposes a set of APIs to the renderer process via the contextBridge object. // TODO: Most of the APIs should be done using RestAPIs from extensions
* @remarks
* This module is used to make Pluggable Electron's facade available to the renderer on window.plugins.
* @function useFacade
* @memberof module:preload
* @returns {void}
*/
/** import { fsInvokers } from './invokers/fs'
* Exposes a set of APIs to the renderer process via the contextBridge object. import { appInvokers } from './invokers/app'
* @remarks import { downloadInvokers } from './invokers/download'
* This module is used to make Pluggable Electron's facade available to the renderer on window.plugins. import { extensionInvokers } from './invokers/extension'
* @namespace electronAPI
* @memberof module:preload
* @property {Function} invokePluginFunc - Invokes a plugin function with the given arguments.
* @property {Function} setNativeThemeLight - Sets the native theme to light.
* @property {Function} setNativeThemeDark - Sets the native theme to dark.
* @property {Function} setNativeThemeSystem - Sets the native theme to system.
* @property {Function} basePlugins - Returns the base plugins.
* @property {Function} pluginPath - Returns the plugin path.
* @property {Function} appDataPath - Returns the app data path.
* @property {Function} reloadPlugins - Reloads the plugins.
* @property {Function} appVersion - Returns the app version.
* @property {Function} openExternalUrl - Opens the given URL in the default browser.
* @property {Function} relaunch - Relaunches the app.
* @property {Function} openAppDirectory - Opens the app directory.
* @property {Function} deleteFile - Deletes the file at the given path.
* @property {Function} isDirectory - Returns true if the file at the given path is a directory.
* @property {Function} getUserSpace - Returns the user space.
* @property {Function} readFile - Reads the file at the given path.
* @property {Function} writeFile - Writes the given data to the file at the given path.
* @property {Function} listFiles - Lists the files in the directory at the given path.
* @property {Function} appendFile - Appends the given data to the file at the given path.
* @property {Function} mkdir - Creates a directory at the given path.
* @property {Function} rmdir - Removes a directory at the given path recursively.
* @property {Function} installRemotePlugin - Installs the remote plugin with the given name.
* @property {Function} downloadFile - Downloads the file at the given URL to the given path.
* @property {Function} pauseDownload - Pauses the download of the file with the given name.
* @property {Function} resumeDownload - Resumes the download of the file with the given name.
* @property {Function} abortDownload - Aborts the download of the file with the given name.
* @property {Function} onFileDownloadUpdate - Registers a callback to be called when a file download is updated.
* @property {Function} onFileDownloadError - Registers a callback to be called when a file download encounters an error.
* @property {Function} onFileDownloadSuccess - Registers a callback to be called when a file download is completed successfully.
* @property {Function} onAppUpdateDownloadUpdate - Registers a callback to be called when an app update download is updated.
* @property {Function} onAppUpdateDownloadError - Registers a callback to be called when an app update download encounters an error.
* @property {Function} onAppUpdateDownloadSuccess - Registers a callback to be called when an app update download is completed successfully.
*/
// Make Pluggable Electron's facade available to the renderer on window.plugins const { contextBridge } = require('electron')
import { useFacade } from './core/plugin/facade'
useFacade()
const { contextBridge, ipcRenderer, shell } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
invokePluginFunc: (plugin: any, method: any, ...args: any[]) => ...extensionInvokers(),
ipcRenderer.invoke('invokePluginFunc', plugin, method, ...args), ...downloadInvokers(),
...fsInvokers(),
setNativeThemeLight: () => ipcRenderer.invoke('setNativeThemeLight'), ...appInvokers(),
setNativeThemeDark: () => ipcRenderer.invoke('setNativeThemeDark'),
setNativeThemeSystem: () => ipcRenderer.invoke('setNativeThemeSystem'),
basePlugins: () => ipcRenderer.invoke('basePlugins'),
pluginPath: () => ipcRenderer.invoke('pluginPath'),
appDataPath: () => ipcRenderer.invoke('appDataPath'),
reloadPlugins: () => ipcRenderer.invoke('reloadPlugins'),
appVersion: () => ipcRenderer.invoke('appVersion'),
openExternalUrl: (url: string) => ipcRenderer.invoke('openExternalUrl', url),
relaunch: () => ipcRenderer.invoke('relaunch'),
openAppDirectory: () => ipcRenderer.invoke('openAppDirectory'),
deleteFile: (filePath: string) => ipcRenderer.invoke('deleteFile', filePath),
isDirectory: (filePath: string) =>
ipcRenderer.invoke('isDirectory', filePath),
getUserSpace: () => ipcRenderer.invoke('getUserSpace'),
readFile: (path: string) => ipcRenderer.invoke('readFile', path),
writeFile: (path: string, data: string) =>
ipcRenderer.invoke('writeFile', path, data),
listFiles: (path: string) => ipcRenderer.invoke('listFiles', path),
appendFile: (path: string, data: string) =>
ipcRenderer.invoke('appendFile', path, data),
readLineByLine: (path: string) => ipcRenderer.invoke('readLineByLine', path),
mkdir: (path: string) => ipcRenderer.invoke('mkdir', path),
rmdir: (path: string) => ipcRenderer.invoke('rmdir', path),
openFileExplorer: (path: string) => shell.openPath(path),
installRemotePlugin: (pluginName: string) =>
ipcRenderer.invoke('installRemotePlugin', pluginName),
downloadFile: (url: string, path: string) =>
ipcRenderer.invoke('downloadFile', url, path),
pauseDownload: (fileName: string) =>
ipcRenderer.invoke('pauseDownload', fileName),
resumeDownload: (fileName: string) =>
ipcRenderer.invoke('resumeDownload', fileName),
abortDownload: (fileName: string) =>
ipcRenderer.invoke('abortDownload', fileName),
onFileDownloadUpdate: (callback: any) =>
ipcRenderer.on('FILE_DOWNLOAD_UPDATE', callback),
onFileDownloadError: (callback: any) =>
ipcRenderer.on('FILE_DOWNLOAD_ERROR', callback),
onFileDownloadSuccess: (callback: any) =>
ipcRenderer.on('FILE_DOWNLOAD_COMPLETE', callback),
onAppUpdateDownloadUpdate: (callback: any) =>
ipcRenderer.on('APP_UPDATE_PROGRESS', callback),
onAppUpdateDownloadError: (callback: any) =>
ipcRenderer.on('APP_UPDATE_ERROR', callback),
onAppUpdateDownloadSuccess: (callback: any) =>
ipcRenderer.on('APP_UPDATE_COMPLETE', callback),
}) })

4
electron/utils/path.ts Normal file
View File

@ -0,0 +1,4 @@
import { join } from 'path'
import { app } from 'electron'
export const userSpacePath = join(app.getPath('home'), 'jan')

View File

@ -1,19 +1,14 @@
{ {
"name": "@janhq/assistant-plugin", "name": "@janhq/assistant-extension",
"version": "1.0.9", "version": "1.0.0",
"description": "Assistant", "description": "Assistant extension",
"icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/cpu-chip.svg",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/module.js", "module": "dist/module.js",
"author": "Jan <service@jan.ai>", "author": "Jan <service@jan.ai>",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"url": "/plugins/assistant-plugin/index.js",
"activationPoints": [
"init"
],
"scripts": { "scripts": {
"build": "tsc -b . && webpack --config webpack.config.js", "build": "tsc -b . && webpack --config webpack.config.js",
"build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/core/pre-install" "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/pre-install"
}, },
"devDependencies": { "devDependencies": {
"rimraf": "^3.0.2", "rimraf": "^3.0.2",

View File

@ -1,23 +1,23 @@
import { PluginType, fs, Assistant } from "@janhq/core"; import { ExtensionType, fs, Assistant } from "@janhq/core";
import { AssistantPlugin } from "@janhq/core/lib/plugins"; import { AssistantExtension } from "@janhq/core";
import { join } from "path"; import { join } from "path";
export default class JanAssistantPlugin implements AssistantPlugin { export default class JanAssistantExtension implements AssistantExtension {
private static readonly _homeDir = "assistants"; private static readonly _homeDir = "assistants";
type(): PluginType { type(): ExtensionType {
return PluginType.Assistant; return ExtensionType.Assistant;
} }
onLoad(): void { onLoad(): void {
// making the assistant directory // making the assistant directory
fs.mkdir(JanAssistantPlugin._homeDir).then(() => { fs.mkdir(JanAssistantExtension._homeDir).then(() => {
this.createJanAssistant(); this.createJanAssistant();
}); });
} }
/** /**
* Called when the plugin is unloaded. * Called when the extension is unloaded.
*/ */
onUnload(): void {} onUnload(): void {}
@ -26,7 +26,7 @@ export default class JanAssistantPlugin implements AssistantPlugin {
// TODO: check if the directory already exists, then ignore creation for now // TODO: check if the directory already exists, then ignore creation for now
const assistantDir = join(JanAssistantPlugin._homeDir, assistant.id); const assistantDir = join(JanAssistantExtension._homeDir, assistant.id);
await fs.mkdir(assistantDir); await fs.mkdir(assistantDir);
// store the assistant metadata json // store the assistant metadata json
@ -46,10 +46,10 @@ export default class JanAssistantPlugin implements AssistantPlugin {
// get all the assistant metadata json // get all the assistant metadata json
const results: Assistant[] = []; const results: Assistant[] = [];
const allFileName: string[] = await fs.listFiles( const allFileName: string[] = await fs.listFiles(
JanAssistantPlugin._homeDir JanAssistantExtension._homeDir
); );
for (const fileName of allFileName) { for (const fileName of allFileName) {
const filePath = join(JanAssistantPlugin._homeDir, fileName); const filePath = join(JanAssistantExtension._homeDir, fileName);
const isDirectory = await fs.isDirectory(filePath); const isDirectory = await fs.isDirectory(filePath);
if (!isDirectory) { if (!isDirectory) {
// if not a directory, ignore // if not a directory, ignore
@ -81,7 +81,7 @@ export default class JanAssistantPlugin implements AssistantPlugin {
} }
// remove the directory // remove the directory
const assistantDir = join(JanAssistantPlugin._homeDir, assistant.id); const assistantDir = join(JanAssistantExtension._homeDir, assistant.id);
await fs.rmdir(assistantDir); await fs.rmdir(assistantDir);
return Promise.resolve(); return Promise.resolve();
} }

View File

@ -1,16 +1,13 @@
{ {
"name": "@janhq/conversational-json", "name": "@janhq/conversational-extension",
"version": "1.0.0", "version": "1.0.0",
"description": "Conversational Plugin - Stores jan app conversations as JSON", "description": "Conversational Extension - Stores jan app threads and messages in JSON files",
"main": "dist/index.js", "main": "dist/index.js",
"author": "Jan <service@jan.ai>", "author": "Jan <service@jan.ai>",
"license": "MIT", "license": "MIT",
"activationPoints": [
"init"
],
"scripts": { "scripts": {
"build": "tsc -b . && webpack --config webpack.config.js", "build": "tsc -b . && webpack --config webpack.config.js",
"build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/core/pre-install" "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/pre-install"
}, },
"exports": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",

View File

@ -1,37 +1,37 @@
import { PluginType, fs } from '@janhq/core' import { ExtensionType, fs } from '@janhq/core'
import { ConversationalPlugin } from '@janhq/core/lib/plugins' import { ConversationalExtension } from '@janhq/core'
import { Thread, ThreadMessage } from '@janhq/core/lib/types' import { Thread, ThreadMessage } from '@janhq/core'
import { join } from 'path' import { join } from 'path'
/** /**
* JSONConversationalPlugin is a ConversationalPlugin implementation that provides * JSONConversationalExtension is a ConversationalExtension implementation that provides
* functionality for managing threads. * functionality for managing threads.
*/ */
export default class JSONConversationalPlugin implements ConversationalPlugin { export default class JSONConversationalExtension implements ConversationalExtension {
private static readonly _homeDir = 'threads' private static readonly _homeDir = 'threads'
private static readonly _threadInfoFileName = 'thread.json' private static readonly _threadInfoFileName = 'thread.json'
private static readonly _threadMessagesFileName = 'messages.jsonl' private static readonly _threadMessagesFileName = 'messages.jsonl'
/** /**
* Returns the type of the plugin. * Returns the type of the extension.
*/ */
type(): PluginType { type(): ExtensionType {
return PluginType.Conversational return ExtensionType.Conversational
} }
/** /**
* Called when the plugin is loaded. * Called when the extension is loaded.
*/ */
onLoad() { onLoad() {
fs.mkdir(JSONConversationalPlugin._homeDir) fs.mkdir(JSONConversationalExtension._homeDir)
console.debug('JSONConversationalPlugin loaded') console.debug('JSONConversationalExtension loaded')
} }
/** /**
* Called when the plugin is unloaded. * Called when the extension is unloaded.
*/ */
onUnload() { onUnload() {
console.debug('JSONConversationalPlugin unloaded') console.debug('JSONConversationalExtension unloaded')
} }
/** /**
@ -67,10 +67,10 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
*/ */
async saveThread(thread: Thread): Promise<void> { async saveThread(thread: Thread): Promise<void> {
try { try {
const threadDirPath = join(JSONConversationalPlugin._homeDir, thread.id) const threadDirPath = join(JSONConversationalExtension._homeDir, thread.id)
const threadJsonPath = join( const threadJsonPath = join(
threadDirPath, threadDirPath,
JSONConversationalPlugin._threadInfoFileName JSONConversationalExtension._threadInfoFileName
) )
await fs.mkdir(threadDirPath) await fs.mkdir(threadDirPath)
await fs.writeFile(threadJsonPath, JSON.stringify(thread, null, 2)) await fs.writeFile(threadJsonPath, JSON.stringify(thread, null, 2))
@ -85,18 +85,18 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
* @param threadId The ID of the thread to delete. * @param threadId The ID of the thread to delete.
*/ */
deleteThread(threadId: string): Promise<void> { deleteThread(threadId: string): Promise<void> {
return fs.rmdir(join(JSONConversationalPlugin._homeDir, `${threadId}`)) return fs.rmdir(join(JSONConversationalExtension._homeDir, `${threadId}`))
} }
async addNewMessage(message: ThreadMessage): Promise<void> { async addNewMessage(message: ThreadMessage): Promise<void> {
try { try {
const threadDirPath = join( const threadDirPath = join(
JSONConversationalPlugin._homeDir, JSONConversationalExtension._homeDir,
message.thread_id message.thread_id
) )
const threadMessagePath = join( const threadMessagePath = join(
threadDirPath, threadDirPath,
JSONConversationalPlugin._threadMessagesFileName JSONConversationalExtension._threadMessagesFileName
) )
await fs.mkdir(threadDirPath) await fs.mkdir(threadDirPath)
await fs.appendFile(threadMessagePath, JSON.stringify(message) + '\n') await fs.appendFile(threadMessagePath, JSON.stringify(message) + '\n')
@ -111,10 +111,10 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
messages: ThreadMessage[] messages: ThreadMessage[]
): Promise<void> { ): Promise<void> {
try { try {
const threadDirPath = join(JSONConversationalPlugin._homeDir, threadId) const threadDirPath = join(JSONConversationalExtension._homeDir, threadId)
const threadMessagePath = join( const threadMessagePath = join(
threadDirPath, threadDirPath,
JSONConversationalPlugin._threadMessagesFileName JSONConversationalExtension._threadMessagesFileName
) )
await fs.mkdir(threadDirPath) await fs.mkdir(threadDirPath)
await fs.writeFile( await fs.writeFile(
@ -135,9 +135,9 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
private async readThread(threadDirName: string): Promise<any> { private async readThread(threadDirName: string): Promise<any> {
return fs.readFile( return fs.readFile(
join( join(
JSONConversationalPlugin._homeDir, JSONConversationalExtension._homeDir,
threadDirName, threadDirName,
JSONConversationalPlugin._threadInfoFileName JSONConversationalExtension._threadInfoFileName
) )
) )
} }
@ -148,12 +148,12 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
*/ */
private async getValidThreadDirs(): Promise<string[]> { private async getValidThreadDirs(): Promise<string[]> {
const fileInsideThread: string[] = await fs.listFiles( const fileInsideThread: string[] = await fs.listFiles(
JSONConversationalPlugin._homeDir JSONConversationalExtension._homeDir
) )
const threadDirs: string[] = [] const threadDirs: string[] = []
for (let i = 0; i < fileInsideThread.length; i++) { for (let i = 0; i < fileInsideThread.length; i++) {
const path = join(JSONConversationalPlugin._homeDir, fileInsideThread[i]) const path = join(JSONConversationalExtension._homeDir, fileInsideThread[i])
const isDirectory = await fs.isDirectory(path) const isDirectory = await fs.isDirectory(path)
if (!isDirectory) { if (!isDirectory) {
console.debug(`Ignore ${path} because it is not a directory`) console.debug(`Ignore ${path} because it is not a directory`)
@ -161,7 +161,7 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
} }
const isHavingThreadInfo = (await fs.listFiles(path)).includes( const isHavingThreadInfo = (await fs.listFiles(path)).includes(
JSONConversationalPlugin._threadInfoFileName JSONConversationalExtension._threadInfoFileName
) )
if (!isHavingThreadInfo) { if (!isHavingThreadInfo) {
console.debug(`Ignore ${path} because it does not have thread info`) console.debug(`Ignore ${path} because it does not have thread info`)
@ -175,20 +175,20 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
async getAllMessages(threadId: string): Promise<ThreadMessage[]> { async getAllMessages(threadId: string): Promise<ThreadMessage[]> {
try { try {
const threadDirPath = join(JSONConversationalPlugin._homeDir, threadId) const threadDirPath = join(JSONConversationalExtension._homeDir, threadId)
const isDir = await fs.isDirectory(threadDirPath) const isDir = await fs.isDirectory(threadDirPath)
if (!isDir) { if (!isDir) {
throw Error(`${threadDirPath} is not directory`) throw Error(`${threadDirPath} is not directory`)
} }
const files: string[] = await fs.listFiles(threadDirPath) const files: string[] = await fs.listFiles(threadDirPath)
if (!files.includes(JSONConversationalPlugin._threadMessagesFileName)) { if (!files.includes(JSONConversationalExtension._threadMessagesFileName)) {
throw Error(`${threadDirPath} not contains message file`) throw Error(`${threadDirPath} not contains message file`)
} }
const messageFilePath = join( const messageFilePath = join(
threadDirPath, threadDirPath,
JSONConversationalPlugin._threadMessagesFileName JSONConversationalExtension._threadMessagesFileName
) )
const result = await fs.readLineByLine(messageFilePath) const result = await fs.readLineByLine(messageFilePath)

View File

@ -1,25 +1,20 @@
{ {
"name": "@janhq/inference-plugin", "name": "@janhq/inference-extension",
"version": "1.0.21", "version": "1.0.0",
"description": "Inference Plugin, powered by @janhq/nitro, bring a high-performance Llama model inference in pure C++.", "description": "Inference Extension, powered by @janhq/nitro, bring a high-performance Llama model inference in pure C++.",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/module.js", "module": "dist/module.js",
"author": "Jan <service@jan.ai>", "author": "Jan <service@jan.ai>",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"supportCloudNative": true,
"url": "/plugins/inference-plugin/index.js",
"activationPoints": [
"init"
],
"scripts": { "scripts": {
"build": "tsc -b . && webpack --config webpack.config.js", "build": "tsc -b . && webpack --config webpack.config.js",
"downloadnitro:linux": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64.tar.gz -e --strip 1 -o ./nitro/linux-cpu && chmod +x ./nitro/linux-cpu/nitro && chmod +x ./nitro/linux-start.sh && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64-cuda.tar.gz -e --strip 1 -o ./nitro/linux-cuda && chmod +x ./nitro/linux-cuda/nitro && chmod +x ./nitro/linux-start.sh", "downloadnitro:linux": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64.tar.gz -e --strip 1 -o ./nitro/linux-cpu && chmod +x ./nitro/linux-cpu/nitro && chmod +x ./nitro/linux-start.sh && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64-cuda.tar.gz -e --strip 1 -o ./nitro/linux-cuda && chmod +x ./nitro/linux-cuda/nitro && chmod +x ./nitro/linux-start.sh",
"downloadnitro:darwin": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-arm64.tar.gz -e --strip 1 -o ./nitro/mac-arm64 && chmod +x ./nitro/mac-arm64/nitro && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-amd64.tar.gz -e --strip 1 -o ./nitro/mac-x64 && chmod +x ./nitro/mac-x64/nitro", "downloadnitro:darwin": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-arm64.tar.gz -e --strip 1 -o ./nitro/mac-arm64 && chmod +x ./nitro/mac-arm64/nitro && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-amd64.tar.gz -e --strip 1 -o ./nitro/mac-x64 && chmod +x ./nitro/mac-x64/nitro",
"downloadnitro:win32": "download.bat", "downloadnitro:win32": "download.bat",
"downloadnitro": "run-script-os", "downloadnitro": "run-script-os",
"build:publish:darwin": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && ../../.github/scripts/auto-sign.sh && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/core/pre-install", "build:publish:darwin": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && ../../.github/scripts/auto-sign.sh && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/pre-install",
"build:publish:win32": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/core/pre-install", "build:publish:win32": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/pre-install",
"build:publish:linux": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/core/pre-install", "build:publish:linux": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/pre-install",
"build:publish": "run-script-os" "build:publish": "run-script-os"
}, },
"exports": { "exports": {

View File

@ -1,9 +1,9 @@
/** /**
* @file This file exports a class that implements the InferencePlugin interface from the @janhq/core package. * @file This file exports a class that implements the InferenceExtension interface from the @janhq/core package.
* The class provides methods for initializing and stopping a model, and for making inference requests. * The class provides methods for initializing and stopping a model, and for making inference requests.
* It also subscribes to events emitted by the @janhq/core package and handles new message requests. * It also subscribes to events emitted by the @janhq/core package and handles new message requests.
* @version 1.0.0 * @version 1.0.0
* @module inference-plugin/src/index * @module inference-extension/src/index
*/ */
import { import {
@ -13,40 +13,40 @@ import {
MessageRequest, MessageRequest,
MessageStatus, MessageStatus,
ModelSettingParams, ModelSettingParams,
PluginType, ExtensionType,
ThreadContent, ThreadContent,
ThreadMessage, ThreadMessage,
events, events,
executeOnMain, executeOnMain,
getUserSpace, getUserSpace,
} from "@janhq/core"; } from "@janhq/core";
import { InferencePlugin } from "@janhq/core/lib/plugins"; import { InferenceExtension } from "@janhq/core";
import { requestInference } from "./helpers/sse"; import { requestInference } from "./helpers/sse";
import { ulid } from "ulid"; import { ulid } from "ulid";
import { join } from "path"; import { join } from "path";
/** /**
* A class that implements the InferencePlugin interface from the @janhq/core package. * A class that implements the InferenceExtension interface from the @janhq/core package.
* The class provides methods for initializing and stopping a model, and for making inference requests. * The class provides methods for initializing and stopping a model, and for making inference requests.
* It also subscribes to events emitted by the @janhq/core package and handles new message requests. * It also subscribes to events emitted by the @janhq/core package and handles new message requests.
*/ */
export default class JanInferencePlugin implements InferencePlugin { export default class JanInferenceExtension implements InferenceExtension {
controller = new AbortController(); controller = new AbortController();
isCancelled = false; isCancelled = false;
/** /**
* Returns the type of the plugin. * Returns the type of the extension.
* @returns {PluginType} The type of the plugin. * @returns {ExtensionType} The type of the extension.
*/ */
type(): PluginType { type(): ExtensionType {
return PluginType.Inference; return ExtensionType.Inference;
} }
/** /**
* Subscribes to events emitted by the @janhq/core package. * Subscribes to events emitted by the @janhq/core package.
*/ */
onLoad(): void { onLoad(): void {
events.on(EventName.OnNewMessageRequest, (data) => events.on(EventName.OnMessageSent, (data) =>
JanInferencePlugin.handleMessageRequest(data, this) JanInferenceExtension.handleMessageRequest(data, this)
); );
} }
@ -131,7 +131,7 @@ export default class JanInferencePlugin implements InferencePlugin {
*/ */
private static async handleMessageRequest( private static async handleMessageRequest(
data: MessageRequest, data: MessageRequest,
instance: JanInferencePlugin instance: JanInferenceExtension
) { ) {
const timestamp = Date.now(); const timestamp = Date.now();
const message: ThreadMessage = { const message: ThreadMessage = {
@ -145,7 +145,7 @@ export default class JanInferencePlugin implements InferencePlugin {
updated: timestamp, updated: timestamp,
object: "thread.message", object: "thread.message",
}; };
events.emit(EventName.OnNewMessageResponse, message); events.emit(EventName.OnMessageResponse, message);
console.log(JSON.stringify(data, null, 2)); console.log(JSON.stringify(data, null, 2));
instance.isCancelled = false; instance.isCancelled = false;
@ -161,11 +161,11 @@ export default class JanInferencePlugin implements InferencePlugin {
}, },
}; };
message.content = [messageContent]; message.content = [messageContent];
events.emit(EventName.OnMessageResponseUpdate, message); events.emit(EventName.OnMessageUpdate, message);
}, },
complete: async () => { complete: async () => {
message.status = MessageStatus.Ready; message.status = MessageStatus.Ready;
events.emit(EventName.OnMessageResponseFinished, message); events.emit(EventName.OnMessageUpdate, message);
}, },
error: async (err) => { error: async (err) => {
const messageContent: ThreadContent = { const messageContent: ThreadContent = {
@ -177,7 +177,7 @@ export default class JanInferencePlugin implements InferencePlugin {
}; };
message.content = [messageContent]; message.content = [messageContent];
message.status = MessageStatus.Ready; message.status = MessageStatus.Ready;
events.emit(EventName.OnMessageResponseUpdate, message); events.emit(EventName.OnMessageUpdate, message);
}, },
}); });
} }

View File

@ -1,20 +1,14 @@
{ {
"name": "@janhq/model-plugin", "name": "@janhq/model-extension",
"version": "1.0.13", "version": "1.0.13",
"description": "Model Management Plugin provides model exploration and seamless downloads", "description": "Model Management Extension provides model exploration and seamless downloads",
"icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/queue-list.svg",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/module.js", "module": "dist/module.js",
"author": "Jan <service@jan.ai>", "author": "Jan <service@jan.ai>",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"supportCloudNative": true,
"url": "/plugins/model-plugin/index.js",
"activationPoints": [
"init"
],
"scripts": { "scripts": {
"build": "tsc -b . && webpack --config webpack.config.js", "build": "tsc -b . && webpack --config webpack.config.js",
"build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/core/pre-install" "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/pre-install"
}, },
"devDependencies": { "devDependencies": {
"cpx": "^1.5.0", "cpx": "^1.5.0",

View File

@ -1,38 +1,37 @@
import { PluginType, fs, downloadFile, abortDownload } from '@janhq/core' import { ExtensionType, fs, downloadFile, abortDownload } from '@janhq/core'
import { ModelPlugin } from '@janhq/core/lib/plugins' import { ModelExtension, Model, ModelCatalog } from '@janhq/core'
import { Model, ModelCatalog } from '@janhq/core/lib/types'
import { parseToModel } from './helpers/modelParser' import { parseToModel } from './helpers/modelParser'
import { join } from 'path' import { join } from 'path'
/** /**
* A plugin for managing machine learning models. * A extension for models
*/ */
export default class JanModelPlugin implements ModelPlugin { export default class JanModelExtension implements ModelExtension {
private static readonly _homeDir = 'models' private static readonly _homeDir = 'models'
private static readonly _modelMetadataFileName = 'model.json' private static readonly _modelMetadataFileName = 'model.json'
/** /**
* Implements type from JanPlugin. * Implements type from JanExtension.
* @override * @override
* @returns The type of the plugin. * @returns The type of the extension.
*/ */
type(): PluginType { type(): ExtensionType {
return PluginType.Model return ExtensionType.Model
} }
/** /**
* Called when the plugin is loaded. * Called when the extension is loaded.
* @override * @override
*/ */
onLoad(): void { onLoad(): void {
/** Cloud Native /** Cloud Native
* TODO: Fetch all downloading progresses? * TODO: Fetch all downloading progresses?
**/ **/
fs.mkdir(JanModelPlugin._homeDir) fs.mkdir(JanModelExtension._homeDir)
} }
/** /**
* Called when the plugin is unloaded. * Called when the extension is unloaded.
* @override * @override
*/ */
onUnload(): void {} onUnload(): void {}
@ -44,7 +43,7 @@ export default class JanModelPlugin implements ModelPlugin {
*/ */
async downloadModel(model: Model): Promise<void> { async downloadModel(model: Model): Promise<void> {
// create corresponding directory // create corresponding directory
const directoryPath = join(JanModelPlugin._homeDir, model.id) const directoryPath = join(JanModelExtension._homeDir, model.id)
await fs.mkdir(directoryPath) await fs.mkdir(directoryPath)
// path to model binary // path to model binary
@ -58,9 +57,9 @@ export default class JanModelPlugin implements ModelPlugin {
* @returns {Promise<void>} A promise that resolves when the download has been cancelled. * @returns {Promise<void>} A promise that resolves when the download has been cancelled.
*/ */
async cancelModelDownload(modelId: string): Promise<void> { async cancelModelDownload(modelId: string): Promise<void> {
return abortDownload(join(JanModelPlugin._homeDir, modelId, modelId)).then( return abortDownload(join(JanModelExtension._homeDir, modelId, modelId)).then(
() => { () => {
fs.rmdir(join(JanModelPlugin._homeDir, modelId)) fs.rmdir(join(JanModelExtension._homeDir, modelId))
} }
) )
} }
@ -72,7 +71,7 @@ export default class JanModelPlugin implements ModelPlugin {
*/ */
async deleteModel(modelId: string): Promise<void> { async deleteModel(modelId: string): Promise<void> {
try { try {
const dirPath = join(JanModelPlugin._homeDir, modelId) const dirPath = join(JanModelExtension._homeDir, modelId)
await fs.rmdir(dirPath) await fs.rmdir(dirPath)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -86,9 +85,9 @@ export default class JanModelPlugin implements ModelPlugin {
*/ */
async saveModel(model: Model): Promise<void> { async saveModel(model: Model): Promise<void> {
const jsonFilePath = join( const jsonFilePath = join(
JanModelPlugin._homeDir, JanModelExtension._homeDir,
model.id, model.id,
JanModelPlugin._modelMetadataFileName JanModelExtension._modelMetadataFileName
) )
try { try {
@ -104,9 +103,9 @@ export default class JanModelPlugin implements ModelPlugin {
*/ */
async getDownloadedModels(): Promise<Model[]> { async getDownloadedModels(): Promise<Model[]> {
const results: Model[] = [] const results: Model[] = []
const allDirs: string[] = await fs.listFiles(JanModelPlugin._homeDir) const allDirs: string[] = await fs.listFiles(JanModelExtension._homeDir)
for (const dir of allDirs) { for (const dir of allDirs) {
const modelDirPath = join(JanModelPlugin._homeDir, dir) const modelDirPath = join(JanModelExtension._homeDir, dir)
const isModelDir = await fs.isDirectory(modelDirPath) const isModelDir = await fs.isDirectory(modelDirPath)
if (!isModelDir) { if (!isModelDir) {
// if not a directory, ignore // if not a directory, ignore
@ -114,7 +113,7 @@ export default class JanModelPlugin implements ModelPlugin {
} }
const jsonFiles: string[] = (await fs.listFiles(modelDirPath)).filter( const jsonFiles: string[] = (await fs.listFiles(modelDirPath)).filter(
(fileName: string) => fileName === JanModelPlugin._modelMetadataFileName (fileName: string) => fileName === JanModelExtension._modelMetadataFileName
) )
for (const json of jsonFiles) { for (const json of jsonFiles) {

View File

@ -1,20 +1,14 @@
{ {
"name": "@janhq/monitoring-plugin", "name": "@janhq/monitoring-extension",
"version": "1.0.9", "version": "1.0.9",
"description": "Utilizing systeminformation, it provides essential System and OS information retrieval", "description": "Utilizing systeminformation, it provides essential System and OS information retrieval",
"icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/cpu-chip.svg",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/module.js", "module": "dist/module.js",
"author": "Jan <service@jan.ai>", "author": "Jan <service@jan.ai>",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"supportCloudNative": true,
"url": "/plugins/monitoring-plugin/index.js",
"activationPoints": [
"init"
],
"scripts": { "scripts": {
"build": "tsc -b . && webpack --config webpack.config.js", "build": "tsc -b . && webpack --config webpack.config.js",
"build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/core/pre-install" "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/pre-install"
}, },
"devDependencies": { "devDependencies": {
"rimraf": "^3.0.2", "rimraf": "^3.0.2",

View File

@ -1,27 +1,27 @@
import { PluginType } from "@janhq/core"; import { ExtensionType } from "@janhq/core";
import { MonitoringPlugin } from "@janhq/core/lib/plugins"; import { MonitoringExtension } from "@janhq/core";
import { executeOnMain } from "@janhq/core"; import { executeOnMain } from "@janhq/core";
/** /**
* JanMonitoringPlugin is a plugin that provides system monitoring functionality. * JanMonitoringExtension is a extension that provides system monitoring functionality.
* It implements the MonitoringPlugin interface from the @janhq/core package. * It implements the MonitoringExtension interface from the @janhq/core package.
*/ */
export default class JanMonitoringPlugin implements MonitoringPlugin { export default class JanMonitoringExtension implements MonitoringExtension {
/** /**
* Returns the type of the plugin. * Returns the type of the extension.
* @returns The PluginType.SystemMonitoring value. * @returns The ExtensionType.SystemMonitoring value.
*/ */
type(): PluginType { type(): ExtensionType {
return PluginType.SystemMonitoring; return ExtensionType.SystemMonitoring;
} }
/** /**
* Called when the plugin is loaded. * Called when the extension is loaded.
*/ */
onLoad(): void {} onLoad(): void {}
/** /**
* Called when the plugin is unloaded. * Called when the extension is unloaded.
*/ */
onUnload(): void {} onUnload(): void {}

View File

@ -35,7 +35,7 @@
"build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"", "build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"",
"build:electron": "yarn workspace jan build", "build:electron": "yarn workspace jan build",
"build:electron:test": "yarn workspace jan build:test", "build:electron:test": "yarn workspace jan build:test",
"build:plugins": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install && npm run build:publish\" \"cd ./plugins/model-plugin && npm install && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run build:publish\" \"cd ./plugins/assistant-plugin && npm install && npm run build:publish\"", "build:extensions": "rimraf ./electron/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./extensions/conversational-extension && npm install && npm run build:publish\" \"cd ./extensions/inference-extension && npm install && npm run build:publish\" \"cd ./extensions/model-extension && npm install && npm run build:publish\" \"cd ./extensions/monitoring-extension && npm install && npm run build:publish\" \"cd ./extensions/assistant-extension && npm install && npm run build:publish\"",
"build:test": "yarn build:web && yarn workspace jan build:test", "build:test": "yarn build:web && yarn workspace jan build:test",
"build": "yarn build:web && yarn workspace jan build", "build": "yarn build:web && yarn workspace jan build",
"build:publish": "yarn build:web && yarn workspace jan build:publish" "build:publish": "yarn build:web && yarn workspace jan build:publish"

View File

@ -3,7 +3,7 @@ import { Fragment, useEffect, useState } from 'react'
import { Listbox, Transition } from '@headlessui/react' import { Listbox, Transition } from '@headlessui/react'
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid' import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid'
import { Model } from '@janhq/core/lib/types' import { Model } from '@janhq/core'
import { atom, useSetAtom } from 'jotai' import { atom, useSetAtom } from 'jotai'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'

View File

@ -1,7 +1,7 @@
import { Fragment } from 'react' import { Fragment } from 'react'
import { PluginType } from '@janhq/core' import { ExtensionType } from '@janhq/core'
import { ModelPlugin } from '@janhq/core/lib/plugins' import { ModelExtension } from '@janhq/core'
import { import {
Progress, Progress,
Modal, Modal,
@ -18,8 +18,8 @@ import { useDownloadState } from '@/hooks/useDownloadState'
import { formatDownloadPercentage } from '@/utils/converter' import { formatDownloadPercentage } from '@/utils/converter'
import { extensionManager } from '@/extension'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
import { pluginManager } from '@/plugin'
export default function DownloadingState() { export default function DownloadingState() {
const { downloadStates } = useDownloadState() const { downloadStates } = useDownloadState()
@ -81,8 +81,8 @@ export default function DownloadingState() {
(e) => e.id === item?.fileName (e) => e.id === item?.fileName
) )
if (!model) return if (!model) return
pluginManager extensionManager
.get<ModelPlugin>(PluginType.Model) .get<ModelExtension>(ExtensionType.Model)
?.cancelModelDownload(item.modelId) ?.cancelModelDownload(item.modelId)
} }
}} }}

View File

@ -1,8 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { PluginType } from '@janhq/core' import { ModelExtension, ExtensionType } from '@janhq/core'
import { ModelPlugin } from '@janhq/core/lib/plugins' import { Model } from '@janhq/core'
import { Model } from '@janhq/core/lib/types'
import { import {
Modal, Modal,
@ -21,8 +20,8 @@ import { useDownloadState } from '@/hooks/useDownloadState'
import { formatDownloadPercentage } from '@/utils/converter' import { formatDownloadPercentage } from '@/utils/converter'
import { extensionManager } from '@/extension'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
import { pluginManager } from '@/plugin'
type Props = { type Props = {
suitableModel: Model suitableModel: Model
@ -77,8 +76,8 @@ export default function ModalCancelDownload({
(e) => e.id === downloadState?.fileName (e) => e.id === downloadState?.fileName
) )
if (!model) return if (!model) return
pluginManager extensionManager
.get<ModelPlugin>(PluginType.Model) .get<ModelExtension>(ExtensionType.Model)
?.cancelModelDownload(downloadState.modelId) ?.cancelModelDownload(downloadState.modelId)
} }
}} }}

View File

@ -5,35 +5,27 @@ import {
events, events,
EventName, EventName,
ThreadMessage, ThreadMessage,
PluginType, ExtensionType,
MessageStatus, MessageStatus,
} from '@janhq/core' } from '@janhq/core'
import { ConversationalPlugin, ModelPlugin } from '@janhq/core/lib/plugins' import { ConversationalExtension } from '@janhq/core'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { useDownloadState } from '@/hooks/useDownloadState' import { extensionManager } from '@/extension'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import { import {
addNewMessageAtom, addNewMessageAtom,
updateMessageAtom, updateMessageAtom,
} from '@/helpers/atoms/ChatMessage.atom' } from '@/helpers/atoms/ChatMessage.atom'
import { import {
updateConversationWaitingForResponseAtom, updateThreadWaitingForResponseAtom,
threadsAtom, threadsAtom,
} from '@/helpers/atoms/Conversation.atom' } from '@/helpers/atoms/Conversation.atom'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
import { pluginManager } from '@/plugin'
export default function EventHandler({ children }: { children: ReactNode }) { export default function EventHandler({ children }: { children: ReactNode }) {
const addNewMessage = useSetAtom(addNewMessageAtom) const addNewMessage = useSetAtom(addNewMessageAtom)
const updateMessage = useSetAtom(updateMessageAtom) const updateMessage = useSetAtom(updateMessageAtom)
const { setDownloadState, setDownloadStateSuccess } = useDownloadState() const updateThreadWaiting = useSetAtom(updateThreadWaitingForResponseAtom)
const { downloadedModels, setDownloadedModels } = useGetDownloadedModels()
const updateConvWaiting = useSetAtom(updateConversationWaitingForResponseAtom)
const models = useAtomValue(downloadingModelsAtom)
const threads = useAtomValue(threadsAtom) const threads = useAtomValue(threadsAtom)
const threadsRef = useRef(threads) const threadsRef = useRef(threads)
@ -50,86 +42,45 @@ export default function EventHandler({ children }: { children: ReactNode }) {
message.id, message.id,
message.thread_id, message.thread_id,
message.content, message.content,
MessageStatus.Pending message.status
) )
} if (message.status === MessageStatus.Ready) {
// Mark the thread as not waiting for response
updateThreadWaiting(message.thread_id, false)
async function handleMessageResponseFinished(message: ThreadMessage) { const thread = threadsRef.current?.find((e) => e.id == message.thread_id)
updateConvWaiting(message.thread_id, false) if (thread) {
const messageContent = message.content[0]?.text.value ?? ''
if (message.id && message.content) { const metadata = {
updateMessage( ...thread.metadata,
message.id, lastMessage: messageContent,
message.thread_id, }
message.content, extensionManager
MessageStatus.Ready .get<ConversationalExtension>(ExtensionType.Conversational)
) ?.saveThread({
} ...thread,
const thread = threadsRef.current?.find((e) => e.id == message.thread_id) metadata,
if (thread) {
const messageContent = message.content[0]?.text.value ?? ''
const metadata = {
...thread.metadata,
lastMessage: messageContent,
}
pluginManager
.get<ConversationalPlugin>(PluginType.Conversational)
?.saveThread({
...thread,
metadata,
})
pluginManager
.get<ConversationalPlugin>(PluginType.Conversational)
?.addNewMessage(message)
}
}
function handleDownloadUpdate(state: any) {
if (!state) return
state.fileName = state.fileName.split('/').pop() ?? ''
setDownloadState(state)
}
function handleDownloadSuccess(state: any) {
if (state && state.fileName && state.success === true) {
state.fileName = state.fileName.split('/').pop() ?? ''
setDownloadStateSuccess(state.fileName)
const model = models.find((e) => e.id === state.fileName)
if (model)
pluginManager
.get<ModelPlugin>(PluginType.Model)
?.saveModel(model)
.then(() => {
setDownloadedModels([...downloadedModels, model])
}) })
extensionManager
.get<ConversationalExtension>(ExtensionType.Conversational)
?.addNewMessage(message)
}
} }
} }
useEffect(() => { useEffect(() => {
if (window.corePlugin.events) { if (window.core.events) {
events.on(EventName.OnNewMessageResponse, handleNewMessageResponse) events.on(EventName.OnMessageResponse, handleNewMessageResponse)
events.on(EventName.OnMessageResponseUpdate, handleMessageResponseUpdate) events.on(EventName.OnMessageUpdate, handleMessageResponseUpdate)
events.on(
EventName.OnMessageResponseFinished,
handleMessageResponseFinished
)
events.on(EventName.OnDownloadUpdate, handleDownloadUpdate)
events.on(EventName.OnDownloadSuccess, handleDownloadSuccess)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
useEffect(() => { useEffect(() => {
return () => { return () => {
events.off(EventName.OnNewMessageResponse, handleNewMessageResponse) events.off(EventName.OnMessageResponse, handleNewMessageResponse)
events.off(EventName.OnMessageResponseUpdate, handleMessageResponseUpdate) events.off(EventName.OnMessageUpdate, handleMessageResponseUpdate)
events.off(
EventName.OnMessageResponseFinished,
handleMessageResponseFinished
)
events.off(EventName.OnDownloadUpdate, handleDownloadUpdate)
events.off(EventName.OnDownloadSuccess, handleDownloadSuccess)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])

View File

@ -2,8 +2,8 @@
import { PropsWithChildren, useEffect, useRef } from 'react' import { PropsWithChildren, useEffect, useRef } from 'react'
import { PluginType } from '@janhq/core' import { ExtensionType } from '@janhq/core'
import { ModelPlugin } from '@janhq/core/lib/plugins' import { ModelExtension } from '@janhq/core'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
@ -14,8 +14,8 @@ import EventHandler from './EventHandler'
import { appDownloadProgress } from './Jotai' import { appDownloadProgress } from './Jotai'
import { extensionManager } from '@/extension/ExtensionManager'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
import { pluginManager } from '@/plugin/PluginManager'
export default function EventListenerWrapper({ children }: PropsWithChildren) { export default function EventListenerWrapper({ children }: PropsWithChildren) {
const setProgress = useSetAtom(appDownloadProgress) const setProgress = useSetAtom(appDownloadProgress)
@ -61,8 +61,8 @@ export default function EventListenerWrapper({ children }: PropsWithChildren) {
const model = modelsRef.current.find((e) => e.id === fileName) const model = modelsRef.current.find((e) => e.id === fileName)
if (model) if (model)
pluginManager extensionManager
.get<ModelPlugin>(PluginType.Model) .get<ModelExtension>(ExtensionType.Model)
?.saveModel(model) ?.saveModel(model)
.then(() => { .then(() => {
setDownloadedModels([...downloadedModelRef.current, model]) setDownloadedModels([...downloadedModelRef.current, model])

View File

@ -14,11 +14,11 @@ import FeatureToggleWrapper from '@/context/FeatureToggle'
import { setupCoreServices } from '@/services/coreService' import { setupCoreServices } from '@/services/coreService'
import { import {
isCorePluginInstalled, isCoreExtensionInstalled,
setupBasePlugins, setupBaseExtensions,
} from '@/services/pluginService' } from '@/services/extensionService'
import { pluginManager } from '@/plugin' import { extensionManager } from '@/extension'
const Providers = (props: PropsWithChildren) => { const Providers = (props: PropsWithChildren) => {
const [setupCore, setSetupCore] = useState(false) const [setupCore, setSetupCore] = useState(false)
@ -26,17 +26,17 @@ const Providers = (props: PropsWithChildren) => {
const { children } = props const { children } = props
async function setupPE() { async function setupExtensions() {
// Register all active plugins with their activation points // Register all active extensions
await pluginManager.registerActive() await extensionManager.registerActive()
setTimeout(async () => { setTimeout(async () => {
if (!isCorePluginInstalled()) { if (!isCoreExtensionInstalled()) {
setupBasePlugins() setupBaseExtensions()
return return
} }
pluginManager.load() extensionManager.load()
setActivated(true) setActivated(true)
}, 500) }, 500)
} }
@ -46,15 +46,15 @@ const Providers = (props: PropsWithChildren) => {
setupCoreServices() setupCoreServices()
setSetupCore(true) setSetupCore(true)
return () => { return () => {
pluginManager.unload() extensionManager.unload()
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
if (setupCore) { if (setupCore) {
// Electron // Electron
if (window && window.coreAPI) { if (window && window.core.api) {
setupPE() setupExtensions()
} else { } else {
// Host // Host
setActivated(true) setActivated(true)

View File

@ -0,0 +1,35 @@
/**
* Extension manifest object.
*/
class Extension {
/** @type {string} Name of the extension. */
name?: string
/** @type {string} The URL of the extension to load. */
url: string
/** @type {boolean} Whether the extension is activated or not. */
active
/** @type {string} Extension's description. */
description
/** @type {string} Extension's version. */
version
constructor(
url: string,
name?: string,
active?: boolean,
description?: string,
version?: string
) {
this.name = name
this.url = url
this.active = active
this.description = description
this.version = version
}
}
export default Extension

View File

@ -0,0 +1,148 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BaseExtension, ExtensionType } from '@janhq/core'
import Extension from './Extension'
/**
* Manages the registration and retrieval of extensions.
*/
export class ExtensionManager {
private extensions = new Map<string, BaseExtension>()
/**
* Registers an extension.
* @param extension - The extension to register.
*/
register<T extends BaseExtension>(name: string, extension: T) {
this.extensions.set(extension.type() ?? name, extension)
}
/**
* Retrieves a extension by its type.
* @param type - The type of the extension to retrieve.
* @returns The extension, if found.
*/
get<T extends BaseExtension>(type: ExtensionType): T | undefined {
return this.extensions.get(type) as T | undefined
}
/**
* Loads all registered extension.
*/
load() {
this.listExtensions().forEach((ext) => {
ext.onLoad()
})
}
/**
* Unloads all registered extensions.
*/
unload() {
this.listExtensions().forEach((ext) => {
ext.onUnload()
})
}
/**
* Retrieves a list of all registered extensions.
* @returns An array of extensions.
*/
listExtensions() {
return [...this.extensions.values()]
}
/**
* Retrieves a list of all registered extensions.
* @returns An array of extensions.
*/
async getActive(): Promise<Extension[]> {
const res = await window.core.api?.getActive()
if (!res || !Array.isArray(res)) return []
const extensions: Extension[] = res.map(
(ext: any) =>
new Extension(
ext.url,
ext.name,
ext.active,
ext.description,
ext.version
)
)
return extensions
}
/**
* Register a extension with its class.
* @param {Extension} extension extension object as provided by the main process.
* @returns {void}
*/
async activateExtension(extension: Extension) {
// Import class
await import(/* webpackIgnore: true */ extension.url).then(
(extensionClass) => {
// Register class if it has a default export
if (
typeof extensionClass.default === 'function' &&
extensionClass.default.prototype
) {
this.register(
extension.name ?? extension.url,
new extensionClass.default()
)
}
}
)
}
/**
* Registers all active extensions.
* @returns {void}
*/
async registerActive() {
// Get active extensions
const activeExtensions = await this.getActive()
// Activate all
await Promise.all(
activeExtensions.map((ext: Extension) => this.activateExtension(ext))
)
}
/**
* Install a new extension.
* @param {Array.<installOptions | string>} extensions A list of NPM specifiers, or installation configuration objects.
* @returns {Promise.<Array.<Extension> | false>} extension as defined by the main process. Has property cancelled set to true if installation was cancelled in the main process.
*/
async install(extensions: any[]) {
if (typeof window === 'undefined') {
return
}
const res = await window.core.api?.install(extensions)
if (res.cancelled) return false
return res.map(async (ext: any) => {
const extension = new Extension(ext.name, ext.url, ext.active)
await this.activateExtension(extension)
return extension
})
}
/**
* Uninstall provided extensions
* @param {Array.<string>} extensions List of names of extensions to uninstall.
* @param {boolean} reload Whether to reload all renderers after updating the extensions.
* @returns {Promise.<boolean>} Whether uninstalling the extensions was successful.
*/
uninstall(extensions: string[], reload = true) {
if (typeof window === 'undefined') {
return
}
return window.core.api?.uninstall(extensions, reload)
}
}
/**
* The singleton instance of the ExtensionManager.
*/
export const extensionManager = new ExtensionManager()

1
web/extension/index.ts Normal file
View File

@ -0,0 +1 @@
export { extensionManager } from './ExtensionManager'

View File

@ -29,7 +29,7 @@ export const activeThreadStateAtom = atom<ThreadState | undefined>((get) => {
return get(threadStatesAtom)[activeConvoId] return get(threadStatesAtom)[activeConvoId]
}) })
export const updateConversationWaitingForResponseAtom = atom( export const updateThreadWaitingForResponseAtom = atom(
null, null,
(get, set, conversationId: string, waitingForResponse: boolean) => { (get, set, conversationId: string, waitingForResponse: boolean) => {
const currentState = { ...get(threadStatesAtom) } const currentState = { ...get(threadStatesAtom) }

Some files were not shown because too many files have changed in this diff Show More