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
*.log
plugin-core/lib
core/lib/**
# Nitro binary files
plugins/inference-plugin/nitro/*/nitro
plugins/inference-plugin/nitro/*/*.exe
plugins/inference-plugin/nitro/*/*.dll
plugins/inference-plugin/nitro/*/*.metal
extensions/inference-extension/nitro/*/nitro
extensions/inference-extension/nitro/*/*.exe
extensions/inference-extension/nitro/*/*.dll
extensions/inference-extension/nitro/*/*.metal

View File

@ -12,14 +12,14 @@ else
cd uikit && yarn install && yarn build
endif
# Installs yarn dependencies and builds core and plugins
# Installs yarn dependencies and builds core and extensions
install-and-build: build-uikit
ifeq ($(OS),Windows_NT)
yarn config set network-timeout 300000
endif
yarn build:core
yarn install
yarn build:plugins
yarn build:extensions
dev: install-and-build
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.
In this step, there are a few notification about installing base plugin, just click `OK` and `Next` to continue.
### For production build

View File

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

View File

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

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 args arguments to pass to the function
* @returns Promise<any>
*
*/
const executeOnMain: (
plugin: string,
extension: string,
method: string,
...args: any[]
) => Promise<any> = (plugin, method, ...args) =>
window.coreAPI?.invokePluginFunc(plugin, method, ...args)
) => Promise<any> = (extension, method, ...args) =>
window.core?.api?.invokeExtensionFunc(extension, method, ...args);
/**
* 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> = (
url,
fileName
) => window.coreAPI?.downloadFile(url, fileName);
) => window.core?.api?.downloadFile(url, fileName);
/**
* 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.
*/
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.
* 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.
*/
const appDataPath: () => Promise<any> = () => window.coreAPI?.appDataPath();
const appDataPath: () => Promise<any> = () => window.core.api?.appDataPath();
/**
* Gets 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.
@ -52,7 +52,7 @@ const getUserSpace = (): Promise<string> => window.coreAPI?.getUserSpace();
* @returns {Promise<any>} A promise that resolves when the file explorer is opened.
*/
const openFileExplorer: (path: string) => Promise<any> = (path) =>
window.coreAPI?.openFileExplorer(path);
window.core.api?.openFileExplorer(path);
/**
* 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.
*/
export enum EventName {
OnNewConversation = "onNewConversation",
OnNewMessageRequest = "onNewMessageRequest",
OnNewMessageResponse = "onNewMessageResponse",
OnMessageResponseUpdate = "onMessageResponseUpdate",
OnMessageResponseFinished = "onMessageResponseFinished",
OnDownloadUpdate = "onDownloadUpdate",
OnDownloadSuccess = "onDownloadSuccess",
OnDownloadError = "onDownloadError",
/** The `OnMessageSent` event is emitted when a message is sent. */
OnMessageSent = "OnMessageSent",
/** The `OnMessageResponse` event is emitted when a message is received. */
OnMessageResponse = "OnMessageResponse",
/** The `OnMessageUpdate` event is emitted when a message is updated. */
OnMessageUpdate = "OnMessageUpdate",
}
/**
@ -22,7 +20,7 @@ const on: (eventName: string, handler: Function) => void = (
eventName,
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,
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.
*/
const emit: (eventName: string, object: any) => void = (eventName, object) => {
window.corePlugin?.events?.emit(eventName, object);
window.core?.events?.emit(eventName, object);
};
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 { JanPlugin } from "../plugin";
import { BaseExtension } from "../extension";
/**
* Abstract class for assistant plugins.
* @extends JanPlugin
* Assistant extension for managing assistants.
* @extends BaseExtension
*/
export abstract class AssistantPlugin extends JanPlugin {
export abstract class AssistantExtension extends BaseExtension {
/**
* Creates a new assistant.
* @param {Assistant} assistant - The assistant object to be created.

View File

@ -1,12 +1,12 @@
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
* @extends JanPlugin
* @extends BaseExtension
*/
export abstract class ConversationalPlugin extends JanPlugin {
export abstract class ConversationalExtension extends BaseExtension {
/**
* Returns a list of thread.
* @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 { 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.
*/
abstract initModel(modelId: string, settings?: ModelSettingParams): Promise<void>;
/**
* Stops the model for the plugin.
* Stops the model for the extension.
*/
abstract stopModel(): Promise<void>;

View File

@ -1,14 +1,10 @@
/**
* Represents a plugin for managing machine learning models.
* @abstract
*/
import { JanPlugin } from "../plugin";
import { BaseExtension } from "../extension";
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.
* @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.
* @extends JanPlugin
* Monitoring extension for system monitoring.
* @extends BaseExtension
*/
export abstract class MonitoringPlugin extends JanPlugin {
export abstract class MonitoringExtension extends BaseExtension {
/**
* Returns information about the system resources.
* @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.
*/
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.
@ -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.
*/
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.
@ -21,7 +21,7 @@ const isDirectory = (path: string): Promise<boolean> =>
* @returns {Promise<any>} A Promise that resolves with the contents of the file.
*/
const readFile: (path: string) => Promise<any> = (path) =>
window.coreAPI?.readFile(path);
window.core.api?.readFile(path);
/**
* 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.
*/
const listFiles: (path: string) => Promise<any> = (path) =>
window.coreAPI?.listFiles(path);
window.core.api?.listFiles(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.
*/
const mkdir: (path: string) => Promise<any> = (path) =>
window.coreAPI?.mkdir(path);
window.core.api?.mkdir(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.
*/
const rmdir: (path: string) => Promise<any> = (path) =>
window.coreAPI?.rmdir(path);
window.core.api?.rmdir(path);
/**
* Deletes a file from the local file system.
* @param {string} path - The path of the file to delete.
* @returns {Promise<any>} A Promise that resolves when the file is deleted.
*/
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.
@ -60,7 +60,7 @@ const deleteFile: (path: string) => Promise<any> = (path) =>
* @param data data to append
*/
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.
@ -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.
*/
const readLineByLine: (path: string) => Promise<any> = (path) =>
window.coreAPI?.readLineByLine(path);
window.core.api?.readLineByLine(path);
export const fs = {
isDirectory,

View File

@ -1,29 +1,35 @@
/**
* Core module exports.
* @module
*/
export * from "./core";
/**
* Events events exports.
* @module
*/
export * from "./events";
/**
* Events types exports.
* Export all types.
* @module
*/
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
*/
export * from "./fs";
/**
* Plugin base module export.
* Export Extension 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 = {
root: true,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
env: {
node: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
],
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
"react/prop-types": "off", // In favor of strong typing - no need to dedupe
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
'@typescript-eslint/no-non-null-assertion': 'off',
'react/prop-types': 'off', // In favor of strong typing - no need to dedupe
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
settings: {
react: {
createClass: "createReactClass", // Regex for Component Factory to use,
createClass: 'createReactClass', // Regex for Component Factory to use,
// default to "createReactClass"
pragma: "React", // Pragma to use, default to "React"
version: "detect", // React version. "detect" automatically picks the version you have installed.
pragma: 'React', // Pragma to use, default to "React"
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.
// default to latest and warns if missing
// It will default to "detect" in the future
},
linkComponents: [
// Components used as alternatives to <a> for linking, eg. <Link to={ url } />
"Hyperlink",
{ name: "Link", linkAttribute: "to" },
'Hyperlink',
{ name: 'Link', linkAttribute: 'to' },
],
},
ignorePatterns: [
"build",
"renderer",
"node_modules",
"core/plugins",
"core/**/*.test.js",
],
};
ignorePatterns: ['build', 'renderer', 'node_modules'],
}

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 { ModuleManager } from "../managers/module";
import { join } from "path";
import { PluginManager } from "../managers/plugin";
import { WindowManager } from "../managers/window";
import { app, ipcMain, shell } from 'electron'
import { ModuleManager } from '../managers/module'
import { join } from 'path'
import { ExtensionManager } from '../managers/extension'
import { WindowManager } from '../managers/window'
import { userSpacePath } from '../utils/path'
export function handleAppIPCs() {
/**
@ -10,57 +11,58 @@ export function handleAppIPCs() {
* 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.
*/
ipcMain.handle("appDataPath", async (_event) => {
return app.getPath("userData");
});
ipcMain.handle('appDataPath', async (_event) => {
return app.getPath('userData')
})
/**
* Returns the version of the app.
* @param _event - The IPC event object.
* @returns The version of the app.
*/
ipcMain.handle("appVersion", async (_event) => {
return app.getVersion();
});
ipcMain.handle('appVersion', async (_event) => {
return app.getVersion()
})
/**
* 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.
* @param _event - The IPC event object.
*/
ipcMain.handle("openAppDirectory", async (_event) => {
const userSpacePath = join(app.getPath('home'), 'jan')
shell.openPath(userSpacePath);
});
ipcMain.handle('openAppDirectory', async (_event) => {
shell.openPath(userSpacePath)
})
/**
* Opens a URL in the user's default browser.
* @param _event - The IPC event object.
* @param url - The URL to open.
*/
ipcMain.handle("openExternalUrl", async (_event, url) => {
shell.openExternal(url);
});
ipcMain.handle('openExternalUrl', async (_event, url) => {
shell.openExternal(url)
})
/**
* Relaunches the app in production - reload window in development.
* @param _event - The IPC event object.
* @param url - The URL to reload.
*/
ipcMain.handle("relaunch", async (_event, url) => {
ModuleManager.instance.clearImportedModules();
ipcMain.handle('relaunch', async (_event, url) => {
ModuleManager.instance.clearImportedModules()
if (app.isPackaged) {
app.relaunch();
app.exit();
app.relaunch()
app.exit()
} else {
for (const modulePath in ModuleManager.instance.requiredModules) {
delete require.cache[
require.resolve(join(app.getPath("userData"), "plugins", modulePath))
];
require.resolve(
join(userSpacePath, 'extensions', modulePath)
)
]
}
PluginManager.instance.setupPlugins();
WindowManager.instance.currentWindow?.reload();
ExtensionManager.instance.setupExtensions()
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 { join } from 'path'
import readline from 'readline'
import { userSpacePath } from '../utils/path'
/**
* Handles file system operations.
*/
export function handleFsIPCs() {
const userSpacePath = join(app.getPath('home'), 'jan')
/**
* Gets the path to the user data directory.
* @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 { ModuleManager } from './managers/module'
import { PluginManager } from './managers/plugin'
import { ExtensionManager } from './managers/extension'
/**
* IPC Handlers
**/
import { handleDownloaderIPCs } from './handlers/download'
import { handleThemesIPCs } from './handlers/theme'
import { handlePluginIPCs } from './handlers/plugin'
import { handleExtensionIPCs } from './handlers/extension'
import { handleAppIPCs } from './handlers/app'
import { handleAppUpdates } from './handlers/update'
app
.whenReady()
.then(PluginManager.instance.migratePlugins)
.then(PluginManager.instance.setupPlugins)
.then(ExtensionManager.instance.migrateExtensions)
.then(ExtensionManager.instance.setupExtensions)
.then(setupMenu)
.then(handleIPCs)
.then(handleAppUpdates)
@ -78,6 +78,6 @@ function handleIPCs() {
handleFsIPCs()
handleDownloaderIPCs()
handleThemesIPCs()
handlePluginIPCs()
handleExtensionIPCs()
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/**/*",
"build/*.{js,map}",
"build/**/*.{js,map}",
"core/pre-install"
"pre-install"
],
"asarUnpack": [
"core/pre-install"
"pre-install"
],
"publish": [
{

View File

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

View File

@ -1,147 +1,21 @@
/**
* 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
*/
/**
* 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.
* @function useFacade
* @memberof module:preload
* @returns {void}
*/
// TODO: Refactor this file for less dependencies and more modularity
// TODO: Most of the APIs should be done using RestAPIs from extensions
/**
* 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.
* @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.
*/
import { fsInvokers } from './invokers/fs'
import { appInvokers } from './invokers/app'
import { downloadInvokers } from './invokers/download'
import { extensionInvokers } from './invokers/extension'
// Make Pluggable Electron's facade available to the renderer on window.plugins
import { useFacade } from './core/plugin/facade'
useFacade()
const { contextBridge, ipcRenderer, shell } = require('electron')
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
invokePluginFunc: (plugin: any, method: any, ...args: any[]) =>
ipcRenderer.invoke('invokePluginFunc', plugin, method, ...args),
setNativeThemeLight: () => ipcRenderer.invoke('setNativeThemeLight'),
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),
...extensionInvokers(),
...downloadInvokers(),
...fsInvokers(),
...appInvokers(),
})

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",
"version": "1.0.9",
"description": "Assistant",
"icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/cpu-chip.svg",
"name": "@janhq/assistant-extension",
"version": "1.0.0",
"description": "Assistant extension",
"main": "dist/index.js",
"module": "dist/module.js",
"author": "Jan <service@jan.ai>",
"license": "AGPL-3.0",
"url": "/plugins/assistant-plugin/index.js",
"activationPoints": [
"init"
],
"scripts": {
"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": {
"rimraf": "^3.0.2",

View File

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

View File

@ -1,16 +1,13 @@
{
"name": "@janhq/conversational-json",
"name": "@janhq/conversational-extension",
"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",
"author": "Jan <service@jan.ai>",
"license": "MIT",
"activationPoints": [
"init"
],
"scripts": {
"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": {
".": "./dist/index.js",

View File

@ -1,37 +1,37 @@
import { PluginType, fs } from '@janhq/core'
import { ConversationalPlugin } from '@janhq/core/lib/plugins'
import { Thread, ThreadMessage } from '@janhq/core/lib/types'
import { ExtensionType, fs } from '@janhq/core'
import { ConversationalExtension } from '@janhq/core'
import { Thread, ThreadMessage } from '@janhq/core'
import { join } from 'path'
/**
* JSONConversationalPlugin is a ConversationalPlugin implementation that provides
* JSONConversationalExtension is a ConversationalExtension implementation that provides
* functionality for managing threads.
*/
export default class JSONConversationalPlugin implements ConversationalPlugin {
export default class JSONConversationalExtension implements ConversationalExtension {
private static readonly _homeDir = 'threads'
private static readonly _threadInfoFileName = 'thread.json'
private static readonly _threadMessagesFileName = 'messages.jsonl'
/**
* Returns the type of the plugin.
* Returns the type of the extension.
*/
type(): PluginType {
return PluginType.Conversational
type(): ExtensionType {
return ExtensionType.Conversational
}
/**
* Called when the plugin is loaded.
* Called when the extension is loaded.
*/
onLoad() {
fs.mkdir(JSONConversationalPlugin._homeDir)
console.debug('JSONConversationalPlugin loaded')
fs.mkdir(JSONConversationalExtension._homeDir)
console.debug('JSONConversationalExtension loaded')
}
/**
* Called when the plugin is unloaded.
* Called when the extension is unloaded.
*/
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> {
try {
const threadDirPath = join(JSONConversationalPlugin._homeDir, thread.id)
const threadDirPath = join(JSONConversationalExtension._homeDir, thread.id)
const threadJsonPath = join(
threadDirPath,
JSONConversationalPlugin._threadInfoFileName
JSONConversationalExtension._threadInfoFileName
)
await fs.mkdir(threadDirPath)
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.
*/
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> {
try {
const threadDirPath = join(
JSONConversationalPlugin._homeDir,
JSONConversationalExtension._homeDir,
message.thread_id
)
const threadMessagePath = join(
threadDirPath,
JSONConversationalPlugin._threadMessagesFileName
JSONConversationalExtension._threadMessagesFileName
)
await fs.mkdir(threadDirPath)
await fs.appendFile(threadMessagePath, JSON.stringify(message) + '\n')
@ -111,10 +111,10 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
messages: ThreadMessage[]
): Promise<void> {
try {
const threadDirPath = join(JSONConversationalPlugin._homeDir, threadId)
const threadDirPath = join(JSONConversationalExtension._homeDir, threadId)
const threadMessagePath = join(
threadDirPath,
JSONConversationalPlugin._threadMessagesFileName
JSONConversationalExtension._threadMessagesFileName
)
await fs.mkdir(threadDirPath)
await fs.writeFile(
@ -135,9 +135,9 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
private async readThread(threadDirName: string): Promise<any> {
return fs.readFile(
join(
JSONConversationalPlugin._homeDir,
JSONConversationalExtension._homeDir,
threadDirName,
JSONConversationalPlugin._threadInfoFileName
JSONConversationalExtension._threadInfoFileName
)
)
}
@ -148,12 +148,12 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
*/
private async getValidThreadDirs(): Promise<string[]> {
const fileInsideThread: string[] = await fs.listFiles(
JSONConversationalPlugin._homeDir
JSONConversationalExtension._homeDir
)
const threadDirs: string[] = []
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)
if (!isDirectory) {
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(
JSONConversationalPlugin._threadInfoFileName
JSONConversationalExtension._threadInfoFileName
)
if (!isHavingThreadInfo) {
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[]> {
try {
const threadDirPath = join(JSONConversationalPlugin._homeDir, threadId)
const threadDirPath = join(JSONConversationalExtension._homeDir, threadId)
const isDir = await fs.isDirectory(threadDirPath)
if (!isDir) {
throw Error(`${threadDirPath} is not directory`)
}
const files: string[] = await fs.listFiles(threadDirPath)
if (!files.includes(JSONConversationalPlugin._threadMessagesFileName)) {
if (!files.includes(JSONConversationalExtension._threadMessagesFileName)) {
throw Error(`${threadDirPath} not contains message file`)
}
const messageFilePath = join(
threadDirPath,
JSONConversationalPlugin._threadMessagesFileName
JSONConversationalExtension._threadMessagesFileName
)
const result = await fs.readLineByLine(messageFilePath)

View File

@ -1,25 +1,20 @@
{
"name": "@janhq/inference-plugin",
"version": "1.0.21",
"description": "Inference Plugin, powered by @janhq/nitro, bring a high-performance Llama model inference in pure C++.",
"name": "@janhq/inference-extension",
"version": "1.0.0",
"description": "Inference Extension, powered by @janhq/nitro, bring a high-performance Llama model inference in pure C++.",
"main": "dist/index.js",
"module": "dist/module.js",
"author": "Jan <service@jan.ai>",
"license": "AGPL-3.0",
"supportCloudNative": true,
"url": "/plugins/inference-plugin/index.js",
"activationPoints": [
"init"
],
"scripts": {
"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: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": "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:win32": "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/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/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"
},
"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.
* It also subscribes to events emitted by the @janhq/core package and handles new message requests.
* @version 1.0.0
* @module inference-plugin/src/index
* @module inference-extension/src/index
*/
import {
@ -13,40 +13,40 @@ import {
MessageRequest,
MessageStatus,
ModelSettingParams,
PluginType,
ExtensionType,
ThreadContent,
ThreadMessage,
events,
executeOnMain,
getUserSpace,
} from "@janhq/core";
import { InferencePlugin } from "@janhq/core/lib/plugins";
import { InferenceExtension } from "@janhq/core";
import { requestInference } from "./helpers/sse";
import { ulid } from "ulid";
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.
* 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();
isCancelled = false;
/**
* Returns the type of the plugin.
* @returns {PluginType} The type of the plugin.
* Returns the type of the extension.
* @returns {ExtensionType} The type of the extension.
*/
type(): PluginType {
return PluginType.Inference;
type(): ExtensionType {
return ExtensionType.Inference;
}
/**
* Subscribes to events emitted by the @janhq/core package.
*/
onLoad(): void {
events.on(EventName.OnNewMessageRequest, (data) =>
JanInferencePlugin.handleMessageRequest(data, this)
events.on(EventName.OnMessageSent, (data) =>
JanInferenceExtension.handleMessageRequest(data, this)
);
}
@ -131,7 +131,7 @@ export default class JanInferencePlugin implements InferencePlugin {
*/
private static async handleMessageRequest(
data: MessageRequest,
instance: JanInferencePlugin
instance: JanInferenceExtension
) {
const timestamp = Date.now();
const message: ThreadMessage = {
@ -145,7 +145,7 @@ export default class JanInferencePlugin implements InferencePlugin {
updated: timestamp,
object: "thread.message",
};
events.emit(EventName.OnNewMessageResponse, message);
events.emit(EventName.OnMessageResponse, message);
console.log(JSON.stringify(data, null, 2));
instance.isCancelled = false;
@ -161,11 +161,11 @@ export default class JanInferencePlugin implements InferencePlugin {
},
};
message.content = [messageContent];
events.emit(EventName.OnMessageResponseUpdate, message);
events.emit(EventName.OnMessageUpdate, message);
},
complete: async () => {
message.status = MessageStatus.Ready;
events.emit(EventName.OnMessageResponseFinished, message);
events.emit(EventName.OnMessageUpdate, message);
},
error: async (err) => {
const messageContent: ThreadContent = {
@ -177,7 +177,7 @@ export default class JanInferencePlugin implements InferencePlugin {
};
message.content = [messageContent];
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",
"description": "Model Management Plugin provides model exploration and seamless downloads",
"icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/queue-list.svg",
"description": "Model Management Extension provides model exploration and seamless downloads",
"main": "dist/index.js",
"module": "dist/module.js",
"author": "Jan <service@jan.ai>",
"license": "AGPL-3.0",
"supportCloudNative": true,
"url": "/plugins/model-plugin/index.js",
"activationPoints": [
"init"
],
"scripts": {
"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": {
"cpx": "^1.5.0",

View File

@ -1,38 +1,37 @@
import { PluginType, fs, downloadFile, abortDownload } from '@janhq/core'
import { ModelPlugin } from '@janhq/core/lib/plugins'
import { Model, ModelCatalog } from '@janhq/core/lib/types'
import { ExtensionType, fs, downloadFile, abortDownload } from '@janhq/core'
import { ModelExtension, Model, ModelCatalog } from '@janhq/core'
import { parseToModel } from './helpers/modelParser'
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 _modelMetadataFileName = 'model.json'
/**
* Implements type from JanPlugin.
* Implements type from JanExtension.
* @override
* @returns The type of the plugin.
* @returns The type of the extension.
*/
type(): PluginType {
return PluginType.Model
type(): ExtensionType {
return ExtensionType.Model
}
/**
* Called when the plugin is loaded.
* Called when the extension is loaded.
* @override
*/
onLoad(): void {
/** Cloud Native
* 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
*/
onUnload(): void {}
@ -44,7 +43,7 @@ export default class JanModelPlugin implements ModelPlugin {
*/
async downloadModel(model: Model): Promise<void> {
// create corresponding directory
const directoryPath = join(JanModelPlugin._homeDir, model.id)
const directoryPath = join(JanModelExtension._homeDir, model.id)
await fs.mkdir(directoryPath)
// 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.
*/
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> {
try {
const dirPath = join(JanModelPlugin._homeDir, modelId)
const dirPath = join(JanModelExtension._homeDir, modelId)
await fs.rmdir(dirPath)
} catch (err) {
console.error(err)
@ -86,9 +85,9 @@ export default class JanModelPlugin implements ModelPlugin {
*/
async saveModel(model: Model): Promise<void> {
const jsonFilePath = join(
JanModelPlugin._homeDir,
JanModelExtension._homeDir,
model.id,
JanModelPlugin._modelMetadataFileName
JanModelExtension._modelMetadataFileName
)
try {
@ -104,9 +103,9 @@ export default class JanModelPlugin implements ModelPlugin {
*/
async getDownloadedModels(): Promise<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) {
const modelDirPath = join(JanModelPlugin._homeDir, dir)
const modelDirPath = join(JanModelExtension._homeDir, dir)
const isModelDir = await fs.isDirectory(modelDirPath)
if (!isModelDir) {
// if not a directory, ignore
@ -114,7 +113,7 @@ export default class JanModelPlugin implements ModelPlugin {
}
const jsonFiles: string[] = (await fs.listFiles(modelDirPath)).filter(
(fileName: string) => fileName === JanModelPlugin._modelMetadataFileName
(fileName: string) => fileName === JanModelExtension._modelMetadataFileName
)
for (const json of jsonFiles) {

View File

@ -1,20 +1,14 @@
{
"name": "@janhq/monitoring-plugin",
"name": "@janhq/monitoring-extension",
"version": "1.0.9",
"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",
"module": "dist/module.js",
"author": "Jan <service@jan.ai>",
"license": "AGPL-3.0",
"supportCloudNative": true,
"url": "/plugins/monitoring-plugin/index.js",
"activationPoints": [
"init"
],
"scripts": {
"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": {
"rimraf": "^3.0.2",

View File

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

View File

@ -35,7 +35,7 @@
"build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"",
"build:electron": "yarn workspace jan build",
"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": "yarn build:web && yarn workspace jan build",
"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 { 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 { twMerge } from 'tailwind-merge'

View File

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

View File

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

View File

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

View File

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

View File

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

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