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 { app, BrowserWindow, ipcMain, dialog, shell } from "electron";
import { readdirSync } from "fs"; import { readdirSync, writeFileSync } from "fs";
import { resolve, join, extname } from "path"; import { resolve, join, extname } from "path";
import { rmdir, unlink, createWriteStream } from "fs"; import { rmdir, unlink, createWriteStream } from "fs";
import { init } from "./core/plugin-manager/pluginMgr"; import { init } from "./core/plugin-manager/pluginMgr";
import { setupMenu } from "./utils/menu"; import { setupMenu } from "./utils/menu";
import { dispose } from "./utils/disposable"; import { dispose } from "./utils/disposable";
const pacote = require("pacote");
const request = require("request"); const request = require("request");
const progress = require("request-progress"); const progress = require("request-progress");
const { autoUpdater } = require("electron-updater"); const { autoUpdater } = require("electron-updater");
@ -225,6 +226,24 @@ function handleIPCs() {
}) })
.pipe(createWriteStream(destination)); .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() { function migratePlugins() {

View File

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

View File

@ -23,24 +23,19 @@ contextBridge.exposeInMainWorld("electronAPI", {
deleteFile: (filePath: string) => ipcRenderer.invoke("deleteFile", filePath), deleteFile: (filePath: string) => ipcRenderer.invoke("deleteFile", filePath),
downloadFile: (url: string, path: string) => installRemotePlugin: (pluginName: string) => ipcRenderer.invoke("installRemotePlugin", pluginName),
ipcRenderer.invoke("downloadFile", url, path),
onFileDownloadUpdate: (callback: any) => downloadFile: (url: string, path: string) => ipcRenderer.invoke("downloadFile", url, path),
ipcRenderer.on("FILE_DOWNLOAD_UPDATE", callback),
onFileDownloadError: (callback: any) => onFileDownloadUpdate: (callback: any) => ipcRenderer.on("FILE_DOWNLOAD_UPDATE", callback),
ipcRenderer.on("FILE_DOWNLOAD_ERROR", callback),
onFileDownloadSuccess: (callback: any) => onFileDownloadError: (callback: any) => ipcRenderer.on("FILE_DOWNLOAD_ERROR", callback),
ipcRenderer.on("FILE_DOWNLOAD_COMPLETE", callback),
onAppUpdateDownloadUpdate: (callback: any) => onFileDownloadSuccess: (callback: any) => ipcRenderer.on("FILE_DOWNLOAD_COMPLETE", callback),
ipcRenderer.on("APP_UPDATE_PROGRESS", callback),
onAppUpdateDownloadError: (callback: any) => onAppUpdateDownloadUpdate: (callback: any) => ipcRenderer.on("APP_UPDATE_PROGRESS", callback),
ipcRenderer.on("APP_UPDATE_ERROR", callback),
onAppUpdateDownloadSuccess: (callback: any) => onAppUpdateDownloadError: (callback: any) => ipcRenderer.on("APP_UPDATE_ERROR", callback),
ipcRenderer.on("APP_UPDATE_COMPLETE", callback),
onAppUpdateDownloadSuccess: (callback: any) => ipcRenderer.on("APP_UPDATE_COMPLETE", callback),
}); });

View File

@ -1,17 +1,13 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import { plugins, extensionPoints } from "@/../../electron/core/plugin-manager/execution/index";
setup,
plugins,
extensionPoints,
activationPoints,
} from "@/../../electron/core/plugin-manager/execution/index";
import { ChartPieIcon, CommandLineIcon, PlayIcon } from "@heroicons/react/24/outline"; import { ChartPieIcon, CommandLineIcon, PlayIcon } from "@heroicons/react/24/outline";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import classNames from "classnames"; import classNames from "classnames";
import { PluginService, preferences } from "@janhq/core"; import { PluginService, preferences } from "@janhq/core";
import { execute } from "../../../electron/core/plugin-manager/execution/extension-manager"; import { execute } from "../../../electron/core/plugin-manager/execution/extension-manager";
import LoadingIndicator from "./LoadingIndicator";
export const Preferences = () => { export const Preferences = () => {
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
@ -20,39 +16,35 @@ export const Preferences = () => {
const [preferenceValues, setPreferenceValues] = useState<any[]>([]); const [preferenceValues, setPreferenceValues] = useState<any[]>([]);
const [isTestAvailable, setIsTestAvailable] = useState(false); const [isTestAvailable, setIsTestAvailable] = useState(false);
const [fileName, setFileName] = useState(""); const [fileName, setFileName] = useState("");
const [pluginCatalog, setPluginCatalog] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const experimentRef = useRef(null); const experimentRef = useRef(null);
const preferenceRef = useRef(null); const preferenceRef = useRef(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { /**
const file = event.target.files?.[0]; * Loads the plugin catalog module from a CDN and sets it as the plugin catalog state.
if (file) { * The `webpackIgnore` comment is used to prevent Webpack from bundling the module.
setFileName(file.name); */
} else {
setFileName("");
}
};
useEffect(() => { useEffect(() => {
async function setupPE() { // @ts-ignore
// Enable activation point management import(/* webpackIgnore: true */ PLUGIN_CATALOGS).then((module) => {
setup({ console.log(module);
//@ts-ignore setPluginCatalog(module.default);
importer: (plugin) =>
import(/* webpackIgnore: true */ plugin).catch((err) => {
console.log(err);
}),
}); });
}, []);
// Register all active plugins with their activation points /**
await plugins.registerActive(); * 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.
const activePlugins = async () => { * 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(); const plgs = await plugins.getActive();
setActivePlugins(plgs); setActivePlugins(plgs);
// Activate alls
setTimeout(async () => {
await activationPoints.trigger("init");
if (extensionPoints.get("experimentComponent")) { if (extensionPoints.get("experimentComponent")) {
const components = await Promise.all(extensionPoints.execute("experimentComponent")); const components = await Promise.all(extensionPoints.execute("experimentComponent"));
if (components.length > 0) { if (components.length > 0) {
@ -69,13 +61,23 @@ export const Preferences = () => {
if (extensionPoints.get("PluginPreferences")) { if (extensionPoints.get("PluginPreferences")) {
const data = await Promise.all(extensionPoints.execute("PluginPreferences")); const data = await Promise.all(extensionPoints.execute("PluginPreferences"));
setPreferenceItems(Array.isArray(data) ? data : []); 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) => { const install = async (e: any) => {
e.preventDefault(); e.preventDefault();
//@ts-ignore //@ts-ignore
@ -84,27 +86,51 @@ export const Preferences = () => {
// Send the filename of the to be installed plugin // Send the filename of the to be installed plugin
// to the main process for installation // to the main process for installation
const installed = await plugins.install([pluginFile]); 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) => { const uninstall = async (name: string) => {
// Send the filename of the to be uninstalled plugin // Send the filename of the to be uninstalled plugin
// to the main process for removal // to the main process for removal
const res = await plugins.uninstall([name]); 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) => { const update = async (plugin: string) => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
// @ts-ignore // @ts-ignore
await window.pluggableElectronIpc.update([plugin], true); 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; let timeout: any | undefined = undefined;
function notifyPreferenceUpdate() { function notifyPreferenceUpdate() {
if (timeout) { if (timeout) {
@ -113,17 +139,19 @@ export const Preferences = () => {
timeout = setTimeout(() => execute(PluginService.OnPreferencesUpdate), 100); timeout = setTimeout(() => execute(PluginService.OnPreferencesUpdate), 100);
} }
useEffect(() => { /**
if (preferenceItems) { * Handles the change event of the plugin file input element by setting the file name state.
Promise.all( * Its to be used to display the plugin file name of the selected file.
preferenceItems.map((e) => * @param event - The change event object.
preferences.get(e.pluginName, e.preferenceKey).then((k) => ({ key: e.preferenceKey, value: k })) */
) const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
).then((data) => { const file = event.target.files?.[0];
setPreferenceValues(data); if (file) {
}); setFileName(file.name);
} else {
setFileName("");
} }
}, [preferenceItems]); };
return ( return (
<div className="w-full h-screen overflow-scroll"> <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" "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={() => { onClick={() => {
window.electronAPI.reloadPlugins(); window.coreAPI?.reloadPlugins();
}} }}
> >
Reload Plugins 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="" /> <img className="h-14 w-14 rounded-md" src={e.icon ?? "icons/app_icon.svg"} alt="" />
</span> </span>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="text-xl font-bold tracking-tight text-gray-900 dark:text-white"> <p className="text-xl font-bold tracking-tight text-gray-900 dark:text-white">{e.name}</p>
{e.name}
</p>
<p className="font-normal text-gray-700 dark:text-gray-400">Version: {e.version}</p> <p className="font-normal text-gray-700 dark:text-gray-400">Version: {e.version}</p>
</div> </div>
</div> </div>
@ -260,6 +286,53 @@ export const Preferences = () => {
</div> </div>
))} ))}
</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 && ( {activePlugins.length > 0 && isTestAvailable && (
<div className="flex flex-row items-center my-4"> <div className="flex flex-row items-center my-4">
<PlayIcon width={30} /> <PlayIcon width={30} />
@ -296,6 +369,12 @@ export const Preferences = () => {
</div> </div>
</div> </div>
</main> </main>
{isLoading && (
<div className="z-50 absolute inset-0 bg-gray-900/90 flex justify-center items-center text-white">
<LoadingIndicator />
Installing...
</div>
)}
</div> </div>
); );
}; };

View File

@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const webpack = require("webpack");
const nextConfig = { const nextConfig = {
output: "export", output: "export",
assetPrefix: ".", assetPrefix: ".",
@ -18,6 +20,12 @@ const nextConfig = {
// do some stuff here // do some stuff here
config.optimization.minimize = false; config.optimization.minimize = false;
config.optimization.minimizer = []; 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; return config;
}, },
}; };

View File

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