diff --git a/plugins/multiplayer-button/main.js b/plugins/multiplayer-button/main.js new file mode 100644 index 0000000..fbbba0f --- /dev/null +++ b/plugins/multiplayer-button/main.js @@ -0,0 +1,7 @@ +api.actions.register("home-menu", { + id: "multiplayer", + label: "Multiplayer", + onClick: function () { + api.views.navigate("lcelive"); + }, +}); diff --git a/plugins/multiplayer-button/plugin.json b/plugins/multiplayer-button/plugin.json new file mode 100644 index 0000000..2fbe4be --- /dev/null +++ b/plugins/multiplayer-button/plugin.json @@ -0,0 +1,9 @@ +{ + "id": "multiplayer-button", + "name": "Multiplayer Button", + "version": "1.0.0", + "author": "neoapps", + "description": "Adds a Multiplayer button to the home menu that opens LCELive", + "main": "main.js", + "permissions": [] +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 41461d5..11229a2 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod download; pub mod file_dialogs; pub mod game; pub mod macos_setup; +pub mod plugins; pub mod proxy_cmd; pub mod runners; pub mod skin; diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs new file mode 100644 index 0000000..e05c2a7 --- /dev/null +++ b/src-tauri/src/commands/plugins.rs @@ -0,0 +1,28 @@ +use serde::Serialize; +use std::fs; +#[derive(Serialize)] +pub struct DirEntry { + pub name: String, + pub is_dir: bool, +} + +#[tauri::command] +pub fn get_plugins_dir(app: tauri::AppHandle) -> Result { + let dir = crate::util::get_app_dir(&app).join("plugins"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.to_string_lossy().to_string()) +} + +#[tauri::command] +pub fn list_directory(path: String) -> Result, String> { + let entries = fs::read_dir(&path).map_err(|e| e.to_string())?; + let mut results = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + results.push(DirEntry { + name: entry.file_name().to_string_lossy().to_string(), + is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false), + }); + } + Ok(results) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index abe9caf..b249efd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ use commands::download; use commands::file_dialogs; use commands::game; use commands::macos_setup; +use commands::plugins; use commands::proxy_cmd; use commands::runners; use commands::skin; @@ -95,6 +96,8 @@ pub fn run() { relay::stop_proxy, relay::stop_all_proxies, relay::join_game, + plugins::get_plugins_dir, + plugins::list_directory, ]) .setup(|app| { let app_handle = app.handle().clone(); diff --git a/src/components/plugins/PluginViewContainer.tsx b/src/components/plugins/PluginViewContainer.tsx new file mode 100644 index 0000000..13857d0 --- /dev/null +++ b/src/components/plugins/PluginViewContainer.tsx @@ -0,0 +1,76 @@ +import React, { Component, type ReactNode, type ErrorInfo } from "react"; +import { motion } from "framer-motion"; +import type { PluginViewRegistration } from "../../plugins/types"; +import { PluginManager } from "../../plugins/PluginManager"; +interface Props { + registry: PluginViewRegistration; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +class PluginErrorBoundary extends Component< + { children: ReactNode; pluginId: string }, + State +> { + constructor(props: { children: ReactNode; pluginId: string }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error(`[Plugin ${this.props.pluginId}] Render error:`, error, info); + } + + render(): ReactNode { + if (this.state.hasError) { + return ( +
+ Plugin Error + + {this.state.error?.message ?? "Unknown error"} + +
+ ); + } + return this.props.children; + } +} + +export function PluginViewContainer({ registry }: Props) { + const pm = PluginManager.instance; + const plugin = pm.plugins.get(registry.pluginId); + if (!plugin) return null; + let Component: React.ComponentType>; + try { + Component = registry.factory(plugin.api); + } catch (err) { + console.error(`[Plugin] Error creating component for ${registry.id}:`, err); + return ( +
+ Failed to create view +
+ ); + } + + return ( + + + + + + ); +} diff --git a/src/components/views/HomeView.tsx b/src/components/views/HomeView.tsx index 5b8e032..90e0f13 100644 --- a/src/components/views/HomeView.tsx +++ b/src/components/views/HomeView.tsx @@ -6,6 +6,7 @@ import { useAudio, useGame, } from "../../context/LauncherContext"; +import { usePluginActions } from "../../plugins/PluginContext"; import type { Edition } from "../../types/edition"; const HomeView = memo(function HomeView() { @@ -23,6 +24,7 @@ const HomeView = memo(function HomeView() { stopGame, updatesAvailable, } = useGame(); + const pluginActions = usePluginActions("home-menu"); const isFocusedSection = focusSection === "menu"; const selectedEdition = editions.find((e: Edition) => e.id === profile); @@ -34,8 +36,8 @@ const HomeView = memo(function HomeView() { const hasAnyInstall = installs.length > 0; const buttonsVal = useMemo( - () => [ - { + () => { + const mainBtn = { label: !hasAnyInstall ? "Install a version" : isGameRunning @@ -56,36 +58,54 @@ const HomeView = memo(function HomeView() { : () => toggleInstall(profile), isDanger: isGameRunning, disabled: isDownloading, - }, - { - label: "Help & Options", - action: () => setActiveView("settings"), + id: "main-action", + }; + + const pluginBtns = pluginActions.map((a) => ({ + label: a.label, + action: () => a.onClick(), + isDanger: false, disabled: false, - id: "settings", - }, - { - label: "Versions", - action: () => setActiveView("versions"), - disabled: false, - id: "versions", - }, - { - label: "Workshop", - action: () => setActiveView("workshop"), - disabled: false, - id: "workshop", - }, - { - label: "Developer Tools", - action: () => setActiveView("devtools"), - disabled: false, - id: "devtools", - }, - ], + id: a.id, + })); + + const menuBtns = [ + { + label: "Help & Options", + action: () => setActiveView("settings"), + isDanger: false, + disabled: false, + id: "settings", + }, + { + label: "Versions", + action: () => setActiveView("versions"), + isDanger: false, + disabled: false, + id: "versions", + }, + { + label: "Workshop", + action: () => setActiveView("workshop"), + isDanger: false, + disabled: false, + id: "workshop", + }, + { + label: "Developer Tools", + action: () => setActiveView("devtools"), + isDanger: false, + disabled: false, + id: "devtools", + }, + ]; + + return [mainBtn, ...pluginBtns, ...menuBtns]; + }, [ isDownloading, hasAnyInstall, isInstalled, selectedVersionName, handleLaunch, toggleInstall, profile, setActiveView, - isGameRunning, stopGame, + isGameRunning, stopGame, pluginActions, ], ); diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index c90cc01..339a333 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo, memo } from "react"; +import { useState, useEffect, useRef, useMemo, memo, useCallback } from "react"; import { motion } from "framer-motion"; import { TauriService, Runner } from "../../services/TauriService"; import { usePlatform } from "../../hooks/usePlatform"; @@ -8,6 +8,7 @@ import { useAudio, useGame, } from "../../context/LauncherContext"; +import { PluginManager, type PluginInfo } from "../../plugins/PluginManager"; const SettingsView = memo(function SettingsView() { const { setActiveView } = useUI(); @@ -60,9 +61,10 @@ const SettingsView = memo(function SettingsView() { const { isLinux, isMac } = usePlatform(); const [focusIndex, setFocusIndex] = useState(null); const [currentSubMenu, setCurrentSubMenu] = useState< - "main" | "audio" | "video" | "controls" | "launcher" | "game" + "main" | "audio" | "video" | "controls" | "launcher" | "game" | "plugins" >("main"); const [runners, setRunners] = useState([]); + const [pluginsInfo, setPluginsInfo] = useState([]); const containerRef = useRef(null); const [argsInput, setArgsInput] = useState(""); const [prefixInput, setPrefixInput] = useState(""); @@ -77,6 +79,16 @@ const SettingsView = memo(function SettingsView() { TauriService.getAvailableRunners().then(setRunners); }, [isRunnerDownloading]); + const refreshPlugins = useCallback(() => { + setPluginsInfo(PluginManager.instance.getPluginInfoList()); + }, []); + + useEffect(() => { + refreshPlugins(); + PluginManager.instance.setEnabledChangedCallback(refreshPlugins); + return () => PluginManager.instance.setEnabledChangedCallback(null!); + }, [refreshPlugins]); + const handleLayoutToggle = () => { playPressSound(); const currentIndex = layouts.indexOf(layout); @@ -318,6 +330,16 @@ const SettingsView = memo(function SettingsView() { setFocusIndex(0); }, }); + items.push({ + id: "plugins_menu", + label: "Plugins", + type: "button", + onClick: () => { + playPressSound(); + setCurrentSubMenu("plugins"); + setFocusIndex(0); + }, + }); } else if (currentSubMenu === "audio") { items.push({ id: "music", @@ -680,7 +702,9 @@ const SettingsView = memo(function SettingsView() { ? "Controls" : currentSubMenu === "game" ? "Game" - : "Launcher"} + : currentSubMenu === "plugins" + ? "Plugins" + : "Launcher"} {currentSubMenu === "main" ? ( @@ -750,6 +774,74 @@ const SettingsView = memo(function SettingsView() { ); })} + ) : currentSubMenu === "plugins" ? ( +
+
+ {pluginsInfo.length === 0 ? ( +
+ No plugins installed +
+ ) : ( + pluginsInfo.map((p, index) => { + const isFocused = focusIndex === index; + return ( +
setFocusIndex(index)} + className={`w-[600px] flex items-center gap-3 px-4 py-3 cursor-pointer outline-none border-none ${ + isFocused ? "text-[#ffff00]" : "text-[#333333]" + }`} + style={{ + backgroundImage: "url('/images/Button_Background2.png')", + backgroundSize: "100% 100%", + imageRendering: "pixelated", + }} + onClick={() => { + playPressSound(); + PluginManager.instance.setPluginEnabled( + p.manifest.id, + !p.enabled, + ); + }} + > +
+ checkbox + {p.enabled && ( + checked + )} +
+
+ + {p.manifest.name} + + + {p.manifest.description} + + + by {p.manifest.author} · v{p.manifest.version} + +
+
+ ); + }) + )} +
+
) : (
@@ -803,7 +895,7 @@ const SettingsView = memo(function SettingsView() { className={`w-[600px] h-10 flex items-center pl-1.5 pr-4 relative z-30 outline-none border-none shrink-0 rounded ${focusIndex === index ? "text-[#ffff00]" : isRed ? "text-red-600" : "text-[#333333]"}`} > {isToggle && ( -
+
checkbox {toggleState && ( checked )} diff --git a/src/main.tsx b/src/main.tsx index afe1914..c0c1227 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,11 +5,13 @@ import App from "./pages/App"; import "./css/index.css"; import "./css/App.css"; import { LauncherProvider } from "./context/LauncherContext"; -// RpcService is now managed by LauncherProvider context +import { PluginProvider } from "./plugins/PluginContext"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - + + + + + ); \ No newline at end of file diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 9815deb..349fe90 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -33,7 +33,11 @@ import { } from "../context/LauncherContext"; import { TauriService } from "../services/TauriService"; import { useLceLiveNotifications } from "../hooks/useLceLiveNotifications"; +import { usePluginViews } from "../plugins/PluginContext"; +import { PluginManager } from "../plugins/PluginManager"; +import { PluginViewContainer } from "../components/plugins/PluginViewContainer"; import type { Edition } from "../types/edition"; +import type { ToastOptions } from "../plugins/types"; import pkg from "../../package.json"; export default function App() { const ui = useUI(); @@ -68,8 +72,51 @@ export default function App() { const displayIsDay = config.isDayTime; const clearError = useCallback(() => game.setError(null), [game]); - const clearGameUpdate = useCallback(() => game.setGameUpdateMessage(null), [game]); - const clearSteamSuccess = useCallback(() => game.setSteamSuccessMessage(null), [game]); + const clearGameUpdate = useCallback( + () => game.setGameUpdateMessage(null), + [game], + ); + const clearSteamSuccess = useCallback( + () => game.setSteamSuccessMessage(null), + [game], + ); + const pluginViews = usePluginViews(); + const [pluginToast, setPluginToast] = useState<{ + message: string; + options?: ToastOptions; + } | null>(null); + useEffect(() => { + const pm = PluginManager.instance; + pm.setNavigateCallback((viewId) => { + setActiveView(viewId); + }); + pm.setToastCallback((_pluginId, message, options) => { + setPluginToast({ message, options }); + }); + pm.setSoundCallback((name) => { + audio.playSfx(name); + }); + }, [setActiveView, audio.playSfx]); + + useEffect(() => { + if (!config.isLoaded) return; + PluginManager.instance.updateSnapshots( + { ...config } as unknown as Record, + { + isGameRunning: game.isGameRunning, + downloadProgress: game.downloadProgress, + downloadingId: game.downloadingId, + }, + game.installs, + ); + }, [ + config, + game.isGameRunning, + game.downloadProgress, + game.downloadingId, + game.installs, + config.isLoaded, + ]); useEffect(() => { if (showIntro && config.skipIntro) { @@ -79,20 +126,20 @@ export default function App() { useEffect(() => { if (config.isLoaded) { - const setupCompleted = localStorage.getItem("lce-setup-completed") === "true"; + const setupCompleted = + localStorage.getItem("lce-setup-completed") === "true"; setShowSetup(!setupCompleted); setIsSetupChecked(true); } }, [config.isLoaded]); - const selectedEdition = useMemo(() => - game.editions.find((e: Edition) => e.instanceId === config.profile), - [game.editions, config.profile] + const selectedEdition = useMemo( + () => game.editions.find((e: Edition) => e.instanceId === config.profile), + [game.editions, config.profile], ); const selectedVersionName = selectedEdition?.name ?? ""; const hasAnyInstall = game.installs.length > 0; const titleImage = selectedEdition?.titleImage ?? "/images/MenuTitle.png"; - useEffect(() => { const handleContextMenu = (e: MouseEvent) => e.preventDefault(); document.addEventListener("contextmenu", handleContextMenu); @@ -100,19 +147,25 @@ export default function App() { }, []); const animDuration = config.animationsEnabled ? undefined : { duration: 0 }; - const uiFade = useMemo(() => ({ - initial: { opacity: 0 }, - animate: { opacity: 1 }, - exit: { opacity: 0 }, - transition: animDuration ?? { duration: 0.5 }, - }), [animDuration]); + const uiFade = useMemo( + () => ({ + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: animDuration ?? { duration: 0.5 }, + }), + [animDuration], + ); - const backgroundFade = useMemo(() => ({ - initial: { opacity: 0 }, - animate: { opacity: 1 }, - exit: { opacity: 0 }, - transition: animDuration ?? { duration: 0.8 }, - }), [animDuration]); + const backgroundFade = useMemo( + () => ({ + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: animDuration ?? { duration: 0.8 }, + }), + [animDuration], + ); if (!config.isLoaded || !isSetupChecked) { return
; @@ -120,7 +173,9 @@ export default function App() { if (showSetup) { return ( -
+
{ setShowSetup(false); @@ -133,7 +188,9 @@ export default function App() { if (showIntro && !config.skipIntro) { return ( -
+
{ setShowIntro(false); @@ -149,7 +206,6 @@ export default function App() {
-
- + + {pluginToast && ( + setPluginToast(null)} + title={pluginToast.options?.title} + variant={pluginToast.options?.variant} + /> + )} + {!config.legacyMode && ( - +
)} - {activeView === "main" && hasAnyInstall && titleImage === "/images/MenuTitle.png" && ( - - {selectedVersionName} - - )} + {activeView === "main" && + hasAnyInstall && + titleImage === "/images/MenuTitle.png" && ( + + {selectedVersionName} + + )}
@@ -408,6 +469,12 @@ export default function App() { {activeView === "credits" && ( )} + {pluginViews.map((pv) => { + if (activeView === pv.id) { + return ; + } + return null; + })}
@@ -422,8 +489,8 @@ export default function App() { Version: {pkg.version} ({__BUILD_DATE__})
- Not affiliated with Mojang AB or Microsoft. "Minecraft" is - a trademark of Mojang Synergies AB. + Not affiliated with Mojang AB or Microsoft. "Minecraft" is a + trademark of Mojang Synergies AB.
{connected && "CONTROLLER CONNECTED"} diff --git a/src/plugins/PluginAPI.ts b/src/plugins/PluginAPI.ts new file mode 100644 index 0000000..8f73c39 --- /dev/null +++ b/src/plugins/PluginAPI.ts @@ -0,0 +1,217 @@ +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen as tauriListen } from "@tauri-apps/api/event"; +import { PluginStorage } from "./PluginStorage"; +import { PluginManager } from "./PluginManager"; +import type { + PluginManifest, + PluginAPI as PluginAPIType, + HookEvent, + HookCallback, + UnsubscribeFn, + PluginComponentFactory, + ViewOptions, + ActionSlot, + ActionDef, + ToastOptions, + StateSnapshot, + EventBus, +} from "./types"; +const PERMISSION_HOOK_PREFIX = "hooks:"; +const PERMISSION_STORAGE = "storage:plugin"; +const PERMISSION_TAURI_ALL = "tauri:*"; +const PERMISSION_TAURI_PREFIX = "tauri:"; +export function buildPluginAPI( + manifest: PluginManifest, + manager: PluginManager, +): PluginAPIType { + const pluginId = manifest.id; + const perms = new Set(manifest.permissions ?? []); + const storage = new PluginStorage(pluginId); + function hasPermission(perm: string): boolean { + return perms.has(perm); + } + + function checkHookPermission(event: HookEvent): void { + if (perms.size === 0) return; + if (hasPermission(`${PERMISSION_HOOK_PREFIX}${event}`)) return; + if (hasPermission(`${PERMISSION_HOOK_PREFIX}*`)) return; + throw new Error( + `Plugin "${pluginId}" lacks permission for hook "${event}"`, + ); + } + + const hookHandlers = new Map>(); + const eventBus: EventBus = manager.buildEventBus(pluginId); + + return { + id: pluginId, + manifest, + events: eventBus, + hooks: { + on(event: HookEvent, callback: HookCallback): UnsubscribeFn { + checkHookPermission(event); + if (!hookHandlers.has(event)) { + hookHandlers.set(event, new Set()); + manager.registerHook(pluginId, event, (payload: unknown) => { + const handlers = hookHandlers.get(event); + if (handlers) { + handlers.forEach((cb) => { + try { + cb(payload); + } catch (err) { + console.error( + `[Plugin ${pluginId}] hook error on ${event}:`, + err, + ); + } + }); + } + }); + } + hookHandlers.get(event)!.add(callback); + return () => { + hookHandlers.get(event)?.delete(callback); + }; + }, + + once(event: HookEvent, callback: HookCallback): UnsubscribeFn { + const inner: HookCallback = (payload) => { + callback(payload); + unsub(); + }; + const unsub = this.on(event, inner); + return unsub; + }, + + off(event: HookEvent, callback: HookCallback): void { + hookHandlers.get(event)?.delete(callback); + }, + }, + + views: { + register( + id: string, + factory: PluginComponentFactory, + options: ViewOptions, + ): void { + manager.registerView(pluginId, id, factory, options); + }, + + unregister(id: string): void { + manager.unregisterView(id); + }, + + navigate(id: string): void { + manager.requestNavigate(id); + }, + }, + + actions: { + register(slot: ActionSlot, action: ActionDef): UnsubscribeFn { + return manager.registerAction(pluginId, slot, action); + }, + }, + + tauri: { + async invoke(cmd: string, args?: Record): Promise { + if (perms.size > 0 && !hasPermission(PERMISSION_TAURI_ALL)) { + if (!hasPermission(`${PERMISSION_TAURI_PREFIX}${cmd}`)) { + throw new Error( + `Plugin "${pluginId}" lacks permission for tauri command "${cmd}"`, + ); + } + } + return invoke(cmd, args); + }, + + async listen( + event: string, + callback: (payload: T) => void, + ): Promise { + if (perms.size > 0 && !hasPermission(PERMISSION_TAURI_ALL)) { + if (!hasPermission(`${PERMISSION_TAURI_PREFIX}listen:${event}`)) { + throw new Error( + `Plugin "${pluginId}" lacks permission to listen for event "${event}"`, + ); + } + } + return tauriListen(event, (evt) => callback(evt.payload)); + }, + }, + + state: { + getConfig(): Record { + return manager.getConfigSnapshot(); + }, + + getGameState(): Record { + return manager.getGameStateSnapshot(); + }, + + getInstalls(): string[] { + return manager.getInstallsSnapshot(); + }, + + subscribe(cb: (snapshot: StateSnapshot) => void): UnsubscribeFn { + return manager.subscribeToState(pluginId, cb); + }, + }, + + storage: { + get(key: string): T | undefined { + if (!hasPermission(PERMISSION_STORAGE)) return undefined; + return storage.get(key); + }, + set(key: string, value: T): void { + if (!hasPermission(PERMISSION_STORAGE)) return; + storage.set(key, value); + }, + remove(key: string): void { + if (!hasPermission(PERMISSION_STORAGE)) return; + storage.remove(key); + }, + clear(): void { + if (!hasPermission(PERMISSION_STORAGE)) return; + storage.clear(); + }, + }, + + React, + useState, + useEffect, + useMemo, + useCallback, + ui: { + showToast(message: string, options?: ToastOptions): void { + manager.showToast(pluginId, message, options); + }, + playSound(name: string): void { + manager.playSound(name); + }, + async openUrl(url: string): Promise { + if ( + perms.size > 0 && + !hasPermission(PERMISSION_TAURI_ALL) && + !hasPermission("tauri:open_url") + ) { + throw new Error(`Plugin "${pluginId}" lacks permission for open_url`); + } + const { openUrl } = await import("@tauri-apps/plugin-opener"); + await openUrl(url); + }, + }, + + log: { + info(...args: unknown[]): void { + console.log(`[Plugin ${pluginId}]`, ...args); + }, + warn(...args: unknown[]): void { + console.warn(`[Plugin ${pluginId}]`, ...args); + }, + error(...args: unknown[]): void { + console.error(`[Plugin ${pluginId}]`, ...args); + }, + }, + }; +} diff --git a/src/plugins/PluginContext.tsx b/src/plugins/PluginContext.tsx new file mode 100644 index 0000000..7615ef0 --- /dev/null +++ b/src/plugins/PluginContext.tsx @@ -0,0 +1,95 @@ +import { + createContext, + useContext, + useState, + useEffect, + useMemo, + useCallback, + type ReactNode, +} from "react"; +import { PluginManager } from "./PluginManager"; +import type { + PluginViewRegistration, + ActionSlot, + ActionDef, + ToastOptions, +} from "./types"; +interface PluginContextType { + views: PluginViewRegistration[]; + getActions: (slot: ActionSlot) => ActionDef[]; + navigateToView: (viewId: string) => void; +} + +const PluginContext = createContext({ + views: [], + getActions: () => [], + navigateToView: () => {}, +}); + +export function usePluginViews(): PluginViewRegistration[] { + return useContext(PluginContext).views; +} + +export function usePluginActions(slot: ActionSlot): ActionDef[] { + return useContext(PluginContext).getActions(slot); +} + +export function usePluginNavigate(): (viewId: string) => void { + return useContext(PluginContext).navigateToView; +} + +interface PluginProviderProps { + children: ReactNode; + onNavigate?: (viewId: string) => void; + onToast?: (pluginId: string, message: string, options?: ToastOptions) => void; + onSound?: (name: string) => void; +} + +export function PluginProvider({ + children, + onNavigate, + onToast, + onSound, +}: PluginProviderProps) { + const [views, setViews] = useState([]); + const pm = PluginManager.instance; + useEffect(() => { + pm.setViewsChangedCallback((updatedViews) => { + setViews(updatedViews); + }); + + if (onNavigate) { + pm.setNavigateCallback(onNavigate); + } + + if (onToast) { + pm.setToastCallback(onToast); + } + + if (onSound) { + pm.setSoundCallback(onSound); + } + + pm.init().catch(console.error); + }, []); + + const getActions = useCallback((slot: ActionSlot): ActionDef[] => { + return pm.getActions(slot); + }, []); + + const navigateToView = useCallback( + (viewId: string) => { + onNavigate?.(viewId); + }, + [onNavigate], + ); + + const value = useMemo( + () => ({ views, getActions, navigateToView }), + [views, getActions, navigateToView], + ); + + return ( + {children} + ); +} diff --git a/src/plugins/PluginManager.ts b/src/plugins/PluginManager.ts new file mode 100644 index 0000000..a009713 --- /dev/null +++ b/src/plugins/PluginManager.ts @@ -0,0 +1,364 @@ +import { invoke } from "@tauri-apps/api/core"; +import { buildPluginAPI } from "./PluginAPI"; +import { PluginSandbox } from "./PluginSandbox"; +import type { + PluginManifest, + LoadedPlugin, + HookEvent, + HookCallback, + UnsubscribeFn, + PluginComponentFactory, + ViewOptions, + PluginViewRegistration, + ActionSlot, + ActionDef, + ToastOptions, + StateSnapshot, + EventBus, +} from "./types"; +type StateChangeCallback = (snapshot: StateSnapshot) => void; +type ViewChangeCallback = (views: PluginViewRegistration[]) => void; +type NavigateCallback = (viewId: string) => void; +type ToastCallback = ( + pluginId: string, + message: string, + options?: ToastOptions, +) => void; +type SoundCallback = (name: string) => void; +type PluginEventHandler = (payload: unknown) => void; + +export interface PluginInfo { + manifest: PluginManifest; + enabled: boolean; +} + +export class PluginManager { + static instance: PluginManager = new PluginManager(); + plugins: Map = new Map(); + private enabledMap: Map = new Map(); + private hooks: Map> = new Map(); + private views: Map = new Map(); + private actions: Map> = new Map(); + private stateSubs: Map = new Map(); + private pluginEvents: Map>> = new Map(); + private onViewsChanged: ViewChangeCallback | null = null; + private onNavigate: NavigateCallback | null = null; + private onToast: ToastCallback | null = null; + private onSound: SoundCallback | null = null; + private onEnabledChanged: (() => void) | null = null; + private configSnapshot: Record = {}; + private gameStateSnapshot: Record = {}; + private installsSnapshot: string[] = []; + private _initialized = false; + get initialized(): boolean { + return this._initialized; + } + + setEnabledChangedCallback(cb: () => void): void { + this.onEnabledChanged = cb; + } + + isPluginEnabled(id: string): boolean { + if (!this.enabledMap.has(id)) { + const stored = localStorage.getItem(`plugin:enabled:${id}`); + const enabled = stored === null ? true : stored === "true"; + this.enabledMap.set(id, enabled); + } + return this.enabledMap.get(id) ?? true; + } + + setPluginEnabled(id: string, enabled: boolean): void { + this.enabledMap.set(id, enabled); + localStorage.setItem(`plugin:enabled:${id}`, String(enabled)); + this.notifyViewsChanged(); + this.onEnabledChanged?.(); + } + + getPluginInfoList(): PluginInfo[] { + return Array.from(this.plugins.values()).map((p) => ({ + manifest: p.manifest, + enabled: this.isPluginEnabled(p.manifest.id), + })); + } + + setViewsChangedCallback(cb: ViewChangeCallback): void { + this.onViewsChanged = cb; + } + + setNavigateCallback(cb: NavigateCallback): void { + this.onNavigate = cb; + } + + setToastCallback(cb: ToastCallback): void { + this.onToast = cb; + } + + setSoundCallback(cb: SoundCallback): void { + this.onSound = cb; + } + + updateSnapshots( + config: Record, + game: Record, + installs: string[], + ): void { + this.configSnapshot = config; + this.gameStateSnapshot = game; + this.installsSnapshot = installs; + const snapshot: StateSnapshot = { config, game, installs }; + this.stateSubs.forEach((cb) => { + try { + cb(snapshot); + } catch (err) { + console.error("[PluginManager] state sub error:", err); + } + }); + } + + getConfigSnapshot(): Record { + return { ...this.configSnapshot }; + } + + getGameStateSnapshot(): Record { + return { ...this.gameStateSnapshot }; + } + + getInstallsSnapshot(): string[] { + return [...this.installsSnapshot]; + } + + subscribeToState(subId: string, cb: StateChangeCallback): UnsubscribeFn { + this.stateSubs.set(subId, cb); + return () => { + this.stateSubs.delete(subId); + }; + } + + async init(): Promise { + if (this._initialized) return; + let pluginsDir: string; + try { + pluginsDir = await invoke("get_plugins_dir"); + } catch { + console.warn("[PluginManager] Could not get plugins directory"); + return; + } + + let entries: Array<{ name: string; is_dir: boolean }>; + try { + entries = await invoke("list_directory", { path: pluginsDir }); + } catch { + return; + } + + const dirs = entries.filter((e) => e.is_dir); + + for (const dir of dirs) { + await this.loadPlugin(pluginsDir, dir.name); + } + + this._initialized = true; + this.emit("app:ready", {}); + this.notifyViewsChanged(); + } + + private async loadPlugin(pluginsDir: string, dirName: string): Promise { + const manifestPath = `${pluginsDir}/${dirName}/plugin.json`; + let manifestRaw: number[]; + try { + manifestRaw = await invoke("read_binary_file", { + path: manifestPath, + }); + } catch { + return; + } + + let manifest: PluginManifest; + try { + manifest = JSON.parse( + new TextDecoder().decode(new Uint8Array(manifestRaw)), + ); + } catch { + console.warn(`[PluginManager] Invalid plugin.json in ${dirName}`); + return; + } + + if (!manifest.id || !manifest.main || !manifest.name) { + console.warn(`[PluginManager] Invalid manifest in ${dirName}`); + return; + } + + if (this.plugins.has(manifest.id)) { + console.warn(`[PluginManager] Duplicate plugin id: ${manifest.id}`); + return; + } + + const mainPath = `${pluginsDir}/${dirName}/${manifest.main}`; + let mainCodeRaw: number[]; + try { + mainCodeRaw = await invoke("read_binary_file", { + path: mainPath, + }); + } catch { + console.warn( + `[PluginManager] Could not read main file for ${manifest.id}`, + ); + return; + } + + const mainCode = new TextDecoder().decode(new Uint8Array(mainCodeRaw)); + const api = buildPluginAPI(manifest, this); + try { + await PluginSandbox.evaluateAsync(api, mainCode); + } catch (err) { + console.error( + `[PluginManager] Error loading plugin ${manifest.id}:`, + err, + ); + return; + } + + this.plugins.set(manifest.id, { manifest, api }); + } + + registerHook( + pluginId: string, + event: HookEvent, + callback: HookCallback, + ): void { + if (!this.hooks.has(event)) { + this.hooks.set(event, new Map()); + } + this.hooks.get(event)!.set(pluginId, callback); + } + + emit(event: HookEvent, payload: unknown): void { + const handlers = this.hooks.get(event); + if (!handlers) return; + handlers.forEach((cb, pid) => { + if (!this.isPluginEnabled(pid)) return; + try { + cb(payload); + } catch (err) { + console.error(`[PluginManager] Error in hook ${event}:`, err); + } + }); + } + + emitPluginEvent(event: string, _sourcePluginId: string, payload: unknown): void { + const eventHandlers = this.pluginEvents.get(event); + if (!eventHandlers) return; + eventHandlers.forEach((handlers, pid) => { + if (!this.isPluginEnabled(pid)) return; + handlers.forEach((cb) => { + try { + cb(payload); + } catch (err) { + console.error(`[PluginManager] Error in plugin event "${event}":`, err); + } + }); + }); + } + + onPluginEvent(pluginId: string, event: string, callback: PluginEventHandler): UnsubscribeFn { + if (!this.pluginEvents.has(event)) { + this.pluginEvents.set(event, new Map()); + } + const eventHandlers = this.pluginEvents.get(event)!; + if (!eventHandlers.has(pluginId)) { + eventHandlers.set(pluginId, new Set()); + } + eventHandlers.get(pluginId)!.add(callback); + return () => { + eventHandlers.get(pluginId)?.delete(callback); + }; + } + + offPluginEvent(pluginId: string, event: string, callback: PluginEventHandler): void { + const eventHandlers = this.pluginEvents.get(event); + if (!eventHandlers) return; + eventHandlers.get(pluginId)?.delete(callback); + } + + buildEventBus(pluginId: string): EventBus { + const self = this; + return { + emit(event: string, payload?: unknown): void { + self.emitPluginEvent(event, pluginId, payload); + }, + on(event: string, callback: (payload: unknown) => void): UnsubscribeFn { + return self.onPluginEvent(pluginId, event, callback); + }, + once(event: string, callback: (payload: unknown) => void): UnsubscribeFn { + const inner: PluginEventHandler = (payload) => { + callback(payload); + unsub(); + }; + const unsub = self.onPluginEvent(pluginId, event, inner); + return unsub; + }, + off(event: string, callback: (payload: unknown) => void): void { + self.offPluginEvent(pluginId, event, callback); + }, + }; + } + + registerView( + _pluginId: string, + id: string, + factory: PluginComponentFactory, + options: ViewOptions, + ): void { + this.views.set(id, { id, factory, options, pluginId: _pluginId }); + this.notifyViewsChanged(); + } + + unregisterView(id: string): void { + this.views.delete(id); + this.notifyViewsChanged(); + } + + getViews(): PluginViewRegistration[] { + return Array.from(this.views.values()).filter((v) => + this.isPluginEnabled(v.pluginId), + ); + } + + requestNavigate(viewId: string): void { + this.onNavigate?.(viewId); + } + + registerAction( + pluginId: string, + slot: ActionSlot, + action: ActionDef, + ): UnsubscribeFn { + if (!this.actions.has(slot)) { + this.actions.set(slot, new Map()); + } + this.actions.get(slot)!.set(action.id, { action, pluginId }); + return () => { + this.actions.get(slot)?.delete(action.id); + }; + } + + getActions(slot: ActionSlot): ActionDef[] { + const slotActions = this.actions.get(slot); + if (!slotActions) return []; + return Array.from(slotActions.values()) + .filter((entry) => this.isPluginEnabled(entry.pluginId)) + .map((entry) => entry.action); + } + + showToast(pluginId: string, message: string, options?: ToastOptions): void { + this.onToast?.(pluginId, message, options); + } + + playSound(name: string): void { + this.onSound?.(name); + } + + private notifyViewsChanged(): void { + this.onViewsChanged?.(this.getViews()); + } +} diff --git a/src/plugins/PluginSandbox.ts b/src/plugins/PluginSandbox.ts new file mode 100644 index 0000000..d637b7e --- /dev/null +++ b/src/plugins/PluginSandbox.ts @@ -0,0 +1,16 @@ +import type { PluginAPI } from "./types"; +export class PluginSandbox { + static evaluate(api: PluginAPI, code: string): void { + const fn = new Function("api", code); + fn(api); + } + + static evaluateAsync(api: PluginAPI, code: string): Promise { + try { + const asyncFn = new Function("api", `return (async () => { ${code} })()`); + return Promise.resolve(asyncFn(api)); + } catch (err) { + return Promise.reject(err); + } + } +} diff --git a/src/plugins/PluginStorage.ts b/src/plugins/PluginStorage.ts new file mode 100644 index 0000000..9d99069 --- /dev/null +++ b/src/plugins/PluginStorage.ts @@ -0,0 +1,41 @@ +const PLUGIN_PREFIX = "plugin:"; +export class PluginStorage { + private namespace: string; + constructor(pluginId: string) { + this.namespace = `${PLUGIN_PREFIX}${pluginId}:`; + } + + private prefixKey(key: string): string { + return `${this.namespace}${key}`; + } + + get(key: string): T | undefined { + try { + const raw = localStorage.getItem(this.prefixKey(key)); + if (raw === null) return undefined; + return JSON.parse(raw) as T; + } catch { + return undefined; + } + } + + set(key: string, value: T): void { + localStorage.setItem(this.prefixKey(key), JSON.stringify(value)); + } + + remove(key: string): void { + localStorage.removeItem(this.prefixKey(key)); + } + + clear(): void { + const prefix = this.prefixKey(""); + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith(prefix)) { + keysToRemove.push(k); + } + } + keysToRemove.forEach((k) => localStorage.removeItem(k)); + } +} diff --git a/src/plugins/types.ts b/src/plugins/types.ts new file mode 100644 index 0000000..c636d05 --- /dev/null +++ b/src/plugins/types.ts @@ -0,0 +1,144 @@ +import type React from "react"; +export interface PluginManifest { + id: string; + name: string; + version: string; + author: string; + description: string; + main: string; + views?: PluginViewDeclaration[]; + permissions?: string[]; +} + +export interface PluginViewDeclaration { + id: string; + label: string; + icon?: string; +} + +export type HookEvent = + | "app:ready" + | "app:before-quit" + | "game:before-launch" + | "game:after-launch" + | "game:before-stop" + | "game:after-stop" + | "game:install-start" + | "game:install-complete" + | "game:install-progress" + | "game:uninstall" + | "config:change" + | "view:mount" + | "view:unmount"; + +export type HookCallback = (payload: unknown) => void; +export type UnsubscribeFn = () => void; +export type PluginComponentFactory = ( + api: PluginAPI, +) => React.ComponentType>; + +export interface ViewOptions { + label: string; + icon?: string; +} + +export type ActionSlot = + | "home-menu" + | "version-toolbar" + | "devtools-list" + | "settings-tab"; + +export interface ActionDef { + id: string; + label: string; + icon?: string; + onClick: () => void; +} + +export interface ToastOptions { + title?: string; + variant?: "error" | "update" | "steam"; + duration?: number; +} + +export interface StateSnapshot { + config: Record; + game: Record; + installs: string[]; +} + +export interface PluginViewRegistration { + id: string; + factory: PluginComponentFactory; + options: ViewOptions; + pluginId: string; +} + +export interface LoadedPlugin { + manifest: PluginManifest; + api: PluginAPI; +} + +export interface EventBus { + emit(event: string, payload?: unknown): void; + on(event: string, callback: (payload: unknown) => void): UnsubscribeFn; + once(event: string, callback: (payload: unknown) => void): UnsubscribeFn; + off(event: string, callback: (payload: unknown) => void): void; +} + +export interface PluginAPI { + id: string; + manifest: PluginManifest; + events: EventBus; + hooks: { + on(event: HookEvent, callback: HookCallback): UnsubscribeFn; + once(event: HookEvent, callback: HookCallback): UnsubscribeFn; + off(event: HookEvent, callback: HookCallback): void; + }; + views: { + register( + id: string, + factory: PluginComponentFactory, + options: ViewOptions, + ): void; + unregister(id: string): void; + navigate(id: string): void; + }; + actions: { + register(slot: ActionSlot, action: ActionDef): UnsubscribeFn; + }; + tauri: { + invoke(cmd: string, args?: Record): Promise; + listen( + event: string, + callback: (payload: T) => void, + ): Promise; + }; + state: { + getConfig(): Record; + getGameState(): Record; + getInstalls(): string[]; + subscribe(cb: (snapshot: StateSnapshot) => void): UnsubscribeFn; + }; + storage: { + get(key: string): T | undefined; + set(key: string, value: T): void; + remove(key: string): void; + clear(): void; + }; + React: typeof React; + useState: typeof React.useState; + useEffect: typeof React.useEffect; + useMemo: typeof React.useMemo; + useCallback: typeof React.useCallback; + ui: { + showToast(message: string, options?: ToastOptions): void; + playSound(name: string): void; + openUrl(url: string): Promise; + }; + log: { + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; + }; +} diff --git a/src/services/TauriService.ts b/src/services/TauriService.ts index 18393ed..d7ad82b 100644 --- a/src/services/TauriService.ts +++ b/src/services/TauriService.ts @@ -332,6 +332,14 @@ export class TauriService { return invoke("restore_instance"); } + static async getPluginsDir(): Promise { + return invoke("get_plugins_dir"); + } + + static async listDirectory(path: string): Promise> { + return invoke("list_directory", { path }); + } + static async importWorld( inputPath: string, outputPath: string,