mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-09-23 08:32:51 +00:00
feat: initial plugin support
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
api.actions.register("home-menu", {
|
||||||
|
id: "multiplayer",
|
||||||
|
label: "Multiplayer",
|
||||||
|
onClick: function () {
|
||||||
|
api.views.navigate("lcelive");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 file_dialogs;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod macos_setup;
|
pub mod macos_setup;
|
||||||
|
pub mod plugins;
|
||||||
pub mod proxy_cmd;
|
pub mod proxy_cmd;
|
||||||
pub mod runners;
|
pub mod runners;
|
||||||
pub mod skin;
|
pub mod skin;
|
||||||
|
|||||||
@@ -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::file_dialogs;
|
||||||
use commands::game;
|
use commands::game;
|
||||||
use commands::macos_setup;
|
use commands::macos_setup;
|
||||||
|
use commands::plugins;
|
||||||
use commands::proxy_cmd;
|
use commands::proxy_cmd;
|
||||||
use commands::runners;
|
use commands::runners;
|
||||||
use commands::skin;
|
use commands::skin;
|
||||||
@@ -95,6 +96,8 @@ pub fn run() {
|
|||||||
relay::stop_proxy,
|
relay::stop_proxy,
|
||||||
relay::stop_all_proxies,
|
relay::stop_all_proxies,
|
||||||
relay::join_game,
|
relay::join_game,
|
||||||
|
plugins::get_plugins_dir,
|
||||||
|
plugins::list_directory,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
|
|||||||
@@ -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,
|
useAudio,
|
||||||
useGame,
|
useGame,
|
||||||
} from "../../context/LauncherContext";
|
} from "../../context/LauncherContext";
|
||||||
|
import { usePluginActions } from "../../plugins/PluginContext";
|
||||||
import type { Edition } from "../../types/edition";
|
import type { Edition } from "../../types/edition";
|
||||||
|
|
||||||
const HomeView = memo(function HomeView() {
|
const HomeView = memo(function HomeView() {
|
||||||
@@ -23,6 +24,7 @@ const HomeView = memo(function HomeView() {
|
|||||||
stopGame,
|
stopGame,
|
||||||
updatesAvailable,
|
updatesAvailable,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
|
const pluginActions = usePluginActions("home-menu");
|
||||||
|
|
||||||
const isFocusedSection = focusSection === "menu";
|
const isFocusedSection = focusSection === "menu";
|
||||||
const selectedEdition = editions.find((e: Edition) => e.id === profile);
|
const selectedEdition = editions.find((e: Edition) => e.id === profile);
|
||||||
@@ -34,8 +36,8 @@ const HomeView = memo(function HomeView() {
|
|||||||
const hasAnyInstall = installs.length > 0;
|
const hasAnyInstall = installs.length > 0;
|
||||||
|
|
||||||
const buttonsVal = useMemo(
|
const buttonsVal = useMemo(
|
||||||
() => [
|
() => {
|
||||||
{
|
const mainBtn = {
|
||||||
label: !hasAnyInstall
|
label: !hasAnyInstall
|
||||||
? "Install a version"
|
? "Install a version"
|
||||||
: isGameRunning
|
: isGameRunning
|
||||||
@@ -56,36 +58,54 @@ const HomeView = memo(function HomeView() {
|
|||||||
: () => toggleInstall(profile),
|
: () => toggleInstall(profile),
|
||||||
isDanger: isGameRunning,
|
isDanger: isGameRunning,
|
||||||
disabled: isDownloading,
|
disabled: isDownloading,
|
||||||
},
|
id: "main-action",
|
||||||
{
|
};
|
||||||
label: "Help & Options",
|
|
||||||
action: () => setActiveView("settings"),
|
const pluginBtns = pluginActions.map((a) => ({
|
||||||
|
label: a.label,
|
||||||
|
action: () => a.onClick(),
|
||||||
|
isDanger: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
id: "settings",
|
id: a.id,
|
||||||
},
|
}));
|
||||||
{
|
|
||||||
label: "Versions",
|
const menuBtns = [
|
||||||
action: () => setActiveView("versions"),
|
{
|
||||||
disabled: false,
|
label: "Help & Options",
|
||||||
id: "versions",
|
action: () => setActiveView("settings"),
|
||||||
},
|
isDanger: false,
|
||||||
{
|
disabled: false,
|
||||||
label: "Workshop",
|
id: "settings",
|
||||||
action: () => setActiveView("workshop"),
|
},
|
||||||
disabled: false,
|
{
|
||||||
id: "workshop",
|
label: "Versions",
|
||||||
},
|
action: () => setActiveView("versions"),
|
||||||
{
|
isDanger: false,
|
||||||
label: "Developer Tools",
|
disabled: false,
|
||||||
action: () => setActiveView("devtools"),
|
id: "versions",
|
||||||
disabled: false,
|
},
|
||||||
id: "devtools",
|
{
|
||||||
},
|
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,
|
isDownloading, hasAnyInstall, isInstalled, selectedVersionName,
|
||||||
handleLaunch, toggleInstall, profile, setActiveView,
|
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 { motion } from "framer-motion";
|
||||||
import { TauriService, Runner } from "../../services/TauriService";
|
import { TauriService, Runner } from "../../services/TauriService";
|
||||||
import { usePlatform } from "../../hooks/usePlatform";
|
import { usePlatform } from "../../hooks/usePlatform";
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useAudio,
|
useAudio,
|
||||||
useGame,
|
useGame,
|
||||||
} from "../../context/LauncherContext";
|
} from "../../context/LauncherContext";
|
||||||
|
import { PluginManager, type PluginInfo } from "../../plugins/PluginManager";
|
||||||
|
|
||||||
const SettingsView = memo(function SettingsView() {
|
const SettingsView = memo(function SettingsView() {
|
||||||
const { setActiveView } = useUI();
|
const { setActiveView } = useUI();
|
||||||
@@ -60,9 +61,10 @@ const SettingsView = memo(function SettingsView() {
|
|||||||
const { isLinux, isMac } = usePlatform();
|
const { isLinux, isMac } = usePlatform();
|
||||||
const [focusIndex, setFocusIndex] = useState<number | null>(null);
|
const [focusIndex, setFocusIndex] = useState<number | null>(null);
|
||||||
const [currentSubMenu, setCurrentSubMenu] = useState<
|
const [currentSubMenu, setCurrentSubMenu] = useState<
|
||||||
"main" | "audio" | "video" | "controls" | "launcher" | "game"
|
"main" | "audio" | "video" | "controls" | "launcher" | "game" | "plugins"
|
||||||
>("main");
|
>("main");
|
||||||
const [runners, setRunners] = useState<Runner[]>([]);
|
const [runners, setRunners] = useState<Runner[]>([]);
|
||||||
|
const [pluginsInfo, setPluginsInfo] = useState<PluginInfo[]>([]);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [argsInput, setArgsInput] = useState("");
|
const [argsInput, setArgsInput] = useState("");
|
||||||
const [prefixInput, setPrefixInput] = useState("");
|
const [prefixInput, setPrefixInput] = useState("");
|
||||||
@@ -77,6 +79,16 @@ const SettingsView = memo(function SettingsView() {
|
|||||||
TauriService.getAvailableRunners().then(setRunners);
|
TauriService.getAvailableRunners().then(setRunners);
|
||||||
}, [isRunnerDownloading]);
|
}, [isRunnerDownloading]);
|
||||||
|
|
||||||
|
const refreshPlugins = useCallback(() => {
|
||||||
|
setPluginsInfo(PluginManager.instance.getPluginInfoList());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshPlugins();
|
||||||
|
PluginManager.instance.setEnabledChangedCallback(refreshPlugins);
|
||||||
|
return () => PluginManager.instance.setEnabledChangedCallback(null!);
|
||||||
|
}, [refreshPlugins]);
|
||||||
|
|
||||||
const handleLayoutToggle = () => {
|
const handleLayoutToggle = () => {
|
||||||
playPressSound();
|
playPressSound();
|
||||||
const currentIndex = layouts.indexOf(layout);
|
const currentIndex = layouts.indexOf(layout);
|
||||||
@@ -318,6 +330,16 @@ const SettingsView = memo(function SettingsView() {
|
|||||||
setFocusIndex(0);
|
setFocusIndex(0);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
items.push({
|
||||||
|
id: "plugins_menu",
|
||||||
|
label: "Plugins",
|
||||||
|
type: "button",
|
||||||
|
onClick: () => {
|
||||||
|
playPressSound();
|
||||||
|
setCurrentSubMenu("plugins");
|
||||||
|
setFocusIndex(0);
|
||||||
|
},
|
||||||
|
});
|
||||||
} else if (currentSubMenu === "audio") {
|
} else if (currentSubMenu === "audio") {
|
||||||
items.push({
|
items.push({
|
||||||
id: "music",
|
id: "music",
|
||||||
@@ -680,7 +702,9 @@ const SettingsView = memo(function SettingsView() {
|
|||||||
? "Controls"
|
? "Controls"
|
||||||
: currentSubMenu === "game"
|
: currentSubMenu === "game"
|
||||||
? "Game"
|
? "Game"
|
||||||
: "Launcher"}
|
: currentSubMenu === "plugins"
|
||||||
|
? "Plugins"
|
||||||
|
: "Launcher"}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{currentSubMenu === "main" ? (
|
{currentSubMenu === "main" ? (
|
||||||
@@ -750,6 +774,74 @@ const SettingsView = memo(function SettingsView() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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="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">
|
<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]"}`}
|
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 && (
|
{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
|
<img
|
||||||
src={
|
src={
|
||||||
focusIndex === index
|
focusIndex === index
|
||||||
@@ -811,14 +903,14 @@ const SettingsView = memo(function SettingsView() {
|
|||||||
: "/images/checkbox.png"
|
: "/images/checkbox.png"
|
||||||
}
|
}
|
||||||
alt="checkbox"
|
alt="checkbox"
|
||||||
className="w-full h-full object-contain"
|
className="absolute inset-0 w-full h-full object-contain"
|
||||||
style={{ imageRendering: "pixelated" }}
|
style={{ imageRendering: "pixelated" }}
|
||||||
/>
|
/>
|
||||||
{toggleState && (
|
{toggleState && (
|
||||||
<img
|
<img
|
||||||
src="/images/check.png"
|
src="/images/check.png"
|
||||||
alt="checked"
|
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" }}
|
style={{ imageRendering: "pixelated" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+6
-4
@@ -5,11 +5,13 @@ import App from "./pages/App";
|
|||||||
import "./css/index.css";
|
import "./css/index.css";
|
||||||
import "./css/App.css";
|
import "./css/App.css";
|
||||||
import { LauncherProvider } from "./context/LauncherContext";
|
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(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<LauncherProvider>
|
<PluginProvider>
|
||||||
<App />
|
<LauncherProvider>
|
||||||
</LauncherProvider>
|
<App />
|
||||||
|
</LauncherProvider>
|
||||||
|
</PluginProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
+107
-40
@@ -33,7 +33,11 @@ import {
|
|||||||
} from "../context/LauncherContext";
|
} from "../context/LauncherContext";
|
||||||
import { TauriService } from "../services/TauriService";
|
import { TauriService } from "../services/TauriService";
|
||||||
import { useLceLiveNotifications } from "../hooks/useLceLiveNotifications";
|
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 { Edition } from "../types/edition";
|
||||||
|
import type { ToastOptions } from "../plugins/types";
|
||||||
import pkg from "../../package.json";
|
import pkg from "../../package.json";
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const ui = useUI();
|
const ui = useUI();
|
||||||
@@ -68,8 +72,51 @@ export default function App() {
|
|||||||
const displayIsDay = config.isDayTime;
|
const displayIsDay = config.isDayTime;
|
||||||
|
|
||||||
const clearError = useCallback(() => game.setError(null), [game]);
|
const clearError = useCallback(() => game.setError(null), [game]);
|
||||||
const clearGameUpdate = useCallback(() => game.setGameUpdateMessage(null), [game]);
|
const clearGameUpdate = useCallback(
|
||||||
const clearSteamSuccess = useCallback(() => game.setSteamSuccessMessage(null), [game]);
|
() => 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(() => {
|
useEffect(() => {
|
||||||
if (showIntro && config.skipIntro) {
|
if (showIntro && config.skipIntro) {
|
||||||
@@ -79,20 +126,20 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.isLoaded) {
|
if (config.isLoaded) {
|
||||||
const setupCompleted = localStorage.getItem("lce-setup-completed") === "true";
|
const setupCompleted =
|
||||||
|
localStorage.getItem("lce-setup-completed") === "true";
|
||||||
setShowSetup(!setupCompleted);
|
setShowSetup(!setupCompleted);
|
||||||
setIsSetupChecked(true);
|
setIsSetupChecked(true);
|
||||||
}
|
}
|
||||||
}, [config.isLoaded]);
|
}, [config.isLoaded]);
|
||||||
|
|
||||||
const selectedEdition = useMemo(() =>
|
const selectedEdition = useMemo(
|
||||||
game.editions.find((e: Edition) => e.instanceId === config.profile),
|
() => game.editions.find((e: Edition) => e.instanceId === config.profile),
|
||||||
[game.editions, config.profile]
|
[game.editions, config.profile],
|
||||||
);
|
);
|
||||||
const selectedVersionName = selectedEdition?.name ?? "";
|
const selectedVersionName = selectedEdition?.name ?? "";
|
||||||
const hasAnyInstall = game.installs.length > 0;
|
const hasAnyInstall = game.installs.length > 0;
|
||||||
const titleImage = selectedEdition?.titleImage ?? "/images/MenuTitle.png";
|
const titleImage = selectedEdition?.titleImage ?? "/images/MenuTitle.png";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleContextMenu = (e: MouseEvent) => e.preventDefault();
|
const handleContextMenu = (e: MouseEvent) => e.preventDefault();
|
||||||
document.addEventListener("contextmenu", handleContextMenu);
|
document.addEventListener("contextmenu", handleContextMenu);
|
||||||
@@ -100,19 +147,25 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const animDuration = config.animationsEnabled ? undefined : { duration: 0 };
|
const animDuration = config.animationsEnabled ? undefined : { duration: 0 };
|
||||||
const uiFade = useMemo(() => ({
|
const uiFade = useMemo(
|
||||||
initial: { opacity: 0 },
|
() => ({
|
||||||
animate: { opacity: 1 },
|
initial: { opacity: 0 },
|
||||||
exit: { opacity: 0 },
|
animate: { opacity: 1 },
|
||||||
transition: animDuration ?? { duration: 0.5 },
|
exit: { opacity: 0 },
|
||||||
}), [animDuration]);
|
transition: animDuration ?? { duration: 0.5 },
|
||||||
|
}),
|
||||||
|
[animDuration],
|
||||||
|
);
|
||||||
|
|
||||||
const backgroundFade = useMemo(() => ({
|
const backgroundFade = useMemo(
|
||||||
initial: { opacity: 0 },
|
() => ({
|
||||||
animate: { opacity: 1 },
|
initial: { opacity: 0 },
|
||||||
exit: { opacity: 0 },
|
animate: { opacity: 1 },
|
||||||
transition: animDuration ?? { duration: 0.8 },
|
exit: { opacity: 0 },
|
||||||
}), [animDuration]);
|
transition: animDuration ?? { duration: 0.8 },
|
||||||
|
}),
|
||||||
|
[animDuration],
|
||||||
|
);
|
||||||
|
|
||||||
if (!config.isLoaded || !isSetupChecked) {
|
if (!config.isLoaded || !isSetupChecked) {
|
||||||
return <div className="w-screen h-screen bg-black" />;
|
return <div className="w-screen h-screen bg-black" />;
|
||||||
@@ -120,7 +173,9 @@ export default function App() {
|
|||||||
|
|
||||||
if (showSetup) {
|
if (showSetup) {
|
||||||
return (
|
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
|
<SetupView
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
setShowSetup(false);
|
setShowSetup(false);
|
||||||
@@ -133,7 +188,9 @@ export default function App() {
|
|||||||
|
|
||||||
if (showIntro && !config.skipIntro) {
|
if (showIntro && !config.skipIntro) {
|
||||||
return (
|
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
|
<CinematicIntro
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
setShowIntro(false);
|
setShowIntro(false);
|
||||||
@@ -149,7 +206,6 @@ export default function App() {
|
|||||||
<div
|
<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" : ""}`}
|
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">
|
<div className="absolute inset-0">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -173,10 +229,7 @@ export default function App() {
|
|||||||
editions={game.editions}
|
editions={game.editions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AchievementToast
|
<AchievementToast message={game.error} onClose={clearError} />
|
||||||
message={game.error}
|
|
||||||
onClose={clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AchievementToast
|
<AchievementToast
|
||||||
message={updateMessage}
|
message={updateMessage}
|
||||||
@@ -209,16 +262,22 @@ export default function App() {
|
|||||||
variant="steam"
|
variant="steam"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{pluginToast && (
|
||||||
|
<AchievementToast
|
||||||
|
message={pluginToast.message}
|
||||||
|
onClose={() => setPluginToast(null)}
|
||||||
|
title={pluginToast.options?.title}
|
||||||
|
variant={pluginToast.options?.variant}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
className="flex flex-col h-full z-10 w-full relative"
|
className="flex flex-col h-full z-10 w-full relative"
|
||||||
>
|
>
|
||||||
{!config.legacyMode && (
|
{!config.legacyMode && (
|
||||||
<motion.div
|
<motion.div {...uiFade} className="absolute top-4 left-8 z-50">
|
||||||
{...uiFade}
|
|
||||||
className="absolute top-4 left-8 z-50"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
audio.playPressSound();
|
audio.playPressSound();
|
||||||
@@ -330,14 +389,16 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
{activeView === "main" && hasAnyInstall && titleImage === "/images/MenuTitle.png" && (
|
{activeView === "main" &&
|
||||||
<motion.div
|
hasAnyInstall &&
|
||||||
{...uiFade}
|
titleImage === "/images/MenuTitle.png" && (
|
||||||
className="absolute -bottom-6 text-[#A0A0A0] text-sm mc-text-shadow tracking-widest uppercase opacity-80 font-['Mojangles']"
|
<motion.div
|
||||||
>
|
{...uiFade}
|
||||||
{selectedVersionName}
|
className="absolute -bottom-6 text-[#A0A0A0] text-sm mc-text-shadow tracking-widest uppercase opacity-80 font-['Mojangles']"
|
||||||
</motion.div>
|
>
|
||||||
)}
|
{selectedVersionName}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -408,6 +469,12 @@ export default function App() {
|
|||||||
{activeView === "credits" && (
|
{activeView === "credits" && (
|
||||||
<CreditsView key="credits-view" />
|
<CreditsView key="credits-view" />
|
||||||
)}
|
)}
|
||||||
|
{pluginViews.map((pv) => {
|
||||||
|
if (activeView === pv.id) {
|
||||||
|
return <PluginViewContainer key={pv.id} registry={pv} />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -422,8 +489,8 @@ export default function App() {
|
|||||||
Version: {pkg.version} ({__BUILD_DATE__})
|
Version: {pkg.version} ({__BUILD_DATE__})
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-[2] text-center whitespace-nowrap">
|
<div className="flex-[2] text-center whitespace-nowrap">
|
||||||
Not affiliated with Mojang AB or Microsoft. "Minecraft" is
|
Not affiliated with Mojang AB or Microsoft. "Minecraft" is a
|
||||||
a trademark of Mojang Synergies AB.
|
trademark of Mojang Synergies AB.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-right whitespace-nowrap">
|
<div className="flex-1 text-right whitespace-nowrap">
|
||||||
{connected && "CONTROLLER CONNECTED"}
|
{connected && "CONTROLLER CONNECTED"}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
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(
|
static async importWorld(
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
outputPath: string,
|
outputPath: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user