From c5925b6a79797604740a2a7dc7cb4d9ddd053141 Mon Sep 17 00:00:00 2001 From: Louis <133622055+louis-jan@users.noreply.github.com> Date: Thu, 19 Oct 2023 22:53:55 +0700 Subject: [PATCH] feat: explore plugins from the npm repository and install them remotely (#399) * feat: explore plugins from the npm repository and install them remotely * refactor: clean out redundant codes * chore: only show update button on different version --- electron/main.ts | 21 ++- electron/package.json | 4 +- electron/preload.ts | 23 ++- web/app/_components/Preferences.tsx | 231 +++++++++++++++++++--------- web/next.config.js | 8 + web/types/index.d.ts | 1 + 6 files changed, 196 insertions(+), 92 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 232116765..f2983d98f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,11 +1,12 @@ import { app, BrowserWindow, ipcMain, dialog, shell } from "electron"; -import { readdirSync } from "fs"; +import { readdirSync, writeFileSync } from "fs"; import { resolve, join, extname } from "path"; import { rmdir, unlink, createWriteStream } from "fs"; import { init } from "./core/plugin-manager/pluginMgr"; import { setupMenu } from "./utils/menu"; import { dispose } from "./utils/disposable"; +const pacote = require("pacote"); const request = require("request"); const progress = require("request-progress"); const { autoUpdater } = require("electron-updater"); @@ -225,6 +226,24 @@ function handleIPCs() { }) .pipe(createWriteStream(destination)); }); + + /** + * 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 pacote + .manifest(pluginName) + .then(async (manifest: any) => { + await pacote.tarball(manifest._resolved).then((data: Buffer) => { + writeFileSync(destination, data); + }); + }) + .then(() => destination); + }); } function migratePlugins() { diff --git a/electron/package.json b/electron/package.json index a4c3cd3cd..67d39f917 100644 --- a/electron/package.json +++ b/electron/package.json @@ -37,7 +37,9 @@ "icon": "icons/icon.png" }, "linux": { - "target": ["deb"], + "target": [ + "deb" + ], "category": "Utility", "icon": "icons/" }, diff --git a/electron/preload.ts b/electron/preload.ts index 7ccc6174d..a52a76c81 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -23,24 +23,19 @@ contextBridge.exposeInMainWorld("electronAPI", { deleteFile: (filePath: string) => ipcRenderer.invoke("deleteFile", filePath), - downloadFile: (url: string, path: string) => - ipcRenderer.invoke("downloadFile", url, path), + installRemotePlugin: (pluginName: string) => ipcRenderer.invoke("installRemotePlugin", pluginName), - onFileDownloadUpdate: (callback: any) => - ipcRenderer.on("FILE_DOWNLOAD_UPDATE", callback), + downloadFile: (url: string, path: string) => ipcRenderer.invoke("downloadFile", url, path), - onFileDownloadError: (callback: any) => - ipcRenderer.on("FILE_DOWNLOAD_ERROR", callback), + onFileDownloadUpdate: (callback: any) => ipcRenderer.on("FILE_DOWNLOAD_UPDATE", callback), - onFileDownloadSuccess: (callback: any) => - ipcRenderer.on("FILE_DOWNLOAD_COMPLETE", callback), + onFileDownloadError: (callback: any) => ipcRenderer.on("FILE_DOWNLOAD_ERROR", callback), - onAppUpdateDownloadUpdate: (callback: any) => - ipcRenderer.on("APP_UPDATE_PROGRESS", callback), + onFileDownloadSuccess: (callback: any) => ipcRenderer.on("FILE_DOWNLOAD_COMPLETE", callback), - onAppUpdateDownloadError: (callback: any) => - ipcRenderer.on("APP_UPDATE_ERROR", callback), + onAppUpdateDownloadUpdate: (callback: any) => ipcRenderer.on("APP_UPDATE_PROGRESS", callback), - onAppUpdateDownloadSuccess: (callback: any) => - ipcRenderer.on("APP_UPDATE_COMPLETE", callback), + onAppUpdateDownloadError: (callback: any) => ipcRenderer.on("APP_UPDATE_ERROR", callback), + + onAppUpdateDownloadSuccess: (callback: any) => ipcRenderer.on("APP_UPDATE_COMPLETE", callback), }); diff --git a/web/app/_components/Preferences.tsx b/web/app/_components/Preferences.tsx index ec43295bc..a48d9543e 100644 --- a/web/app/_components/Preferences.tsx +++ b/web/app/_components/Preferences.tsx @@ -1,17 +1,13 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { - setup, - plugins, - extensionPoints, - activationPoints, -} from "@/../../electron/core/plugin-manager/execution/index"; +import { plugins, extensionPoints } from "@/../../electron/core/plugin-manager/execution/index"; import { ChartPieIcon, CommandLineIcon, PlayIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import classNames from "classnames"; import { PluginService, preferences } from "@janhq/core"; import { execute } from "../../../electron/core/plugin-manager/execution/extension-manager"; +import LoadingIndicator from "./LoadingIndicator"; export const Preferences = () => { const [search, setSearch] = useState(""); @@ -20,62 +16,68 @@ export const Preferences = () => { const [preferenceValues, setPreferenceValues] = useState([]); const [isTestAvailable, setIsTestAvailable] = useState(false); const [fileName, setFileName] = useState(""); + const [pluginCatalog, setPluginCatalog] = useState([]); + const [isLoading, setIsLoading] = useState(false); const experimentRef = useRef(null); const preferenceRef = useRef(null); - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - setFileName(file.name); - } else { - setFileName(""); - } - }; - + /** + * Loads the plugin catalog module from a CDN and sets it as the plugin catalog state. + * The `webpackIgnore` comment is used to prevent Webpack from bundling the module. + */ useEffect(() => { - async function setupPE() { - // Enable activation point management - setup({ - //@ts-ignore - importer: (plugin) => - import(/* webpackIgnore: true */ plugin).catch((err) => { - console.log(err); - }), - }); - - // Register all active plugins with their activation points - await plugins.registerActive(); - } - - const activePlugins = async () => { - const plgs = await plugins.getActive(); - setActivePlugins(plgs); - // Activate alls - setTimeout(async () => { - await activationPoints.trigger("init"); - if (extensionPoints.get("experimentComponent")) { - const components = await Promise.all(extensionPoints.execute("experimentComponent")); - if (components.length > 0) { - setIsTestAvailable(true); - } - components.forEach((e) => { - if (experimentRef.current) { - // @ts-ignore - experimentRef.current.appendChild(e); - } - }); - } - - if (extensionPoints.get("PluginPreferences")) { - const data = await Promise.all(extensionPoints.execute("PluginPreferences")); - setPreferenceItems(Array.isArray(data) ? data : []); - } - }, 500); - }; - setupPE().then(() => activePlugins()); + // @ts-ignore + import(/* webpackIgnore: true */ PLUGIN_CATALOGS).then((module) => { + console.log(module); + setPluginCatalog(module.default); + }); }, []); - // Install a new plugin on clicking the install button + /** + * Fetches the active plugins and their preferences from the `plugins` and `preferences` modules. + * If the `experimentComponent` extension point is available, it executes the extension point and + * appends the returned components to the `experimentRef` element. + * If the `PluginPreferences` extension point is available, it executes the extension point and + * fetches the preferences for each plugin using the `preferences.get` function. + */ + useEffect(() => { + const getActivePlugins = async () => { + const plgs = await plugins.getActive(); + setActivePlugins(plgs); + + if (extensionPoints.get("experimentComponent")) { + const components = await Promise.all(extensionPoints.execute("experimentComponent")); + if (components.length > 0) { + setIsTestAvailable(true); + } + components.forEach((e) => { + if (experimentRef.current) { + // @ts-ignore + experimentRef.current.appendChild(e); + } + }); + } + + if (extensionPoints.get("PluginPreferences")) { + const data = await Promise.all(extensionPoints.execute("PluginPreferences")); + setPreferenceItems(Array.isArray(data) ? data : []); + Promise.all( + (Array.isArray(data) ? data : []).map((e) => + preferences.get(e.pluginName, e.preferenceKey).then((k) => ({ key: e.preferenceKey, value: k })) + ) + ).then((data) => { + setPreferenceValues(data); + }); + } + }; + getActivePlugins(); + }, []); + + /** + * Installs a plugin by calling the `plugins.install` function with the plugin file path. + * If the installation is successful, the application is relaunched using the `coreAPI.relaunch` function. + * @param e - The event object. + */ const install = async (e: any) => { e.preventDefault(); //@ts-ignore @@ -84,27 +86,51 @@ export const Preferences = () => { // Send the filename of the to be installed plugin // to the main process for installation const installed = await plugins.install([pluginFile]); - if (installed) window.electronAPI.relaunch(); + if (installed) window.coreAPI?.relaunch(); }; - // Uninstall a plugin on clicking uninstall + /** + * Uninstalls a plugin by calling the `plugins.uninstall` function with the plugin name. + * If the uninstallation is successful, the application is relaunched using the `coreAPI.relaunch` function. + * @param name - The name of the plugin to uninstall. + */ const uninstall = async (name: string) => { // Send the filename of the to be uninstalled plugin // to the main process for removal const res = await plugins.uninstall([name]); - if (res) window.electronAPI.relaunch(); + if (res) window.coreAPI?.relaunch(); }; - // Update all plugins on clicking update plugins + /** + * Updates a plugin by calling the `window.pluggableElectronIpc.update` function with the plugin name. + * If the update is successful, the application is relaunched using the `window.coreAPI.relaunch` function. + * TODO: should update using window.coreAPI rather than pluggableElectronIpc (Plugin Manager Facades) + * @param plugin - The name of the plugin to update. + */ const update = async (plugin: string) => { if (typeof window !== "undefined") { // @ts-ignore await window.pluggableElectronIpc.update([plugin], true); - window.electronAPI.reloadPlugins(); + window.coreAPI?.relaunch(); } - // plugins.update(active.map((plg) => plg.name)); }; + /** + * Downloads a remote plugin tarball and installs it using the `plugins.install` function. + * If the installation is successful, the application is relaunched using the `coreAPI.relaunch` function. + * @param pluginName - The name of the remote plugin to download and install. + */ + const downloadTarball = async (pluginName: string) => { + setIsLoading(true); + const pluginPath = await window.coreAPI?.installRemotePlugin(pluginName); + const installed = await plugins.install([pluginPath]); + setIsLoading(false); + if (installed) window.coreAPI.relaunch(); + }; + /** + * Notifies plugins of a preference update by executing the `PluginService.OnPreferencesUpdate` event. + * If a timeout is already set, it is cleared before setting a new timeout to execute the event. + */ let timeout: any | undefined = undefined; function notifyPreferenceUpdate() { if (timeout) { @@ -113,17 +139,19 @@ export const Preferences = () => { timeout = setTimeout(() => execute(PluginService.OnPreferencesUpdate), 100); } - useEffect(() => { - if (preferenceItems) { - Promise.all( - preferenceItems.map((e) => - preferences.get(e.pluginName, e.preferenceKey).then((k) => ({ key: e.preferenceKey, value: k })) - ) - ).then((data) => { - setPreferenceValues(data); - }); + /** + * Handles the change event of the plugin file input element by setting the file name state. + * Its to be used to display the plugin file name of the selected file. + * @param event - The change event object. + */ + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + setFileName(file.name); + } else { + setFileName(""); } - }, [preferenceItems]); + }; return (
@@ -200,7 +228,7 @@ export const Preferences = () => { "bg-blue-500 hover:bg-blue-300 rounded-md px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" )} onClick={() => { - window.electronAPI.reloadPlugins(); + window.coreAPI?.reloadPlugins(); }} > Reload Plugins @@ -227,9 +255,7 @@ export const Preferences = () => {
-

- {e.name} -

+

{e.name}

Version: {e.version}

@@ -260,6 +286,53 @@ export const Preferences = () => { ))} + +
+ + Explore Plugins +
+
+ {pluginCatalog + .filter((e: any) => search.trim() === "" || e.name.toLowerCase().includes(search.toLowerCase())) + .map((e: any) => ( +
+
+ + + +
+

{e.name}

+

Version: {e.version}

+
+
+ +

+ {e.description ?? "Jan's Plugin"} +

+ +
+ {e.version !== activePlugins.filter((p) => p.name === e.name)[0]?.version && ( + + )} +
+
+ ))} +
{activePlugins.length > 0 && isTestAvailable && (
@@ -296,6 +369,12 @@ export const Preferences = () => {
+ {isLoading && ( +
+ + Installing... +
+ )} ); }; diff --git a/web/next.config.js b/web/next.config.js index bcca4cd79..2c8be464a 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -1,4 +1,6 @@ /** @type {import('next').NextConfig} */ +const webpack = require("webpack"); + const nextConfig = { output: "export", assetPrefix: ".", @@ -18,6 +20,12 @@ const nextConfig = { // do some stuff here config.optimization.minimize = false; config.optimization.minimizer = []; + config.plugins = [ + ...config.plugins, + new webpack.DefinePlugin({ + PLUGIN_CATALOGS: JSON.stringify("https://cdn.jsdelivr.net/npm/@janhq/plugin-catalog@latest/dist/index.js"), + }), + ]; return config; }, }; diff --git a/web/types/index.d.ts b/web/types/index.d.ts index 2bc4065c6..2abe13da4 100644 --- a/web/types/index.d.ts +++ b/web/types/index.d.ts @@ -1,5 +1,6 @@ export {}; +declare const PLUGIN_CATALOGS: string[]; declare global { interface Window { electronAPI?: any | undefined;