diff --git a/plugins/multiplayer-button/plugin.json b/plugins/multiplayer-button/meta.json similarity index 100% rename from plugins/multiplayer-button/plugin.json rename to plugins/multiplayer-button/meta.json diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs index e05c2a7..9fdf26a 100644 --- a/src-tauri/src/commands/plugins.rs +++ b/src-tauri/src/commands/plugins.rs @@ -26,3 +26,23 @@ pub fn list_directory(path: String) -> Result, String> { } Ok(results) } + +#[tauri::command] +pub fn create_plugin_dir(app: tauri::AppHandle, plugin_id: String) -> Result { + let dir = crate::util::get_app_dir(&app) + .join("plugins") + .join(&plugin_id); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.to_string_lossy().to_string()) +} + +#[tauri::command] +pub fn remove_plugin_dir(app: tauri::AppHandle, plugin_id: String) -> Result<(), String> { + let dir = crate::util::get_app_dir(&app) + .join("plugins") + .join(&plugin_id); + if dir.exists() { + fs::remove_dir_all(&dir).map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b249efd..ccae29c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -98,6 +98,8 @@ pub fn run() { relay::join_game, plugins::get_plugins_dir, plugins::list_directory, + plugins::create_plugin_dir, + plugins::remove_plugin_dir, ]) .setup(|app| { let app_handle = app.handle().clone(); diff --git a/src/components/views/WorkshopView.tsx b/src/components/views/WorkshopView.tsx index 09bfc3b..0134bfe 100644 --- a/src/components/views/WorkshopView.tsx +++ b/src/components/views/WorkshopView.tsx @@ -22,20 +22,25 @@ import { InstalledWorkshopPackage, type CustomEdition, } from "../../services/TauriService"; +import { PluginManager } from "../../plugins/PluginManager"; const REGISTRY_URL = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/registry.json"; const VERSIONS_URL = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/versions.json"; +const PLUGINS_URL = + "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/plugins.json"; const RAW_BASE = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main"; const VERSIONS_BASE = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/.00versions"; +const PLUGINS_BASE = + "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/.00plugins"; const BYTEBUKKIT_BASE = "https://emerald-bytebukkit.onrender.com"; const SERVERS_URL = "https://raw.githubusercontent.com/bytebukkit/servers/refs/heads/main/servers.json"; const SERVERS_BASE = "https://raw.githubusercontent.com/bytebukkit/servers/refs/heads/main"; -const CATEGORY_TABS = ["Skin", "Texture", "World", "Mod", "DLC"] as const; +const CATEGORY_TABS = ["Skin", "Texture", "World", "Mod", "DLC", "Plugins"] as const; const UTILITY_TABS = ["Versions", "Installed", "Search"] as const; const SERVER_TABS = ["Server", "Server Plugins"] as const; const ALL_TABS = [...CATEGORY_TABS, ...UTILITY_TABS, ...SERVER_TABS] as const; @@ -61,6 +66,9 @@ interface RegistryPackage { server_address?: string; server_discord?: string; server_type?: string; + main?: string; + permissions?: string[]; + files?: string[]; } interface ServerListing { @@ -92,6 +100,18 @@ interface ByteBukkitAddon { displayName: string; } +interface PluginRegistryEntry { + id: string; + name: string; + version: string; + author: string; + description: string; + extended_description?: string; + main: string; + permissions?: string[]; + files?: string[]; +} + const COLS = 4; const WorkshopView = memo(function WorkshopView() { const { setActiveView } = useUI(); @@ -117,6 +137,8 @@ const WorkshopView = memo(function WorkshopView() { const [serverListingCategory, setServerListingCategory] = useState("all"); const [savedServers, setSavedServers] = useState>(new Set()); + const [pluginPackages, setPluginPackages] = useState([]); + const [installedPluginIds, setInstalledPluginIds] = useState>(new Set()); const refreshInstalled = useCallback(async () => { try { const data = await TauriService.workshopListInstalled(); @@ -126,6 +148,12 @@ const WorkshopView = memo(function WorkshopView() { } }, []); + const refreshInstalledPlugins = useCallback(() => { + const ids = new Set(); + PluginManager.instance.plugins.forEach((_, id) => ids.add(id)); + setInstalledPluginIds(ids); + }, []); + useEffect(() => { containerRef.current?.focus(); refreshInstalled(); @@ -145,17 +173,36 @@ const WorkshopView = memo(function WorkshopView() { Promise.all([ fetch(REGISTRY_URL).then((r) => r.json()), fetch(VERSIONS_URL).then((r) => r.json()), + fetch(PLUGINS_URL).then((r) => r.json()).catch(() => null), ]) - .then(([registryData, versionsData]) => { + .then(([registryData, versionsData, pluginsData]) => { setAllPackages(registryData.packages ?? []); setVersionPackages(versionsData.versionlist ?? []); + if (pluginsData?.pluginlist) { + setPluginPackages( + pluginsData.pluginlist.map((entry: PluginRegistryEntry) => ({ + id: entry.id, + name: entry.name, + version: entry.version, + author: entry.author, + description: entry.description, + extended_description: entry.extended_description || "", + category: ["Plugin"], + thumbnail: "", + main: entry.main, + permissions: entry.permissions, + files: entry.files, + })), + ); + } setLoading(false); }) .catch((e) => { setError(e.message ?? "Failed to load registry"); setLoading(false); }); - }, []); + refreshInstalledPlugins(); + }, [refreshInstalledPlugins]); useEffect(() => { fetch(`${BYTEBUKKIT_BASE}/api/addons?limit=500`) @@ -217,7 +264,7 @@ const WorkshopView = memo(function WorkshopView() { }, [serverListings]); const getInstalledEntries = useCallback( - (pkgId: string) => { + (pkgId: string, pkgVersion?: string) => { if (activeTab === "Versions") { const isAdded = config.customEditions?.some( (e: CustomEdition) => @@ -236,14 +283,20 @@ const WorkshopView = memo(function WorkshopView() { } return []; } + if (activeTab === "Plugins") { + return installedPluginIds.has(pkgId) + ? [{ packageId: pkgId, instanceId: pkgId, version: pkgVersion || "0.0.0" }] as InstalledWorkshopPackage[] + : []; + } if (activeTab === "Server Plugins" || activeTab === "Server") return []; return installedPkgs.filter((p) => p.packageId === pkgId); }, - [installedPkgs, activeTab, config.customEditions, versionPackages], + [installedPkgs, activeTab, config.customEditions, versionPackages, installedPluginIds], ); const isInstalled = useCallback( (pkgId: string) => { + if (activeTab === "Plugins") return installedPluginIds.has(pkgId); if (activeTab === "Server Plugins" || activeTab === "Server") return false; if (activeTab === "Versions") { return ( @@ -256,11 +309,14 @@ const WorkshopView = memo(function WorkshopView() { } return installedPkgs.some((p) => p.packageId === pkgId); }, - [installedPkgs, activeTab, config.customEditions, versionPackages], + [installedPkgs, activeTab, config.customEditions, versionPackages, installedPluginIds], ); const hasUpdate = useCallback( (pkg: RegistryPackage) => { + if (activeTab === "Plugins") { + return false; + } if ( activeTab === "Versions" || activeTab === "Server Plugins" || @@ -308,7 +364,7 @@ const WorkshopView = memo(function WorkshopView() { : serverPlugins.filter((pkg) => pkg.category.includes(serverCategory), ) - : activeTab === "Server" + : activeTab === "Server" ? search.trim() ? serverListings.filter((pkg) => { if ( @@ -328,6 +384,17 @@ const WorkshopView = memo(function WorkshopView() { : serverListings.filter((pkg) => pkg.category.includes(serverListingCategory), ) + : activeTab === "Plugins" + ? search.trim() + ? pluginPackages.filter((pkg) => { + const q = search.toLowerCase(); + return ( + pkg.name.toLowerCase().includes(q) || + pkg.author.toLowerCase().includes(q) || + pkg.description.toLowerCase().includes(q) + ); + }) + : pluginPackages : (activeTab === "Versions" ? versionPackages : allPackages).filter( (pkg) => { const matchesTab = @@ -454,11 +521,11 @@ const WorkshopView = memo(function WorkshopView() { playPressSound(); } else if (e.key === "ArrowDown") { e.preventDefault(); - setFocusedIdx((p) => Math.min((p ?? -COLS) + COLS, count - 1)); + setFocusedIdx((p) => Math.min((p ?? -1) + (isPluginTab ? 1 : COLS), count - 1)); playPressSound(); } else if (e.key === "ArrowUp") { e.preventDefault(); - setFocusedIdx((p) => Math.max((p ?? COLS) - COLS, 0)); + setFocusedIdx((p) => Math.max((p ?? 1) - (isPluginTab ? 1 : COLS), 0)); playPressSound(); } else if (e.key === "Enter" && focusedIdx !== null) { const pkg = filteredItems[focusedIdx]; @@ -481,10 +548,12 @@ const WorkshopView = memo(function WorkshopView() { const isSearchTab = activeTab === "Search"; const isInstalledTab = activeTab === "Installed"; const isVersionTab = activeTab === "Versions"; + const isPluginTab = activeTab === "Plugins"; const showSearch = isSearchTab || isInstalledTab || isVersionTab || + isPluginTab || activeTab === "Server Plugins" || activeTab === "Server"; return ( @@ -600,11 +669,13 @@ const WorkshopView = memo(function WorkshopView() { ? "FILTER INSTALLED..." : isVersionTab ? "FILTER VERSIONS..." - : activeTab === "Server Plugins" + : isPluginTab ? "FILTER PLUGINS..." - : activeTab === "Server" - ? "FILTER SERVERS..." - : "ENTER KEYWORDS..." + : activeTab === "Server Plugins" + ? "FILTER PLUGINS..." + : activeTab === "Server" + ? "FILTER SERVERS..." + : "ENTER KEYWORDS..." } spellCheck={false} autoFocus={isSearchTab} @@ -733,13 +804,32 @@ const WorkshopView = memo(function WorkshopView() { {isInstalledTab ? "Nothing Installed" - : activeTab === "Server Plugins" + : activeTab === "Plugins" ? "No plugins available" - : activeTab === "Server" - ? "No servers available" - : "No results"} + : activeTab === "Server Plugins" + ? "No plugins available" + : activeTab === "Server" + ? "No servers available" + : "No results"} + ) : isPluginTab ? ( +
+ {filteredItems.map((pkg, i) => ( + setFocusedIdx(i)} + onClick={() => openModal(pkg)} + installed={isInstalled(pkg.id)} + hasUpdate={hasUpdate(pkg)} + isVersionTab={isVersionTab} + isPluginTab={isPluginTab} + /> + ))} +
) : (
+ ) : isPluginTab ? ( +
+ {filteredItems.map((pkg, i) => ( + setFocusedIdx(i)} + onClick={() => openModal(pkg)} + installed={isInstalled(pkg.id)} + hasUpdate={hasUpdate(pkg)} + isVersionTab={isVersionTab} + isPluginTab={isPluginTab} + /> + ))} +
) : (
))}
@@ -849,12 +957,13 @@ const WorkshopView = memo(function WorkshopView() { pkg={selectedPkg} onClose={closeModal} playPressSound={playPressSound} - installedEntries={getInstalledEntries(selectedPkg.id)} - onInstallComplete={refreshInstalled} - onUninstallComplete={refreshInstalled} + installedEntries={getInstalledEntries(selectedPkg.id, selectedPkg.version)} + onInstallComplete={() => { refreshInstalled(); refreshInstalledPlugins(); }} + onUninstallComplete={() => { refreshInstalled(); refreshInstalledPlugins(); }} isVersionTab={activeTab === "Versions"} isServerTab={activeTab === "Server Plugins"} isGameServerTab={activeTab === "Server"} + isPluginTab={isPluginTab} isSaved={ selectedPkg.server_address ? savedServers.has(selectedPkg.server_address) @@ -877,6 +986,7 @@ function PackageCard({ installed, hasUpdate, isVersionTab, + isPluginTab, }: { pkg: RegistryPackage; index: number; @@ -886,6 +996,7 @@ function PackageCard({ installed: boolean; hasUpdate: boolean; isVersionTab?: boolean; + isPluginTab?: boolean; }) { const thumbnailUrl = pkg.thumbnail.startsWith("http") ? pkg.thumbnail @@ -898,14 +1009,15 @@ function PackageCard({ data-card={index} onMouseEnter={onHover} onClick={onClick} - className={`flex flex-col cursor-pointer border-2 ${focused ? "border-[#FFFF55] z-10" : "border-[#333]"} rounded-sm overflow-hidden bg-black/40`} + className={`flex flex-col cursor-pointer border-2 ${focused ? "border-[#FFFF55] z-10" : "border-[#333]"} rounded-sm overflow-hidden ${isPluginTab ? "bg-black/80" : "bg-black/40"}`} style={{ - backgroundImage: "url('/images/frame_background.png')", + backgroundImage: isPluginTab ? "url('/images/Button_Background2.png')" : "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", boxShadow: focused ? "0 0 20px rgba(255, 255, 85, 0.2)" : "none", }} > + {pkg.thumbnail ? (
@@ -947,6 +1059,9 @@ function PackageCard({
)} + ) : ( +
+ )}
void; }) { @@ -999,7 +1116,9 @@ function PackageModal({ ? pkg.thumbnail : isVersionTab ? `${VERSIONS_BASE}/${pkg.id}/${pkg.thumbnail}` - : `${RAW_BASE}/${pkg.id}/${pkg.thumbnail}`; + : isPluginTab || !pkg.thumbnail + ? "" + : `${RAW_BASE}/${pkg.id}/${pkg.thumbnail}`; const [imgError, setImgError] = useState(false); const [modalFocus, setModalFocus] = useState< "install" | "uninstall" | "close" @@ -1090,6 +1209,8 @@ function PackageModal({ } catch (e) { console.error(e); } + } else if (isPluginTab) { + setShowInstall(true); } else { setShowInstall(true); } @@ -1105,6 +1226,12 @@ function PackageModal({ ? hasInstalled ? "ADDED" : "ADD" + : isPluginTab + ? hasInstalled + ? needsUpdate + ? "UPDATE" + : "REINSTALL" + : "INSTALL" : !hasInstalled ? "INSTALL" : needsUpdate @@ -1114,6 +1241,18 @@ function PackageModal({ <>
e.stopPropagation()} className="flex flex-col w-[640px] max-h-[85vh] overflow-hidden font-['Mojangles'] mc-options-bg"> + {isPluginTab ? ( +
+
+ + {pkg.name} + + + By {pkg.author} + +
+
+ ) : (
{imgError ? (
@@ -1161,6 +1300,7 @@ function PackageModal({
)}
+ )}
@@ -1437,6 +1577,7 @@ function PackageModal({ onInstallComplete(); }} playPressSound={playPressSound} + isPluginTab={isPluginTab} /> )} @@ -1451,6 +1592,7 @@ function PackageModal({ }} playPressSound={playPressSound} isVersionTab={isVersionTab} + isPluginTab={isPluginTab} /> )} @@ -1462,10 +1604,12 @@ function InstallModal({ pkg, onClose, playPressSound, + isPluginTab, }: { pkg: RegistryPackage; onClose: () => void; playPressSound: () => void; + isPluginTab?: boolean; }) { const game = useContext(GameContext); const availableEditions = @@ -1475,6 +1619,61 @@ function InstallModal({ "idle" | "installing" | "success" | "error" >("idle"); const [errorMsg, setErrorMsg] = useState(null); + + const installPlugin = useCallback(async () => { + setStatus("installing"); + setErrorMsg(null); + playPressSound(); + try { + const pluginsDir = await TauriService.getPluginsDir(); + const pluginDir = `${pluginsDir}/${pkg.id}`; + + await TauriService.createPluginDir(pkg.id); + + const encoder = new TextEncoder(); + + const manifest = { + id: pkg.id, + name: pkg.name, + version: pkg.version, + author: pkg.author, + description: pkg.description, + extended_description: pkg.extended_description || "", + main: pkg.main || "main.js", + permissions: pkg.permissions || [], + }; + await TauriService.writeBinaryFile( + `${pluginDir}/plugin.json`, + encoder.encode(JSON.stringify(manifest, null, 2)), + ); + + const pluginBaseUrl = `${RAW_BASE}/.00plugins/${pkg.id}`; + + const allFiles = [pkg.main || "main.js", ...(pkg.files || [])]; + for (const file of allFiles) { + const res = await TauriService.httpProxyRequest("GET", `${pluginBaseUrl}/${file}`, null, {}); + if (res.status !== 200) throw new Error(`Failed to download ${file}`); + await TauriService.writeBinaryFile( + `${pluginDir}/${file}`, + encoder.encode(res.body), + ); + } + + await PluginManager.instance.reload(); + setStatus("success"); + } catch (e: unknown) { + console.error(e); + setStatus("error"); + setErrorMsg(e instanceof Error ? e.message : typeof e === "string" ? e : "Unknown error"); + } + }, [pkg, playPressSound]); + + useEffect(() => { + if (isPluginTab && status === "idle") { + installPlugin(); + } + }, [isPluginTab, status, installPlugin]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { e.stopPropagation(); @@ -1547,10 +1746,10 @@ function InstallModal({ >
- INSTALL CONTENT + {isPluginTab ? "INSTALL PLUGIN" : "INSTALL CONTENT"} - Target Edition for "{pkg.name}" + {isPluginTab ? `Installing "${pkg.name}"` : `Target Edition for "${pkg.name}"`}
@@ -1561,8 +1760,25 @@ function InstallModal({ Installing... - Downloading and extracting assets + {isPluginTab ? "Downloading plugin files" : "Downloading and extracting assets"} + {isPluginTab && pkg.permissions && pkg.permissions.length > 0 && ( +
+ + Requested Permissions + +
+ {pkg.permissions.map((perm) => ( + + {perm} + + ))} +
+
+ )}
)} {status === "success" && ( @@ -1584,7 +1800,10 @@ function InstallModal({ {errorMsg}
)} - {status === "idle" && + {status === "idle" && !isPluginTab && (availableEditions.length === 0 ? (
@@ -1632,12 +1851,14 @@ function UninstallModal({ onClose, playPressSound, isVersionTab, + isPluginTab, }: { pkg: RegistryPackage; installedEntries: InstalledWorkshopPackage[]; onClose: () => void; playPressSound: () => void; isVersionTab?: boolean; + isPluginTab?: boolean; }) { const { deleteCustomEdition } = useGame(); const game = useContext(GameContext); @@ -1651,6 +1872,27 @@ function UninstallModal({ return ed?.name ?? instanceId; }; + const uninstallPlugin = useCallback(async () => { + setStatus("removing"); + setErrorMsg(null); + playPressSound(); + try { + await TauriService.removePluginDir(pkg.id); + await PluginManager.instance.reload(); + setStatus("success"); + } catch (e: unknown) { + console.error(e); + setStatus("error"); + setErrorMsg(e instanceof Error ? e.message : typeof e === "string" ? e : "Unknown error"); + } + }, [pkg.id, playPressSound]); + + useEffect(() => { + if (isPluginTab && status === "idle") { + uninstallPlugin(); + } + }, [isPluginTab, status, uninstallPlugin]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { e.stopPropagation(); @@ -1722,10 +1964,10 @@ function UninstallModal({ >
- REMOVE CONTENT + {isPluginTab ? "REMOVE PLUGIN" : "REMOVE CONTENT"} - Select edition to remove "{pkg.name}" + {isPluginTab ? `Remove "${pkg.name}"` : `Select edition to remove "${pkg.name}"`}
@@ -1759,7 +2001,10 @@ function UninstallModal({ {errorMsg}
)} - {status === "idle" && + {status === "idle" && !isPluginTab && installedEntries.map((entry, i) => (
{ + this._initialized = false; + this.hooks.clear(); + this.views.clear(); + this.actions.clear(); + this.plugins.clear(); + this.enabledMap.clear(); + this.pluginEvents.clear(); + await this.init(); + } + async init(): Promise { if (this._initialized) return; let pluginsDir: string; diff --git a/src/services/TauriService.ts b/src/services/TauriService.ts index d7ad82b..4fb0a7f 100644 --- a/src/services/TauriService.ts +++ b/src/services/TauriService.ts @@ -336,6 +336,14 @@ export class TauriService { return invoke("get_plugins_dir"); } + static async createPluginDir(pluginId: string): Promise { + return invoke("create_plugin_dir", { pluginId }); + } + + static async removePluginDir(pluginId: string): Promise { + return invoke("remove_plugin_dir", { pluginId }); + } + static async listDirectory(path: string): Promise> { return invoke("list_directory", { path }); }