feat: another settings category, launch prefixes, backup&restore and more

This commit is contained in:
neoapps-dev
2026-05-26 19:15:56 +03:00
parent 3525bd6523
commit 0c1b2beced
14 changed files with 896 additions and 68 deletions
+129 -37
View File
@@ -1,7 +1,10 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { TauriService, PlaytimeResponse } from "../../services/TauriService";
import {
TauriService,
PlaytimeResponse,
PlaytimeDayEntry,
} from "../../services/TauriService";
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
@@ -11,6 +14,54 @@ function formatTime(seconds: number): string {
return `${s}s`;
}
function formatShort(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h`;
if (m > 0) return `${m}m`;
return `${seconds}s`;
}
function PlaytimeChart({ data }: { data: PlaytimeDayEntry[] }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || data.length === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = 320;
const h = 120;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const maxSecs = Math.max(...data.map((d) => d.seconds), 1);
const pad = { top: 8, bottom: 20, left: 6, right: 6 };
const chartW = w - pad.left - pad.right;
const chartH = h - pad.top - pad.bottom;
const barW = Math.min(28, (chartW - (data.length - 1) * 4) / data.length);
const gap = (chartW - barW * data.length) / (data.length - 1);
data.forEach((entry, i) => {
const x = pad.left + i * (barW + gap);
const barH = (entry.seconds / maxSecs) * chartH;
const y = pad.top + chartH - barH;
ctx.fillStyle = "#FFFF55";
ctx.fillRect(x, y, barW, barH);
ctx.fillStyle = "#FFFFFF";
ctx.font = "9px Mojangles, monospace";
ctx.textAlign = "center";
ctx.fillText(formatShort(entry.seconds), x + barW / 2, y - 3);
ctx.fillStyle = "#AAAAAA";
ctx.fillText(entry.label, x + barW / 2, h - 4);
});
}, [data]);
return <canvas ref={canvasRef} className="w-full max-w-[320px] mx-auto" />;
}
export default function PlaytimeModal({
isOpen,
onClose,
@@ -25,19 +76,27 @@ export default function PlaytimeModal({
playBackSound: (s?: string) => void;
}) {
const [playtime, setPlaytime] = useState<PlaytimeResponse | null>(null);
const [dailyData, setDailyData] = useState<PlaytimeDayEntry[]>([]);
const [loading, setLoading] = useState(false);
const [chartDays, setChartDays] = useState(7);
useEffect(() => {
if (!isOpen) {
setPlaytime(null);
setDailyData([]);
return;
}
setLoading(true);
TauriService.getPlaytime(instanceId)
.then(setPlaytime)
Promise.all([
TauriService.getPlaytime(instanceId),
TauriService.getPlaytimeDaily(instanceId, chartDays),
])
.then(([pt, daily]) => {
setPlaytime(pt);
setDailyData(daily);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [isOpen, instanceId]);
}, [isOpen, instanceId, chartDays]);
useEffect(() => {
if (!isOpen) return;
@@ -52,7 +111,6 @@ export default function PlaytimeModal({
}, [isOpen, onClose, playBackSound]);
if (!isOpen) return null;
return (
<motion.div
initial={{ opacity: 0 }}
@@ -72,41 +130,75 @@ export default function PlaytimeModal({
Playtime
</h2>
<p className="text-white text-sm mc-text-shadow mb-5 text-center">
<p className="text-white text-sm mc-text-shadow mb-4 text-center">
{instanceName}
</p>
{loading ? (
<div className="text-gray-400 text-sm mc-text-shadow mb-4">Loading...</div>
) : playtime ? (
<div className="w-full flex flex-col gap-3 mb-4">
<div className="flex justify-between items-center bg-black/30 px-4 py-3 border border-[#373737]">
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
Total
</span>
<span className="text-white text-lg mc-text-shadow font-bold">
{formatTime(playtime.totalSeconds)}
</span>
</div>
<div className="flex justify-between items-center bg-black/30 px-4 py-3 border border-[#373737]">
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
This Week
</span>
<span className="text-white text-lg mc-text-shadow font-bold">
{formatTime(playtime.weekSeconds)}
</span>
</div>
<div className="flex justify-between items-center bg-black/30 px-4 py-3 border border-[#373737]">
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
Today
</span>
<span className="text-white text-lg mc-text-shadow font-bold">
{formatTime(playtime.daySeconds)}
</span>
</div>
<div className="text-gray-400 text-sm mc-text-shadow mb-4">
Loading...
</div>
) : playtime ? (
<>
<div className="w-full flex flex-col gap-2 mb-4">
<div className="flex justify-between items-center bg-black/30 px-4 py-2 border border-[#373737]">
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
Total
</span>
<span className="text-white text-lg mc-text-shadow font-bold">
{formatTime(playtime.totalSeconds)}
</span>
</div>
<div className="flex justify-between items-center bg-black/30 px-4 py-2 border border-[#373737]">
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
This Week
</span>
<span className="text-white text-lg mc-text-shadow font-bold">
{formatTime(playtime.weekSeconds)}
</span>
</div>
<div className="flex justify-between items-center bg-black/30 px-4 py-2 border border-[#373737]">
<span className="text-[#AAAAAA] text-sm mc-text-shadow uppercase tracking-wider">
Today
</span>
<span className="text-white text-lg mc-text-shadow font-bold">
{formatTime(playtime.daySeconds)}
</span>
</div>
</div>
{dailyData.length > 0 && (
<div className="w-full mb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[#888] text-[10px] mc-text-shadow uppercase tracking-widest">
Daily Breakdown
</span>
<div className="flex gap-1">
{[3, 7, 14].map((d) => (
<button
key={d}
onClick={() => setChartDays(d)}
className={`text-[9px] px-1.5 py-0.5 border transition-colors ${
chartDays === d
? "border-[#FFFF55] text-[#FFFF55] bg-black/40"
: "border-[#555] text-[#888] bg-black/20"
} mc-text-shadow uppercase tracking-wider`}
>
{d}d
</button>
))}
</div>
</div>
<div className="bg-black/20 border border-[#373737] p-2">
<PlaytimeChart data={dailyData} />
</div>
</div>
)}
</>
) : (
<div className="text-red-400 text-sm mc-text-shadow mb-4">Failed to load playtime data.</div>
<div className="text-red-400 text-sm mc-text-shadow mb-4">
Failed to load playtime data.
</div>
)}
<button
+369 -5
View File
@@ -32,6 +32,12 @@ const SettingsView = memo(function SettingsView() {
setLegacyMode,
mangohudEnabled,
setMangohudEnabled,
extraLaunchArgs,
setExtraLaunchArgs,
launchPrefix,
setLaunchPrefix,
launchEnvVars,
setLaunchEnvVars,
} = useConfig();
const {
currentTrack,
@@ -50,10 +56,16 @@ 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"
"main" | "audio" | "video" | "controls" | "launcher" | "game"
>("main");
const [runners, setRunners] = useState<Runner[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const [argsInput, setArgsInput] = useState("");
const [prefixInput, setPrefixInput] = useState("");
const [envVarsInput, setEnvVarsInput] = useState("");
const [showModal, setShowModal] = useState<
"args" | "prefix" | "envVars" | null
>(null);
const layouts = ["KBM", "PLAYSTATION", "XBOX"];
@@ -282,6 +294,16 @@ const SettingsView = memo(function SettingsView() {
setFocusIndex(0);
},
});
items.push({
id: "game_menu",
label: "Game",
type: "button",
onClick: () => {
playPressSound();
setCurrentSubMenu("game");
setFocusIndex(0);
},
});
} else if (currentSubMenu === "audio") {
items.push({
id: "music",
@@ -331,6 +353,48 @@ const SettingsView = memo(function SettingsView() {
type: "button",
onClick: handleLayoutToggle,
});
} else if (currentSubMenu === "game") {
const envVarsCount = launchEnvVars
? Object.keys(launchEnvVars).length
: 0;
items.push({
id: "extra_launch_args",
label:
extraLaunchArgs && extraLaunchArgs.length > 0
? `Extra Args: ${extraLaunchArgs.join(" ")}`
: "Extra Launch Args: None",
type: "button",
onClick: () => {
playPressSound();
setArgsInput(extraLaunchArgs?.join(" ") ?? "");
setShowModal("args");
},
});
items.push({
id: "launch_prefix",
label: launchPrefix ? `Prefix: ${launchPrefix}` : "Launch Prefix: None",
type: "button",
onClick: () => {
playPressSound();
setPrefixInput(launchPrefix ?? "");
setShowModal("prefix");
},
});
items.push({
id: "launch_env_vars",
label: `Launch Env Vars: ${envVarsCount > 0 ? `${envVarsCount} set` : "None"}`,
type: "button",
onClick: () => {
playPressSound();
const current = launchEnvVars
? Object.entries(launchEnvVars)
.map(([k, v]) => `${k}=${v}`)
.join("\n")
: "";
setEnvVarsInput(current);
setShowModal("envVars");
},
});
} else if (currentSubMenu === "launcher") {
items.push({
id: "rpc",
@@ -375,6 +439,32 @@ const SettingsView = memo(function SettingsView() {
});
}
items.push({
id: "export_settings",
label: "Export Settings",
type: "button",
onClick: async () => {
playPressSound();
try {
await TauriService.exportSettings();
} catch (e) {
if (e !== "CANCELED") console.error(e);
}
},
});
items.push({
id: "import_settings",
label: "Import Settings",
type: "button",
onClick: async () => {
playPressSound();
try {
await TauriService.importSettings();
} catch (e) {
if (e !== "CANCELED") console.error(e);
}
},
});
items.push({
id: "reset_setup",
label: "Reset Setup",
@@ -444,11 +534,19 @@ const SettingsView = memo(function SettingsView() {
playBackSound,
setActiveView,
runners,
extraLaunchArgs,
launchPrefix,
launchEnvVars,
]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "Backspace") {
if (showModal) {
playBackSound();
setShowModal(null);
return;
}
playBackSound();
if (currentSubMenu !== "main") {
setCurrentSubMenu("main");
@@ -486,7 +584,14 @@ const SettingsView = memo(function SettingsView() {
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [focusIndex, settingsItems, playBackSound, setActiveView, currentSubMenu]);
}, [
focusIndex,
settingsItems,
playBackSound,
setActiveView,
currentSubMenu,
showModal,
]);
useEffect(() => {
if (focusIndex !== null) {
@@ -545,7 +650,9 @@ const SettingsView = memo(function SettingsView() {
? "Video"
: currentSubMenu === "controls"
? "Controls"
: "Launcher"}
: currentSubMenu === "game"
? "Game"
: "Launcher"}
</h2>
{currentSubMenu === "main" ? (
@@ -584,8 +691,10 @@ const SettingsView = memo(function SettingsView() {
);
}
const isRed = ("color" in item && (item as { color: string }).color === "red");
const isSmall = "small" in item && (item as { small: boolean }).small;
const isRed =
"color" in item && (item as { color: string }).color === "red";
const isSmall =
"small" in item && (item as { small: boolean }).small;
return (
<button
@@ -723,6 +832,261 @@ const SettingsView = memo(function SettingsView() {
</button>
);
})()}
{showModal === "args" && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md"
>
<div
className="relative w-[400px] p-6 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
Extra Launch Args
</h2>
<p className="text-[#AAAAAA] text-xs mb-4 text-center mc-text-shadow">
Space-separated arguments passed to the game executable
</p>
<input
autoFocus
value={argsInput}
onChange={(e) => setArgsInput(e.target.value)}
placeholder="e.g. -quitondisconnect -ip 127.0.0.1"
className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles'] text-center"
style={{ imageRendering: "pixelated" }}
/>
<div className="flex gap-4 mt-6 w-full justify-center">
<button
onClick={() => {
playBackSound();
setShowModal(null);
}}
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/button_highlighted.png')")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/Button_Background.png')")
}
>
Cancel
</button>
<button
onClick={() => {
playPressSound();
const trimmed = argsInput.trim();
setExtraLaunchArgs(trimmed ? trimmed.split(/\s+/) : []);
setShowModal(null);
}}
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/button_highlighted.png')")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/Button_Background.png')")
}
>
Save
</button>
</div>
</div>
</motion.div>
)}
{showModal === "prefix" && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md"
>
<div
className="relative w-[400px] p-6 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
Launch Prefix
</h2>
<p className="text-[#AAAAAA] text-xs mb-4 text-center mc-text-shadow">
Command that wraps the entire launch (e.g. gamemoderun)
</p>
<input
autoFocus
value={prefixInput}
onChange={(e) => setPrefixInput(e.target.value)}
placeholder="e.g. gamemoderun"
className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles'] text-center"
style={{ imageRendering: "pixelated" }}
/>
<div className="flex gap-4 mt-6 w-full justify-center">
<button
onClick={() => {
playBackSound();
setShowModal(null);
}}
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/button_highlighted.png')")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/Button_Background.png')")
}
>
Cancel
</button>
<button
onClick={() => {
playPressSound();
setLaunchPrefix(prefixInput.trim() || undefined);
setShowModal(null);
}}
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/button_highlighted.png')")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/Button_Background.png')")
}
>
Save
</button>
</div>
</div>
</motion.div>
)}
{showModal === "envVars" && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md"
>
<div
className="relative w-[420px] p-6 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
Launch Env Vars
</h2>
<p className="text-[#AAAAAA] text-xs mb-4 text-center mc-text-shadow">
One KEY=VALUE per line
</p>
<textarea
autoFocus
value={envVarsInput}
onChange={(e) => setEnvVarsInput(e.target.value)}
placeholder="WINEDLLOVERRIDES=foo=n&#10;MANGOHUD=1"
rows={6}
className="w-full px-3 py-2 bg-black/40 border-2 border-[#373737] text-white text-sm outline-none font-['Mojangles'] resize-none"
style={{ imageRendering: "pixelated" }}
/>
<div className="flex gap-4 mt-6 w-full justify-center">
<button
onClick={() => {
playBackSound();
setShowModal(null);
}}
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/button_highlighted.png')")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/Button_Background.png')")
}
>
Cancel
</button>
<button
onClick={() => {
playPressSound();
const trimmed = envVarsInput.trim();
if (trimmed) {
const vars: Record<string, string> = {};
for (const line of trimmed.split("\n")) {
const eqIdx = line.indexOf("=");
if (eqIdx > 0) {
vars[line.slice(0, eqIdx).trim()] = line
.slice(eqIdx + 1)
.trim();
}
}
setLaunchEnvVars(
Object.keys(vars).length > 0 ? vars : undefined,
);
} else {
setLaunchEnvVars(undefined);
}
setShowModal(null);
}}
className="w-32 h-10 flex items-center justify-center text-xl mc-text-shadow text-white transition-colors outline-none border-none hover:text-[#FFFF55]"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/button_highlighted.png')")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundImage =
"url('/images/Button_Background.png')")
}
>
Save
</button>
</div>
</div>
</motion.div>
)}
</motion.div>
);
});
+53
View File
@@ -620,6 +620,59 @@ const VersionsView = memo(function VersionsView() {
</svg>
Import World
</button>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
TauriService.backupInstance(edition.instanceId).catch((err) => {
if (err !== "CANCELED") console.error(err);
});
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
Backup
</button>
<button
onClick={async (e) => {
e.stopPropagation();
playPressSound();
setOpenMenuId(null);
try {
await TauriService.restoreInstance();
} catch (err) {
if (err !== "CANCELED") console.error(err);
}
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<path d="M21 12a9 9 0 1 1-9-9 9 9 0 0 1 9 9z" />
<polyline points="12 7 12 12 15 15" />
</svg>
Restore
</button>
<button
onClick={(e) => {
e.stopPropagation();
+55
View File
@@ -633,6 +633,61 @@ const WorkshopView = memo(function WorkshopView() {
ref={gridRef}
className="flex-1 overflow-y-auto p-6 scroll-smooth"
>
{isInstalledTab && !search.trim() && filteredItems.length > 0 && (
<div className="flex items-center justify-center gap-3 mb-4 pb-3 border-b border-[#333]">
<button
onClick={async () => {
const updates = filteredItems.filter((p) => hasUpdate(p));
if (updates.length === 0) return;
playPressSound();
for (const pkg of updates) {
const entries = installedPkgs.filter((p) => p.packageId === pkg.id);
for (const entry of entries) {
try {
await TauriService.workshopInstall(entry.instanceId, pkg.id, pkg.zips!, pkg.version);
} catch (e) {
console.error(e);
}
}
}
refreshInstalled();
}}
className="h-8 px-4 flex items-center justify-center text-sm mc-text-shadow border border-[#555] text-[#FFFF55] hover:bg-white/10 transition-colors"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Update All ({filteredItems.filter((p) => hasUpdate(p)).length})
</button>
<button
onClick={async () => {
if (filteredItems.length === 0) return;
playPressSound();
for (const pkg of filteredItems) {
const entries = installedPkgs.filter((p) => p.packageId === pkg.id);
for (const entry of entries) {
try {
await TauriService.workshopInstall(entry.instanceId, pkg.id, pkg.zips!, pkg.version);
} catch (e) {
console.error(e);
}
}
}
refreshInstalled();
}}
className="h-8 px-4 flex items-center justify-center text-sm mc-text-shadow border border-[#555] text-white hover:bg-white/10 transition-colors"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Reinstall All ({filteredItems.length})
</button>
</div>
)}
{isSearchTab && !search.trim() ? (
<div className="flex flex-col items-center justify-center h-[200px] opacity-40">
<span className="text-xl mc-text-shadow tracking-widest uppercase">
+8 -2
View File
@@ -51,6 +51,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
setProfile: configRaw.setProfile,
customEditions: configRaw.customEditions,
setCustomEditions: configRaw.setCustomEditions,
extraLaunchArgs: configRaw.extraLaunchArgs,
});
const skinSync = useSkinSync({ username: configRaw.username, profile: configRaw.profile, editions: gameRaw.editions });
const audioRaw = useAudioController({
@@ -65,7 +66,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled,
configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime,
configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions,
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled,
configRaw.extraLaunchArgs, configRaw.launchPrefix, configRaw.launchEnvVars
]);
const game = useMemo(() => gameRaw, [
@@ -130,6 +132,9 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
sfxVol: config.sfxVol,
legacyMode: config.legacyMode,
mangohudEnabled: config.mangohudEnabled,
extraLaunchArgs: config.extraLaunchArgs,
launchPrefix: config.launchPrefix,
launchEnvVars: config.launchEnvVars,
}).catch(console.error);
}
}, [
@@ -137,7 +142,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
config.perfBoost, config.customEditions, config.profile,
config.vfxEnabled, config.animationsEnabled,
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
config.mangohudEnabled, config.isLoaded
config.mangohudEnabled, config.extraLaunchArgs, config.launchPrefix,
config.launchEnvVars, config.isLoaded
]);
useEffect(() => {
+16 -1
View File
@@ -19,6 +19,9 @@ export function useAppConfig() {
const [perfBoost, setPerfBoost] = useState(false);
const [customEditions, setCustomEditions] = useState<CustomEdition[]>([]);
const [mangohudEnabled, setMangohudEnabled] = useState(false);
const [extraLaunchArgs, setExtraLaunchArgs] = useState<string[] | undefined>();
const [launchPrefix, setLaunchPrefix] = useState<string | undefined>();
const [launchEnvVars, setLaunchEnvVars] = useState<Record<string, string> | undefined>();
useEffect(() => {
TauriService.loadConfig().then((config) => {
if (config.username) setUsername(config.username);
@@ -35,6 +38,9 @@ export function useAppConfig() {
if (config.sfxVol !== undefined && config.sfxVol !== null) setSfxVol(config.sfxVol);
if (config.legacyMode !== undefined) setLegacyMode(config.legacyMode);
if (config.mangohudEnabled !== undefined) setMangohudEnabled(config.mangohudEnabled);
if (config.extraLaunchArgs) setExtraLaunchArgs(config.extraLaunchArgs);
if (config.launchPrefix) setLaunchPrefix(config.launchPrefix);
if (config.launchEnvVars) setLaunchEnvVars(config.launchEnvVars);
setIsLoaded(true);
});
}, []);
@@ -55,9 +61,12 @@ export function useAppConfig() {
sfxVol,
legacyMode,
mangohudEnabled,
extraLaunchArgs,
launchPrefix,
launchEnvVars,
}).catch(console.error);
}
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, mangohudEnabled, isLoaded]);
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, isLoaded]);
return {
username,
@@ -93,5 +102,11 @@ export function useAppConfig() {
setHasCompletedSetup,
mangohudEnabled,
setMangohudEnabled,
extraLaunchArgs,
setExtraLaunchArgs,
launchPrefix,
setLaunchPrefix,
launchEnvVars,
setLaunchEnvVars,
};
}
+3 -1
View File
@@ -77,6 +77,7 @@ interface GameManagerProps {
setProfile: (id: string) => void;
customEditions: CustomEdition[];
setCustomEditions: (editions: CustomEdition[]) => void;
extraLaunchArgs?: string[];
}
function compareVersions(v1: string, v2: string) {
@@ -101,6 +102,7 @@ export function useGameManager({
setProfile,
customEditions,
setCustomEditions,
extraLaunchArgs,
}: GameManagerProps) {
const [installs, setInstalls] = useState<string[]>([]);
const [isGameRunning, setIsGameRunning] = useState(false);
@@ -410,7 +412,7 @@ export function useGameManager({
setIsGameRunning(true);
try {
getCurrentWindow().minimize();
await TauriService.launchGame(profile, PARTNERSHIP_SERVERS);
await TauriService.launchGame(profile, PARTNERSHIP_SERVERS, extraLaunchArgs);
} catch (e: unknown) {
console.error(e);
setError(
+28
View File
@@ -41,6 +41,9 @@ export interface AppConfig {
legacyMode?: boolean;
mangohudEnabled?: boolean;
savedServers?: McServer[];
extraLaunchArgs?: string[];
launchPrefix?: string;
launchEnvVars?: Record<string, string>;
}
export interface ThemePalette {
@@ -74,6 +77,11 @@ export interface PlaytimeResponse {
daySeconds: number;
}
export interface PlaytimeDayEntry {
label: string;
seconds: number;
}
export class TauriService {
static async saveConfig(config: AppConfig): Promise<void> {
return invoke("save_config", { config });
@@ -91,6 +99,14 @@ export class TauriService {
return invoke("import_theme");
}
static async exportSettings(): Promise<void> {
return invoke("export_settings");
}
static async importSettings(): Promise<string> {
return invoke("import_settings");
}
static async getAvailableRunners(): Promise<Runner[]> {
return invoke("get_available_runners");
}
@@ -293,6 +309,10 @@ export class TauriService {
return invoke("get_playtime", { instanceId });
}
static async getPlaytimeDaily(instanceId: string, days: number): Promise<PlaytimeDayEntry[]> {
return invoke("get_playtime_daily", { instanceId, days });
}
static async getInstancePath(instanceId: string): Promise<string> {
return invoke("get_instance_path", { instanceId });
}
@@ -301,6 +321,14 @@ export class TauriService {
return invoke("read_screenshot_as_data_url", { path });
}
static async backupInstance(instanceId: string): Promise<void> {
return invoke("backup_instance", { instanceId });
}
static async restoreInstance(): Promise<string> {
return invoke("restore_instance");
}
static async importWorld(
inputPath: string,
outputPath: string,