Skip to content

Commit

Permalink
feat: explore plugins from the npm repository and install them remote…
Browse files Browse the repository at this point in the history
…ly (#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
  • Loading branch information
louis-menlo authored Oct 19, 2023
1 parent 1f40c26 commit c5925b6
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 86 deletions.
21 changes: 20 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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() {
Expand Down
4 changes: 3 additions & 1 deletion electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
"icon": "icons/icon.png"
},
"linux": {
"target": ["deb"],
"target": [
"deb"
],
"category": "Utility",
"icon": "icons/"
},
Expand Down
23 changes: 9 additions & 14 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
219 changes: 149 additions & 70 deletions web/app/_components/Preferences.tsx
Original file line number Diff line number Diff line change
@@ -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>("");
Expand All @@ -20,62 +16,68 @@ 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);
}),
});

// Register all active plugins with their activation points
await plugins.registerActive();
}
// @ts-ignore
import(/* webpackIgnore: true */ PLUGIN_CATALOGS).then((module) => {
console.log(module);
setPluginCatalog(module.default);
});
}, []);

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) {
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 : []);
if (extensionPoints.get("experimentComponent")) {
const components = await Promise.all(extensionPoints.execute("experimentComponent"));
if (components.length > 0) {
setIsTestAvailable(true);
}
}, 500);
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);
});
}
};
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
Expand All @@ -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) {
Expand All @@ -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">
Expand Down Expand Up @@ -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
Expand All @@ -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>
Expand Down Expand Up @@ -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} />
Expand Down Expand Up @@ -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>
);
};
Loading

0 comments on commit c5925b6

Please sign in to comment.