diff --git a/server/core/plugin/facade.ts b/server/core/plugin/facade.ts new file mode 100644 index 000000000..bd1089109 --- /dev/null +++ b/server/core/plugin/facade.ts @@ -0,0 +1,30 @@ +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; +} diff --git a/server/core/plugin/globals.ts b/server/core/plugin/globals.ts new file mode 100644 index 000000000..69df7925c --- /dev/null +++ b/server/core/plugin/globals.ts @@ -0,0 +1,36 @@ +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"); +} \ No newline at end of file diff --git a/server/core/plugin/index.ts b/server/core/plugin/index.ts new file mode 100644 index 000000000..e8c64747b --- /dev/null +++ b/server/core/plugin/index.ts @@ -0,0 +1,149 @@ +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, + }; +} diff --git a/server/core/plugin/plugin.ts b/server/core/plugin/plugin.ts new file mode 100644 index 000000000..f0fc073d7 --- /dev/null +++ b/server/core/plugin/plugin.ts @@ -0,0 +1,213 @@ +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} 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; + main?: string; + description?: string; + icon?: string; + + /** @private */ + _active = false; + + /** + * @private + * @property {Object.} #listeners A list of callbacks to be executed when the Plugin is updated. + */ + listeners: Record 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.} 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.} 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; diff --git a/server/core/plugin/router.ts b/server/core/plugin/router.ts new file mode 100644 index 000000000..09c79485b --- /dev/null +++ b/server/core/plugin/router.ts @@ -0,0 +1,97 @@ +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 = {}; + 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; +} diff --git a/server/core/plugin/store.ts b/server/core/plugin/store.ts new file mode 100644 index 000000000..cfd25e5ca --- /dev/null +++ b/server/core/plugin/store.ts @@ -0,0 +1,131 @@ +/** + * 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.} plugin - List of installed plugins + */ +const plugins: Record = {}; + +/** + * 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.} All plugin objects + * @alias pluginManager.getAllPlugins + */ +export function getAllPlugins() { + return Object.values(plugins); +} + +/** + * Get list of active plugin objects. + * @returns {Array.} 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 = {}; + 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.} 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.>} 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.} 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. + */ diff --git a/server/core/pre-install/.gitkeep b/server/core/pre-install/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/server/handlers/download.ts b/server/handlers/download.ts new file mode 100644 index 000000000..3a1fc36d1 --- /dev/null +++ b/server/handlers/download.ts @@ -0,0 +1,108 @@ +import { app, ipcMain } from 'electron' +import { DownloadManager } from '../managers/download' +import { resolve, join } from 'path' +import { WindowManager } from '../managers/window' +import request from 'request' +import { createWriteStream, unlink } from 'fs' +const progress = require('request-progress') + +export function handleDownloaderIPCs() { + /** + * Handles the "pauseDownload" IPC message by pausing the download associated with the provided fileName. + * @param _event - The IPC event object. + * @param fileName - The name of the file being downloaded. + */ + ipcMain.handle('pauseDownload', async (_event, fileName) => { + DownloadManager.instance.networkRequests[fileName]?.pause() + }) + + /** + * Handles the "resumeDownload" IPC message by resuming the download associated with the provided fileName. + * @param _event - The IPC event object. + * @param fileName - The name of the file being downloaded. + */ + ipcMain.handle('resumeDownload', async (_event, fileName) => { + DownloadManager.instance.networkRequests[fileName]?.resume() + }) + + /** + * Handles the "abortDownload" IPC message by aborting the download associated with the provided fileName. + * The network request associated with the fileName is then removed from the networkRequests object. + * @param _event - The IPC event object. + * @param fileName - The name of the file being downloaded. + */ + ipcMain.handle('abortDownload', async (_event, fileName) => { + const rq = DownloadManager.instance.networkRequests[fileName] + DownloadManager.instance.networkRequests[fileName] = undefined + const userDataPath = app.getPath('userData') + const fullPath = join(userDataPath, fileName) + rq?.abort() + let result = 'NULL' + unlink(fullPath, function (err) { + if (err && err.code == 'ENOENT') { + result = `File not exist: ${err}` + } else if (err) { + result = `File delete error: ${err}` + } else { + result = 'File deleted successfully' + } + console.debug( + `Delete file ${fileName} from ${fullPath} result: ${result}` + ) + }) + }) + + /** + * Downloads a file from a given URL. + * @param _event - The IPC event object. + * @param url - The URL to download the file from. + * @param fileName - The name to give the downloaded file. + */ + ipcMain.handle('downloadFile', async (_event, url, fileName) => { + const userDataPath = join(app.getPath('home'), 'jan') + const destination = resolve(userDataPath, fileName) + const rq = request(url) + + progress(rq, {}) + .on('progress', function (state: any) { + WindowManager?.instance.currentWindow?.webContents.send( + 'FILE_DOWNLOAD_UPDATE', + { + ...state, + fileName, + } + ) + }) + .on('error', function (err: Error) { + WindowManager?.instance.currentWindow?.webContents.send( + 'FILE_DOWNLOAD_ERROR', + { + fileName, + err, + } + ) + }) + .on('end', function () { + if (DownloadManager.instance.networkRequests[fileName]) { + WindowManager?.instance.currentWindow?.webContents.send( + 'FILE_DOWNLOAD_COMPLETE', + { + fileName, + } + ) + DownloadManager.instance.setRequest(fileName, undefined) + } else { + WindowManager?.instance.currentWindow?.webContents.send( + 'FILE_DOWNLOAD_ERROR', + { + fileName, + err: 'Download cancelled', + } + ) + } + }) + .pipe(createWriteStream(destination)) + + DownloadManager.instance.setRequest(fileName, rq) + }) +} diff --git a/server/handlers/fs.ts b/server/handlers/fs.ts new file mode 100644 index 000000000..c1e8a85e4 --- /dev/null +++ b/server/handlers/fs.ts @@ -0,0 +1,156 @@ +import { app, ipcMain } from 'electron' +import * as fs from 'fs' +import { join } from '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. + * @returns A promise that resolves with the path to the user data directory. + */ + ipcMain.handle( + 'getUserSpace', + (): Promise => Promise.resolve(userSpacePath) + ) + + /** + * Checks whether the path is a directory. + * @param event - The event object. + * @param path - The path to check. + * @returns A promise that resolves with a boolean indicating whether the path is a directory. + */ + ipcMain.handle('isDirectory', (_event, path: string): Promise => { + const fullPath = join(userSpacePath, path) + return Promise.resolve( + fs.existsSync(fullPath) && fs.lstatSync(fullPath).isDirectory() + ) + }) + + /** + * Reads a file from the user data directory. + * @param event - The event object. + * @param path - The path of the file to read. + * @returns A promise that resolves with the contents of the file. + */ + ipcMain.handle('readFile', async (event, path: string): Promise => { + return new Promise((resolve, reject) => { + fs.readFile(join(userSpacePath, path), 'utf8', (err, data) => { + if (err) { + reject(err) + } else { + resolve(data) + } + }) + }) + }) + + /** + * Writes data to a file in the user data directory. + * @param event - The event object. + * @param path - The path of the file to write to. + * @param data - The data to write to the file. + * @returns A promise that resolves when the file has been written. + */ + ipcMain.handle( + 'writeFile', + async (event, path: string, data: string): Promise => { + return new Promise((resolve, reject) => { + fs.writeFile(join(userSpacePath, path), data, 'utf8', (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } + ) + + /** + * Creates a directory in the user data directory. + * @param event - The event object. + * @param path - The path of the directory to create. + * @returns A promise that resolves when the directory has been created. + */ + ipcMain.handle('mkdir', async (event, path: string): Promise => { + return new Promise((resolve, reject) => { + fs.mkdir(join(userSpacePath, path), { recursive: true }, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + }) + + /** + * Removes a directory in the user data directory. + * @param event - The event object. + * @param path - The path of the directory to remove. + * @returns A promise that resolves when the directory is removed successfully. + */ + ipcMain.handle('rmdir', async (event, path: string): Promise => { + return new Promise((resolve, reject) => { + fs.rmdir(join(userSpacePath, path), { recursive: true }, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + }) + + /** + * Lists the files in a directory in the user data directory. + * @param event - The event object. + * @param path - The path of the directory to list files from. + * @returns A promise that resolves with an array of file names. + */ + ipcMain.handle( + 'listFiles', + async (event, path: string): Promise => { + return new Promise((resolve, reject) => { + fs.readdir(join(userSpacePath, path), (err, files) => { + if (err) { + reject(err) + } else { + resolve(files) + } + }) + }) + } + ) + + /** + * Deletes a file from the user data folder. + * @param _event - The IPC event object. + * @param filePath - The path to the file to delete. + * @returns A string indicating the result of the operation. + */ + ipcMain.handle('deleteFile', async (_event, filePath) => { + const fullPath = join(userSpacePath, filePath) + + let result = 'NULL' + fs.unlink(fullPath, function (err) { + if (err && err.code == 'ENOENT') { + result = `File not exist: ${err}` + } else if (err) { + result = `File delete error: ${err}` + } else { + result = 'File deleted successfully' + } + console.debug( + `Delete file ${filePath} from ${fullPath} result: ${result}` + ) + }) + + return result + }) +} diff --git a/server/handlers/plugin.ts b/server/handlers/plugin.ts new file mode 100644 index 000000000..22bf253e6 --- /dev/null +++ b/server/handlers/plugin.ts @@ -0,0 +1,118 @@ +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); + }); +} diff --git a/server/icons/icon.png b/server/icons/icon.png new file mode 100644 index 000000000..289f99ded Binary files /dev/null and b/server/icons/icon.png differ diff --git a/server/main.ts b/server/main.ts new file mode 100644 index 000000000..a4f4df1bb --- /dev/null +++ b/server/main.ts @@ -0,0 +1,32 @@ +import { join } from 'path' +import { setupMenu } from './utils/menu' +import { handleFsIPCs } from './handlers/fs' +import app from 'express' + +/** + * Managers + **/ +import { ModuleManager } from './managers/module' +import { PluginManager } from './managers/plugin' + +/** + * IPC Handlers + **/ +import { handleDownloaderIPCs } from './handlers/download' +import { handlePluginIPCs } from './handlers/plugin' + +app().listen(6969, ()=>{ + PluginManager.instance.migratePlugins() + PluginManager.instance.setupPlugins() + setupMenu() + handleIPCs() +}) + +/** + * Handles various IPC messages from the renderer process. + */ +function handleIPCs() { + handleFsIPCs() + handleDownloaderIPCs() + handlePluginIPCs() +} diff --git a/server/managers/download.ts b/server/managers/download.ts new file mode 100644 index 000000000..08c089b74 --- /dev/null +++ b/server/managers/download.ts @@ -0,0 +1,24 @@ +import { Request } from "request"; + +/** + * Manages file downloads and network requests. + */ +export class DownloadManager { + public networkRequests: Record = {}; + + public static instance: DownloadManager = new DownloadManager(); + + constructor() { + if (DownloadManager.instance) { + return DownloadManager.instance; + } + } + /** + * Sets a network request for a specific file. + * @param {string} fileName - The name of the file. + * @param {Request | undefined} request - The network request to set, or undefined to clear the request. + */ + setRequest(fileName: string, request: Request | undefined) { + this.networkRequests[fileName] = request; + } +} diff --git a/server/managers/module.ts b/server/managers/module.ts new file mode 100644 index 000000000..43dda0fb6 --- /dev/null +++ b/server/managers/module.ts @@ -0,0 +1,33 @@ +import { dispose } from "../utils/disposable"; + +/** + * Manages imported modules. + */ +export class ModuleManager { + public requiredModules: Record = {}; + + public static instance: ModuleManager = new ModuleManager(); + + constructor() { + if (ModuleManager.instance) { + return ModuleManager.instance; + } + } + + /** + * Sets a module. + * @param {string} moduleName - The name of the module. + * @param {any | undefined} nodule - The module to set, or undefined to clear the module. + */ + setModule(moduleName: string, nodule: any | undefined) { + this.requiredModules[moduleName] = nodule; + } + + /** + * Clears all imported modules. + */ + clearImportedModules() { + dispose(this.requiredModules); + this.requiredModules = {}; + } +} diff --git a/server/managers/plugin.ts b/server/managers/plugin.ts new file mode 100644 index 000000000..227eab34e --- /dev/null +++ b/server/managers/plugin.ts @@ -0,0 +1,60 @@ +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); + } + }); + } +} diff --git a/server/managers/window.ts b/server/managers/window.ts new file mode 100644 index 000000000..c930dd5ec --- /dev/null +++ b/server/managers/window.ts @@ -0,0 +1,37 @@ +import { BrowserWindow } from "electron"; + +/** + * Manages the current window instance. + */ +export class WindowManager { + public static instance: WindowManager = new WindowManager(); + public currentWindow?: BrowserWindow; + + constructor() { + if (WindowManager.instance) { + return WindowManager.instance; + } + } + + /** + * Creates a new window instance. + * @param {Electron.BrowserWindowConstructorOptions} options - The options to create the window with. + * @returns The created window instance. + */ + createWindow(options?: Electron.BrowserWindowConstructorOptions | undefined) { + this.currentWindow = new BrowserWindow({ + width: 1200, + minWidth: 800, + height: 800, + show: false, + trafficLightPosition: { + x: 10, + y: 15, + }, + titleBarStyle: "hidden", + vibrancy: "sidebar", + ...options, + }); + return this.currentWindow; + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 000000000..8185f37d9 --- /dev/null +++ b/server/package.json @@ -0,0 +1,97 @@ +{ + "name": "jan-server", + "version": "0.1.3", + "main": "./build/main.js", + "author": "Jan ", + "license": "MIT", + "homepage": "https://github.com/janhq/jan/tree/main/electron", + "description": "Use offline LLMs with your own data. Run open source models like Llama2 or Falcon on your internal computers/servers.", + "build": { + "appId": "jan.ai.app", + "productName": "Jan", + "files": [ + "renderer/**/*", + "build/*.{js,map}", + "build/**/*.{js,map}", + "core/pre-install", + "core/plugin-manager/facade" + ], + "asarUnpack": [ + "core/pre-install" + ], + "publish": [ + { + "provider": "github", + "owner": "janhq", + "repo": "jan" + } + ], + "extends": null, + "mac": { + "type": "distribution", + "entitlements": "./entitlements.mac.plist", + "entitlementsInherit": "./entitlements.mac.plist", + "notarize": { + "teamId": "YT49P7GXG4" + }, + "icon": "icons/icon.png" + }, + "linux": { + "target": [ + "deb" + ], + "category": "Utility", + "icon": "icons/" + }, + "win": { + "icon": "icons/icon.png" + }, + "artifactName": "jan-${os}-${arch}-${version}.${ext}" + }, + "scripts": { + "lint": "eslint . --ext \".js,.jsx,.ts,.tsx\"", + "test:e2e": "playwright test --workers=1", + "dev": "tsc -p . && electron .", + "build": "run-script-os", + "build:test": "run-script-os", + "build:test:darwin": "tsc -p . && electron-builder -p never -m --dir", + "build:test:win32": "tsc -p . && electron-builder -p never -w --dir", + "build:test:linux": "tsc -p . && electron-builder -p never -l --dir", + "build:darwin": "tsc -p . && electron-builder -p never -m", + "build:win32": "tsc -p . && electron-builder -p never -w", + "build:linux": "tsc -p . && electron-builder -p never --linux deb", + "build:publish": "run-script-os", + "build:publish:darwin": "tsc -p . && electron-builder -p onTagOrDraft -m --x64 --arm64", + "build:publish:win32": "tsc -p . && electron-builder -p onTagOrDraft -w", + "build:publish:linux": "tsc -p . && electron-builder -p onTagOrDraft -l deb" + }, + "dependencies": { + "@npmcli/arborist": "^7.1.0", + "@types/express": "^4.17.21", + "@types/request": "^2.48.12", + "@uiball/loaders": "^1.3.0", + "electron-store": "^8.1.0", + "electron-updater": "^6.1.4", + "express": "^4.18.2", + "pacote": "^17.0.4", + "request": "^2.88.2", + "request-progress": "^3.0.0", + "use-debounce": "^9.0.4" + }, + "devDependencies": { + "@electron/notarize": "^2.1.0", + "@playwright/test": "^1.38.1", + "@types/npmcli__arborist": "^5.6.4", + "@types/pacote": "^11.1.7", + "@typescript-eslint/eslint-plugin": "^6.7.3", + "@typescript-eslint/parser": "^6.7.3", + "electron": "26.2.1", + "electron-builder": "^24.6.4", + "electron-playwright-helpers": "^1.6.0", + "eslint-plugin-react": "^7.33.2", + "run-script-os": "^1.1.6" + }, + "installConfig": { + "hoistingLimits": "workspaces" + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 000000000..3cc218f93 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "noImplicitAny": true, + "sourceMap": true, + "strict": true, + "outDir": "./build", + "rootDir": "./", + "noEmitOnError": true, + "esModuleInterop": true, + "baseUrl": ".", + "allowJs": true, + "skipLibCheck": true, + "paths": { "*": ["node_modules/*"] }, + "typeRoots": ["node_modules/@types"] + }, + "include": ["./**/*.ts"], + "exclude": ["core", "build", "dist", "tests", "node_modules"] +} diff --git a/server/utils/disposable.ts b/server/utils/disposable.ts new file mode 100644 index 000000000..462f7e3e5 --- /dev/null +++ b/server/utils/disposable.ts @@ -0,0 +1,8 @@ +export function dispose(requiredModules: Record) { + for (const key in requiredModules) { + const module = requiredModules[key]; + if (typeof module["dispose"] === "function") { + module["dispose"](); + } + } +} diff --git a/server/utils/menu.ts b/server/utils/menu.ts new file mode 100644 index 000000000..65e009aef --- /dev/null +++ b/server/utils/menu.ts @@ -0,0 +1,111 @@ +// @ts-nocheck +const { app, Menu, dialog } = require("electron"); +const isMac = process.platform === "darwin"; +const { autoUpdater } = require("electron-updater"); +import { compareSemanticVersions } from "./versionDiff"; + +const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ + ...(isMac + ? [ + { + label: app.name, + submenu: [ + { role: "about" }, + { + label: "Check for Updates...", + click: () => + autoUpdater.checkForUpdatesAndNotify().then((e) => { + if ( + !e || + compareSemanticVersions( + app.getVersion(), + e.updateInfo.version + ) >= 0 + ) + dialog.showMessageBox({ + message: `There are currently no updates available.`, + }); + }), + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }, + ] + : []), + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + ...(isMac + ? [ + { role: "pasteAndMatchStyle" }, + { role: "delete" }, + { role: "selectAll" }, + { type: "separator" }, + { + label: "Speech", + submenu: [{ role: "startSpeaking" }, { role: "stopSpeaking" }], + }, + ] + : [{ role: "delete" }, { type: "separator" }, { role: "selectAll" }]), + ], + }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + ...(isMac + ? [ + { type: "separator" }, + { role: "front" }, + { type: "separator" }, + { role: "window" }, + ] + : [{ role: "close" }]), + ], + }, + { + role: "help", + submenu: [ + { + label: "Learn More", + click: async () => { + const { shell } = require("electron"); + await shell.openExternal("https://jan.ai/"); + }, + }, + ], + }, +]; + +export const setupMenu = () => { + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +}; diff --git a/server/utils/versionDiff.ts b/server/utils/versionDiff.ts new file mode 100644 index 000000000..25934e87f --- /dev/null +++ b/server/utils/versionDiff.ts @@ -0,0 +1,21 @@ +export const compareSemanticVersions = (a: string, b: string) => { + + // 1. Split the strings into their parts. + const a1 = a.split('.'); + const b1 = b.split('.'); + // 2. Contingency in case there's a 4th or 5th version + const len = Math.min(a1.length, b1.length); + // 3. Look through each version number and compare. + for (let i = 0; i < len; i++) { + const a2 = +a1[ i ] || 0; + const b2 = +b1[ i ] || 0; + + if (a2 !== b2) { + return a2 > b2 ? 1 : -1; + } + } + + // 4. We hit this if the all checked versions so far are equal + // + return b1.length - a1.length; +}; \ No newline at end of file