mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-15 23:10:48 +00:00
feat: initial plugin support
This commit is contained in:
7
plugins/multiplayer-button/main.js
Normal file
7
plugins/multiplayer-button/main.js
Normal file
@@ -0,0 +1,7 @@
|
||||
api.actions.register("home-menu", {
|
||||
id: "multiplayer",
|
||||
label: "Multiplayer",
|
||||
onClick: function () {
|
||||
api.views.navigate("lcelive");
|
||||
},
|
||||
});
|
||||
9
plugins/multiplayer-button/plugin.json
Normal file
9
plugins/multiplayer-button/plugin.json
Normal 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": []
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
28
src-tauri/src/commands/plugins.rs
Normal file
28
src-tauri/src/commands/plugins.rs
Normal 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)
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
76
src/components/plugins/PluginViewContainer.tsx
Normal file
76
src/components/plugins/PluginViewContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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} · 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" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
10
src/main.tsx
10
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(
|
||||
<React.StrictMode>
|
||||
<LauncherProvider>
|
||||
<App />
|
||||
</LauncherProvider>
|
||||
<PluginProvider>
|
||||
<LauncherProvider>
|
||||
<App />
|
||||
</LauncherProvider>
|
||||
</PluginProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -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
217
src/plugins/PluginAPI.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
95
src/plugins/PluginContext.tsx
Normal file
95
src/plugins/PluginContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
364
src/plugins/PluginManager.ts
Normal file
364
src/plugins/PluginManager.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
16
src/plugins/PluginSandbox.ts
Normal file
16
src/plugins/PluginSandbox.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/plugins/PluginStorage.ts
Normal file
41
src/plugins/PluginStorage.ts
Normal 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
144
src/plugins/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user