feat: initial plugin support

This commit is contained in:
neoapps-dev
2026-06-14 18:05:20 +03:00
parent e105686b23
commit 8e27a4bed1
17 changed files with 1268 additions and 78 deletions

View File

@@ -0,0 +1,7 @@
api.actions.register("home-menu", {
id: "multiplayer",
label: "Multiplayer",
onClick: function () {
api.views.navigate("lcelive");
},
});

View File

@@ -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": []
}

View File

@@ -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;

View File

@@ -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<String, String> {
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<Vec<DirEntry>, 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)
}

View File

@@ -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();

View File

@@ -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 (
<div className="flex flex-col items-center justify-center h-full text-red-400 gap-2 p-8">
<span className="text-lg">Plugin Error</span>
<span className="text-sm text-gray-400">
{this.state.error?.message ?? "Unknown error"}
</span>
</div>
);
}
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<Record<string, unknown>>;
try {
Component = registry.factory(plugin.api);
} catch (err) {
console.error(`[Plugin] Error creating component for ${registry.id}:`, err);
return (
<div className="flex items-center justify-center h-full text-red-400">
Failed to create view
</div>
);
}
return (
<PluginErrorBoundary pluginId={registry.pluginId}>
<motion.div
key={registry.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
className="w-full h-full flex flex-col items-center justify-center"
>
<Component />
</motion.div>
</PluginErrorBoundary>
);
}

View File

@@ -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,
],
);

View File

@@ -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<number | null>(null);
const [currentSubMenu, setCurrentSubMenu] = useState<
"main" | "audio" | "video" | "controls" | "launcher" | "game"
"main" | "audio" | "video" | "controls" | "launcher" | "game" | "plugins"
>("main");
const [runners, setRunners] = useState<Runner[]>([]);
const [pluginsInfo, setPluginsInfo] = useState<PluginInfo[]>([]);
const containerRef = useRef<HTMLDivElement>(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"}
</h2>
{currentSubMenu === "main" ? (
@@ -750,6 +774,74 @@ const SettingsView = memo(function SettingsView() {
);
})}
</div>
) : currentSubMenu === "plugins" ? (
<div className="min-w-[640px] w-fit p-4 flex flex-col items-center mc-options-bg">
<div className="w-full space-y-3 flex flex-col items-center overflow-y-auto max-h-[50vh] py-2 settings-scrollbar">
{pluginsInfo.length === 0 ? (
<div className="text-[#888888] text-lg mc-text-shadow py-8">
No plugins installed
</div>
) : (
pluginsInfo.map((p, index) => {
const isFocused = focusIndex === index;
return (
<div
key={p.manifest.id}
data-index={index}
onMouseEnter={() => 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,
);
}}
>
<div className="relative w-6 h-6 shrink-0 flex items-center justify-center">
<img
src={
isFocused
? "/images/checkbox_highlighted.png"
: "/images/checkbox.png"
}
alt="checkbox"
className="absolute inset-0 w-full h-full object-contain"
style={{ imageRendering: "pixelated" }}
/>
{p.enabled && (
<img
src="/images/check.png"
alt="checked"
className="relative z-10 w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
)}
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-lg mc-text-shadow truncate">
{p.manifest.name}
</span>
<span className="text-xs mc-text-shadow opacity-70 truncate">
{p.manifest.description}
</span>
<span className="text-xs mc-text-shadow opacity-50">
by {p.manifest.author} &middot; v{p.manifest.version}
</span>
</div>
</div>
);
})
)}
</div>
</div>
) : (
<div className="min-w-[640px] w-fit p-4 flex flex-col items-center mc-options-bg">
<div className="w-full space-y-3 flex flex-col items-center overflow-y-auto max-h-[50vh] py-2 settings-scrollbar">
@@ -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 && (
<div className="relative w-6 h-6 mr-3 shrink-0">
<div className="relative w-6 h-6 mr-3 shrink-0 flex items-center justify-center">
<img
src={
focusIndex === index
@@ -811,14 +903,14 @@ const SettingsView = memo(function SettingsView() {
: "/images/checkbox.png"
}
alt="checkbox"
className="w-full h-full object-contain"
className="absolute inset-0 w-full h-full object-contain"
style={{ imageRendering: "pixelated" }}
/>
{toggleState && (
<img
src="/images/check.png"
alt="checked"
className="absolute inset-0 w-full h-full object-contain"
className="relative z-10 w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
)}

View File

@@ -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(
<React.StrictMode>
<LauncherProvider>
<App />
</LauncherProvider>
<PluginProvider>
<LauncherProvider>
<App />
</LauncherProvider>
</PluginProvider>
</React.StrictMode>
);

View File

@@ -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<string, unknown>,
{
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 <div className="w-screen h-screen bg-black" />;
@@ -120,7 +173,9 @@ export default function App() {
if (showSetup) {
return (
<div className={`w-screen h-screen overflow-hidden select-none flex flex-col relative bg-black text-white font-['Mojangles'] outline-none focus:outline-none ${!config.animationsEnabled ? "no-animations" : ""}`}>
<div
className={`w-screen h-screen overflow-hidden select-none flex flex-col relative bg-black text-white font-['Mojangles'] outline-none focus:outline-none ${!config.animationsEnabled ? "no-animations" : ""}`}
>
<SetupView
onComplete={() => {
setShowSetup(false);
@@ -133,7 +188,9 @@ export default function App() {
if (showIntro && !config.skipIntro) {
return (
<div className={`w-screen h-screen overflow-hidden select-none flex flex-col relative bg-black text-white font-['Mojangles'] outline-none focus:outline-none ${!config.animationsEnabled ? "no-animations" : ""}`}>
<div
className={`w-screen h-screen overflow-hidden select-none flex flex-col relative bg-black text-white font-['Mojangles'] outline-none focus:outline-none ${!config.animationsEnabled ? "no-animations" : ""}`}
>
<CinematicIntro
onComplete={() => {
setShowIntro(false);
@@ -149,7 +206,6 @@ export default function App() {
<div
className={`w-screen h-screen overflow-hidden select-none flex flex-col relative bg-black text-white font-['Mojangles'] outline-none focus:outline-none ${!config.animationsEnabled ? "no-animations" : ""}`}
>
<div className="absolute inset-0">
<AnimatePresence>
<motion.div
@@ -173,10 +229,7 @@ export default function App() {
editions={game.editions}
/>
<AchievementToast
message={game.error}
onClose={clearError}
/>
<AchievementToast message={game.error} onClose={clearError} />
<AchievementToast
message={updateMessage}
@@ -209,16 +262,22 @@ export default function App() {
variant="steam"
/>
{pluginToast && (
<AchievementToast
message={pluginToast.message}
onClose={() => setPluginToast(null)}
title={pluginToast.options?.title}
variant={pluginToast.options?.variant}
/>
)}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col h-full z-10 w-full relative"
>
{!config.legacyMode && (
<motion.div
{...uiFade}
className="absolute top-4 left-8 z-50"
>
<motion.div {...uiFade} className="absolute top-4 left-8 z-50">
<button
onClick={() => {
audio.playPressSound();
@@ -330,14 +389,16 @@ export default function App() {
</div>
</motion.div>
)}
{activeView === "main" && hasAnyInstall && titleImage === "/images/MenuTitle.png" && (
<motion.div
{...uiFade}
className="absolute -bottom-6 text-[#A0A0A0] text-sm mc-text-shadow tracking-widest uppercase opacity-80 font-['Mojangles']"
>
{selectedVersionName}
</motion.div>
)}
{activeView === "main" &&
hasAnyInstall &&
titleImage === "/images/MenuTitle.png" && (
<motion.div
{...uiFade}
className="absolute -bottom-6 text-[#A0A0A0] text-sm mc-text-shadow tracking-widest uppercase opacity-80 font-['Mojangles']"
>
{selectedVersionName}
</motion.div>
)}
</div>
</div>
@@ -408,6 +469,12 @@ export default function App() {
{activeView === "credits" && (
<CreditsView key="credits-view" />
)}
{pluginViews.map((pv) => {
if (activeView === pv.id) {
return <PluginViewContainer key={pv.id} registry={pv} />;
}
return null;
})}
</AnimatePresence>
</div>
</div>
@@ -422,8 +489,8 @@ export default function App() {
Version: {pkg.version} ({__BUILD_DATE__})
</div>
<div className="flex-[2] text-center whitespace-nowrap">
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.
</div>
<div className="flex-1 text-right whitespace-nowrap">
{connected && "CONTROLLER CONNECTED"}

217
src/plugins/PluginAPI.ts Normal file
View File

@@ -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<HookEvent, Set<HookCallback>>();
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<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
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<T>(cmd, args);
},
async listen<T>(
event: string,
callback: (payload: T) => void,
): Promise<UnsubscribeFn> {
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<T>(event, (evt) => callback(evt.payload));
},
},
state: {
getConfig(): Record<string, unknown> {
return manager.getConfigSnapshot();
},
getGameState(): Record<string, unknown> {
return manager.getGameStateSnapshot();
},
getInstalls(): string[] {
return manager.getInstallsSnapshot();
},
subscribe(cb: (snapshot: StateSnapshot) => void): UnsubscribeFn {
return manager.subscribeToState(pluginId, cb);
},
},
storage: {
get<T>(key: string): T | undefined {
if (!hasPermission(PERMISSION_STORAGE)) return undefined;
return storage.get<T>(key);
},
set<T>(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<void> {
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);
},
},
};
}

View File

@@ -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<PluginContextType>({
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<PluginViewRegistration[]>([]);
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 (
<PluginContext.Provider value={value}>{children}</PluginContext.Provider>
);
}

View File

@@ -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<string, LoadedPlugin> = new Map();
private enabledMap: Map<string, boolean> = new Map();
private hooks: Map<HookEvent, Map<string, HookCallback>> = new Map();
private views: Map<string, PluginViewRegistration> = new Map();
private actions: Map<ActionSlot, Map<string, { action: ActionDef; pluginId: string }>> = new Map();
private stateSubs: Map<string, StateChangeCallback> = new Map();
private pluginEvents: Map<string, Map<string, Set<PluginEventHandler>>> = 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<string, unknown> = {};
private gameStateSnapshot: Record<string, unknown> = {};
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<string, unknown>,
game: Record<string, unknown>,
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<string, unknown> {
return { ...this.configSnapshot };
}
getGameStateSnapshot(): Record<string, unknown> {
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<void> {
if (this._initialized) return;
let pluginsDir: string;
try {
pluginsDir = await invoke<string>("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<void> {
const manifestPath = `${pluginsDir}/${dirName}/plugin.json`;
let manifestRaw: number[];
try {
manifestRaw = await invoke<number[]>("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<number[]>("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());
}
}

View File

@@ -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<void> {
try {
const asyncFn = new Function("api", `return (async () => { ${code} })()`);
return Promise.resolve(asyncFn(api));
} catch (err) {
return Promise.reject(err);
}
}
}

View File

@@ -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<T>(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<T>(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));
}
}

144
src/plugins/types.ts Normal file
View File

@@ -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<Record<string, unknown>>;
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<string, unknown>;
game: Record<string, unknown>;
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<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
listen<T>(
event: string,
callback: (payload: T) => void,
): Promise<UnsubscribeFn>;
};
state: {
getConfig(): Record<string, unknown>;
getGameState(): Record<string, unknown>;
getInstalls(): string[];
subscribe(cb: (snapshot: StateSnapshot) => void): UnsubscribeFn;
};
storage: {
get<T>(key: string): T | undefined;
set<T>(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<void>;
};
log: {
info(...args: unknown[]): void;
warn(...args: unknown[]): void;
error(...args: unknown[]): void;
};
}

View File

@@ -332,6 +332,14 @@ export class TauriService {
return invoke("restore_instance");
}
static async getPluginsDir(): Promise<string> {
return invoke("get_plugins_dir");
}
static async listDirectory(path: string): Promise<Array<{ name: string; is_dir: boolean }>> {
return invoke("list_directory", { path });
}
static async importWorld(
inputPath: string,
outputPath: string,