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
This commit is contained in:
Louis 2023-10-19 22:53:55 +07:00 committed by GitHub
parent 1f40c26cc5
commit c5925b6a79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 196 additions and 92 deletions

View File

@ -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() {

View File

@ -37,7 +37,9 @@
"icon": "icons/icon.png"
},
"linux": {
"target": ["deb"],
"target": [
"deb"
],
"category": "Utility",
"icon": "icons/"
},

View File

@ -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),
});

View File

@ -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<string>("");
@ -20,39 +16,35 @@ export const Preferences = () => {
const [preferenceValues, setPreferenceValues] = useState<any[]>([]);
const [isTestAvailable, setIsTestAvailable] = useState(false);
const [fileName, setFileName] = useState("");
const [pluginCatalog, setPluginCatalog] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const experimentRef = useRef(null);
const preferenceRef = useRef(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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);
}),
import(/* webpackIgnore: true */ PLUGIN_CATALOGS).then((module) => {
console.log(module);
setPluginCatalog(module.default);
});
}, []);
// Register all active plugins with their activation points
await plugins.registerActive();
}
const activePlugins = async () => {
/**
* 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);
// Activate alls
setTimeout(async () => {
await activationPoints.trigger("init");
if (extensionPoints.get("experimentComponent")) {
const components = await Promise.all(extensionPoints.execute("experimentComponent"));
if (components.length > 0) {
@ -69,13 +61,23 @@ export const Preferences = () => {
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);
});
}
}, 500);
};
setupPE().then(() => activePlugins());
getActivePlugins();
}, []);
// Install a new plugin on clicking the install button
/**
* 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<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setFileName(file.name);
} else {
setFileName("");
}
}, [preferenceItems]);
};
return (
<div className="w-full h-screen overflow-scroll">
@ -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 = () => {
<img className="h-14 w-14 rounded-md" src={e.icon ?? "icons/app_icon.svg"} alt="" />
</span>
<div className="flex flex-col">
<p className="text-xl font-bold tracking-tight text-gray-900 dark:text-white">
{e.name}
</p>
<p className="text-xl font-bold tracking-tight text-gray-900 dark:text-white">{e.name}</p>
<p className="font-normal text-gray-700 dark:text-gray-400">Version: {e.version}</p>
</div>
</div>
@ -260,6 +286,53 @@ export const Preferences = () => {
</div>
))}
</div>
<div className="flex flex-row items-center my-4">
<CommandLineIcon width={30} />
Explore Plugins
</div>
<div className="grid grid-cols-2 items-stretch gap-4">
{pluginCatalog
.filter((e: any) => search.trim() === "" || e.name.toLowerCase().includes(search.toLowerCase()))
.map((e: any) => (
<div
key={e.name}
data-testid="plugin-item"
className="flex flex-col h-full p-6 bg-white border border-gray-200 rounded-sm dark:border-gray-300"
>
<div className="flex flex-row space-x-2 items-center">
<span className="relative inline-block mt-1">
<img className="h-14 w-14 rounded-md" src={e.icon ?? "icons/app_icon.svg"} alt="" />
</span>
<div className="flex flex-col">
<p className="text-xl font-bold tracking-tight text-gray-900 dark:text-white">{e.name}</p>
<p className="font-normal text-gray-700 dark:text-gray-400">Version: {e.version}</p>
</div>
</div>
<p className="flex-1 mt-2 text-sm font-normal text-gray-500 dark:text-gray-400 w-full">
{e.description ?? "Jan's Plugin"}
</p>
<div className="flex flex-row space-x-5">
{e.version !== activePlugins.filter((p) => p.name === e.name)[0]?.version && (
<button
type="submit"
onClick={() => downloadTarball(e.name)}
className={classNames(
"mt-5 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-red-600",
activePlugins.some((p) => p.name === e.name)
? "bg-blue-500 hover:bg-blue-600"
: "bg-red-500 hover:bg-red-600"
)}
>
{activePlugins.some((p) => p.name === e.name) ? "Update" : "Install"}
</button>
)}
</div>
</div>
))}
</div>
{activePlugins.length > 0 && isTestAvailable && (
<div className="flex flex-row items-center my-4">
<PlayIcon width={30} />
@ -296,6 +369,12 @@ export const Preferences = () => {
</div>
</div>
</main>
{isLoading && (
<div className="z-50 absolute inset-0 bg-gray-900/90 flex justify-center items-center text-white">
<LoadingIndicator />
Installing...
</div>
)}
</div>
);
};

View File

@ -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;
},
};

View File

@ -1,5 +1,6 @@
export {};
declare const PLUGIN_CATALOGS: string[];
declare global {
interface Window {
electronAPI?: any | undefined;