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:
parent
1f40c26cc5
commit
c5925b6a79
@ -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() {
|
||||
|
||||
@ -37,7 +37,9 @@
|
||||
"icon": "icons/icon.png"
|
||||
},
|
||||
"linux": {
|
||||
"target": ["deb"],
|
||||
"target": [
|
||||
"deb"
|
||||
],
|
||||
"category": "Utility",
|
||||
"icon": "icons/"
|
||||
},
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}),
|
||||
// @ts-ignore
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
1
web/types/index.d.ts
vendored
1
web/types/index.d.ts
vendored
@ -1,5 +1,6 @@
|
||||
export {};
|
||||
|
||||
declare const PLUGIN_CATALOGS: string[];
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: any | undefined;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user