diff --git a/public/i18n/zh-CN/pages.yml b/public/i18n/zh-CN/pages.yml index 11c45d58..d5fb74bc 100644 --- a/public/i18n/zh-CN/pages.yml +++ b/public/i18n/zh-CN/pages.yml @@ -1,9 +1,27 @@ +create-game: + title: 创建新游戏 + name-input-title: 游戏名称 + version-select-title: 游戏版本 + storage-title: 存储 + container-select-title: 选择要共享存储空间的游戏 + container-select-placeholder: 未选取 + version-select-placeholder: 未选取 + storage-policy: + new: 独立分配 + share: 与已有游戏共享 + account-title: 帐户 + default-name: 新游戏({{date}}) + loading-versions: 正在获取版本列表…… + + account-tip: 现在需要为每个游戏重新登录帐户。在未来的版本中,将能够选择使用已有的帐户。 + create-btn: 创建 + toast-created: 已创建游戏。 + games: loading: 正在读取游戏列表…… load-list-failed: 读取游戏列表失败。 title: 游戏库 new: 创建新游戏 - reload: 重新加载 load-failed: 无法读取 {{name}} (标识符为 {{id}}),此游戏可能已损坏。 remove-failed: 删除游戏 auth-failed: diff --git a/src/main/api/auth.ts b/src/main/api/auth.ts index a8b9fe12..8485d063 100644 --- a/src/main/api/auth.ts +++ b/src/main/api/auth.ts @@ -1,8 +1,17 @@ +import { VanillaAccount } from "@/main/auth/vanilla"; import { ipcMain } from "@/main/ipc/typed"; import { reg } from "@/main/registry/registry"; -ipcMain.handle("gameAuth", (_, gameId) => { +ipcMain.handle("gameAuth", async (_, gameId) => { const g = reg.games.get(gameId); - const a = reg.accounts.get(g.launchHint.accountId); - return a.refresh(); + + const a = g.launchHint.accountId ? reg.accounts.get(g.launchHint.accountId) : new VanillaAccount(); + + const success = await a.refresh(); + + if (success) { + g.launchHint.accountId = a.uuid; + } + + return success; }); diff --git a/src/main/api/game.ts b/src/main/api/game.ts index b7b3adae..f0802206 100644 --- a/src/main/api/game.ts +++ b/src/main/api/game.ts @@ -1,9 +1,14 @@ import { containerInspector } from "@/main/container/inspect"; import { containers } from "@/main/container/manage"; +import type { ContainerSpec } from "@/main/container/spec"; +import { paths } from "@/main/fs/paths"; import { gameInspector } from "@/main/game/inspect"; +import type { GameProfile } from "@/main/game/spec"; +import { vanillaInstaller } from "@/main/install/vanilla"; import { ipcMain } from "@/main/ipc/typed"; import { reg } from "@/main/registry/registry"; import { shell } from "electron"; +import { nanoid } from "nanoid"; ipcMain.handle("listGames", () => reg.games.getAll()); ipcMain.handle("tellGame", (_, gameId) => gameInspector.tellGame(gameId)); @@ -20,4 +25,46 @@ ipcMain.on("revealGameContent", async (_, gameId, scope) => { void shell.openPath(container.content(scope)); }); -ipcMain.handle("addGame", (_, game) => reg.games.add(game.id, game)); +export interface CreateGameInit { + name: string; + profileId: string; + containerId?: string; +} + +ipcMain.handle("addGame", async (_, init) => { + const { name, profileId, containerId } = init; + + let cid = containerId; + + if (!cid) { + cid = nanoid(); + const spec: ContainerSpec = { + id: cid, + root: paths.game.to(cid), + flags: {} // TODO add support for linking + }; + reg.containers.add(cid, spec); + } + + const g: GameProfile = { + id: nanoid(), + name, + installed: false, + launchHint: { + accountId: "", // TODO update ID when other account types are added + containerId: cid, + profileId, + pref: {} // TODO allow user to choose pref + }, + time: Date.now() + }; + + reg.games.add(g.id, g); + + const c = containers.get(cid); + + // TODO it may be possible to delay the installation to launch stage + // This can be helpful when creating games with mod loaders + // Implement it and then remove this + await vanillaInstaller.installProfile(profileId, c); +}); diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 8158276b..b66e3d49 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -1,3 +1,4 @@ +import type { CreateGameInit } from "@/main/api/game"; import type { LaunchGameResult } from "@/main/api/launcher"; import type { UserConfig } from "@/main/conf/conf"; import type { GameProfile, GameProfileDetail } from "@/main/game/spec"; @@ -23,6 +24,6 @@ export type IpcCommands = { tellGame: (gameId: string) => GameProfileDetail; launch: (id: string) => LaunchGameResult; gameAuth: (gameId: string) => boolean; - addGame: (game: GameProfile) => void; + addGame: (game: CreateGameInit) => void; getVersionManifest: () => VersionManifest; } diff --git a/src/main/launch/types.ts b/src/main/launch/types.ts index 9bbc23d5..fddbd500 100644 --- a/src/main/launch/types.ts +++ b/src/main/launch/types.ts @@ -7,9 +7,12 @@ import { VersionProfile } from "@/main/profile/version-profile"; * User is capable for customizing the detailed launch options without creating new containers or create additional files. */ export interface LaunchHint { - id: string; containerId: string; profileId: string; + + /** + * Account identifier. An empty string indicates a new account should be created when launching. + */ accountId: string; pref: Partial; } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 4982142d..162ba659 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,3 +1,4 @@ +import type { CreateGameInit } from "@/main/api/game"; import type { LaunchGameResult } from "@/main/api/launcher"; import type { UserConfig } from "@/main/conf/conf"; import type { GameProfile, GameProfileDetail } from "@/main/game/spec"; @@ -72,7 +73,7 @@ const native = { /** * Adds the specified game to registry. */ - add(game: GameProfile): Promise { + add(game: CreateGameInit): Promise { return ipcRenderer.invoke("addGame", game); } }, @@ -82,9 +83,9 @@ const native = { */ auth: { /** - * Refreshes the account of the given game. + * Authenticate the account of the specified game. */ - refresh(id: string): Promise { + forGame(id: string): Promise { return ipcRenderer.invoke("gameAuth", id); } }, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 21a3e2d0..a1a474a3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3,8 +3,10 @@ import { themeManager, useTheme } from "@/renderer/theme"; import { Navigator } from "@components/Navigator"; import { HeroUIProvider } from "@heroui/react"; import { AboutView } from "@pages/about/AboutView"; +import { CreateGameView } from "@pages/create-game/CreateGameView"; import { GamesView } from "@pages/games/GamesView"; -import { MonitorListView, MonitorView } from "@pages/monitor/MonitorView"; +import { MonitorListView } from "@pages/monitor-list/MonitorListView"; +import { MonitorView } from "@pages/monitor/MonitorView"; import { pages } from "@pages/pages"; import { SettingsView } from "@pages/settings/SettingsView"; import React from "react"; @@ -63,6 +65,7 @@ function Routes() { return + diff --git a/src/renderer/pages/create-game/ContainerSelector.tsx b/src/renderer/pages/create-game/ContainerSelector.tsx new file mode 100644 index 00000000..7595e4f6 --- /dev/null +++ b/src/renderer/pages/create-game/ContainerSelector.tsx @@ -0,0 +1,38 @@ +import { useGameList } from "@/renderer/services/game"; +import { Select, SelectItem, type SharedSelection } from "@heroui/react"; +import { useTranslation } from "react-i18next"; + +interface ContainerSelectorProps { + enabled: boolean; + containerId?: string; + onChange: (containerId?: string) => void; +} + +export function ContainerSelector({ enabled, containerId, onChange }: ContainerSelectorProps) { + const { t } = useTranslation("pages", { keyPrefix: "create-game" }); + + const games = useGameList(); + + const sid = games.find(g => g.launchHint.containerId === containerId)?.id; + + function handleSelectionChange(s: SharedSelection) { + if (s instanceof Set) { + const gid = [...s][0]; + if (!gid) { + onChange(undefined); + } else { + onChange(games.find(g => g.id === gid)?.launchHint.containerId); + } + } + } + + return ; +} diff --git a/src/renderer/pages/create-game/CreateGameView.tsx b/src/renderer/pages/create-game/CreateGameView.tsx new file mode 100644 index 00000000..b0512420 --- /dev/null +++ b/src/renderer/pages/create-game/CreateGameView.tsx @@ -0,0 +1,95 @@ +import { Alert } from "@components/Alert"; +import { Radio, RadioGroup } from "@heroui/radio"; +import { Button, Input } from "@heroui/react"; +import { ContainerSelector } from "@pages/create-game/ContainerSelector"; +import { VersionSelector } from "@pages/create-game/VersionSelector"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; +import { useLocation } from "wouter"; + + +/** + * Create new game without the help of the wizard. + */ +export function CreateGameView() { + const { t } = useTranslation("pages", { keyPrefix: "create-game" }); + + const [gameName, setGameName] = useState(t("default-name", { date: new Date().toLocaleString() })); + const [gameVersion, setGameVersion] = useState(); + const [containerId, setContainerId] = useState(); + const [shareContainer, setShareContainer] = useState(false); + + const [creating, setCreating] = useState(false); + const [, nav] = useLocation(); + + + const valid = gameVersion && (!shareContainer || (shareContainer && containerId)); + + async function handleCreate() { + if (valid) { + setCreating(true); + // TODO add error handler + await native.game.add({ + name: gameName, + containerId, + profileId: gameVersion + }); + setCreating(false); + toast(t("toast-created"), { type: "success" }); + nav("/games"); + } + } + + return
+
{t("title")}
+
+
+
{t("name-input-title")}
+ +
+ +
+
{t("version-select-title")}
+ +
+ + +
+
{t("storage-title")}
+ + setShareContainer(v === "share")} + > + {t("storage-policy.new")} + {t("storage-policy.share")} + + + +
+ + +
+
{t("account-title")}
+ + +
+ + +
+
; +} diff --git a/src/renderer/pages/create-game/VersionSelector.tsx b/src/renderer/pages/create-game/VersionSelector.tsx new file mode 100644 index 00000000..4308e226 --- /dev/null +++ b/src/renderer/pages/create-game/VersionSelector.tsx @@ -0,0 +1,96 @@ +import type { VersionEntry } from "@/main/install/vanilla"; +import { useVersionManifest } from "@/renderer/services/sources"; +import grassBlock from "@assets/img/grass-block.webp"; +import snowyGrassBlock from "@assets/img/snowy-grass-block.webp"; +import { Checkbox, Select, SelectItem, type SharedSelection, Spinner } from "@heroui/react"; +import { DotIcon } from "lucide-react"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; + +interface VersionSelectorProps { + version?: string; + onChange: (version?: string) => void; +} + +export function VersionSelector({ version, onChange }: VersionSelectorProps) { + const vm = useVersionManifest(); + const { t } = useTranslation("pages", { keyPrefix: "create-game.version-select" }); + const [showSnapshots, setShowSnapshots] = useState(false); + + if (!vm) return ; + + let versions = vm.versions; + + if (!showSnapshots) { + versions = vm.versions.filter(v => v.type !== "snapshot"); + } + + function handleSelectionChange(sel: SharedSelection) { + if (sel instanceof Set) { + const s = [...sel]; + onChange(s[0]?.toString()); // Might be null when no selection + } + } + + return
+
+ +
+ +
+ 包含快照 +
+ +
; +} + +function VersionContent({ version: { id, type, sha1, releaseTime } }: { version: VersionEntry }) { + const src = type === "release" ? grassBlock : snowyGrassBlock; + + return
+
+ version +
+ +
+
{id}
+
+ {sha1} + + {new Date(releaseTime).toLocaleString()} +
+
+
; +} + +function VersionLoading() { + const { t } = useTranslation("pages", { keyPrefix: "create-game" }); + + return
+ + + {t("loading-versions")} + +
; +} diff --git a/src/renderer/pages/games/GameCard.tsx b/src/renderer/pages/games/GameCard.tsx index 7a2791d0..7c93d7cb 100644 --- a/src/renderer/pages/games/GameCard.tsx +++ b/src/renderer/pages/games/GameCard.tsx @@ -1,5 +1,5 @@ import type { GameProfileDetail } from "@/main/game/spec"; -import { remoteGame } from "@/renderer/lib/remote-game"; +import { procService } from "@/renderer/services/proc"; import { GameTypeImage } from "@components/GameTypeImage"; import { Alert, @@ -170,7 +170,7 @@ function GameActions({ installed, detail }: GameActionsProps) { async function launch() { setLaunching(true); - const authed = await native.auth.refresh(detail.id); + const authed = await native.auth.forGame(detail.id); if (!authed) { toast(AuthFailedToast, { type: "error" }); @@ -178,7 +178,7 @@ function GameActions({ installed, detail }: GameActionsProps) { } // TODO add error handler - const procId = await remoteGame.create(detail); + const procId = await procService.create(detail); setLaunching(false); nav(`/monitor/${procId}`); } diff --git a/src/renderer/pages/games/GamesView.tsx b/src/renderer/pages/games/GamesView.tsx index 7329a833..5901deeb 100644 --- a/src/renderer/pages/games/GamesView.tsx +++ b/src/renderer/pages/games/GamesView.tsx @@ -1,38 +1,26 @@ import type { GameProfile } from "@/main/game/spec"; +import { useGameList } from "@/renderer/services/game"; import { Alert } from "@components/Alert"; import { Button, ButtonGroup, Spinner, Tooltip } from "@heroui/react"; import { GameCardDisplay } from "@pages/games/GameCard"; -import { - ArrowDownAZIcon, - ArrowUpAZIcon, - ClockArrowDownIcon, - ClockArrowUpIcon, - PlusIcon, - RefreshCcwIcon -} from "lucide-react"; -import { useEffect, useState } from "react"; +import { ArrowDownAZIcon, ArrowUpAZIcon, ClockArrowDownIcon, ClockArrowUpIcon, PlusIcon } from "lucide-react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocalStorage } from "react-use"; +import { useLocation } from "wouter"; /** * The index page of game launching, listing user-defined games for playing. */ export function GamesView() { - const [games, setGames] = useState(); + const games = useGameList(); const [error, setError] = useState(); const [sortMethod, setSortMethod] = useLocalStorage("games.sort-method", "latest"); + const [, nav] = useLocation(); const { t } = useTranslation("pages", { keyPrefix: "games" }); - async function loadGames() { - native.game.list().then(setGames).catch(setError); - } - - useEffect(() => { - void loadGames(); - }, []); - if (error !== undefined) { - return ; + return ; } if (!games) { @@ -43,18 +31,16 @@ export function GamesView() { return
- - - - - -
@@ -105,7 +91,6 @@ function SortMethodControl({ sortMethod, onChange }: SortMethodControlProps) {
; } -function FailedAlert({ retry }: { retry: () => void }) { +function FailedAlert() { const { t } = useTranslation("pages", { keyPrefix: "games" }); return -
- - {t("reload")} -
- - } />; } diff --git a/src/renderer/pages/monitor-list/MonitorListView.tsx b/src/renderer/pages/monitor-list/MonitorListView.tsx new file mode 100644 index 00000000..23cbfd89 --- /dev/null +++ b/src/renderer/pages/monitor-list/MonitorListView.tsx @@ -0,0 +1,70 @@ +import { type RemoteGameProcess, type RemoteGameStatus, useGameProcList } from "@/renderer/services/proc"; +import { GameTypeImage } from "@components/GameTypeImage"; +import { Button, Card, CardBody, Chip } from "@heroui/react"; +import { ArrowRightIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "wouter"; + +export function MonitorListView() { + const procs = useGameProcList(); + + return
+
+ { + procs.map(p => ) + } +
+
; +} + +const statusColors = { + running: "success", + crashed: "danger", + exited: "default" +} as const; + +function StatusChip({ status }: { status: RemoteGameStatus }) { + const { t } = useTranslation("pages", { keyPrefix: "monitor.status" }); + + return + {t(status)} + ; +} + +function MonitorItem({ proc }: { proc: RemoteGameProcess }) { + const { detail: { modLoader, stable, name, versionId }, status } = proc; + const [, nav] = useLocation(); + + function revealProc() { + nav(`/monitor/${proc.id}`); + } + + return + +
+
+ +
+ +
+
{name}
+
{versionId}
+
+ +
+ +
+ + +
+ +
+
+
+
; +} diff --git a/src/renderer/pages/monitor/Monitor.tsx b/src/renderer/pages/monitor/Monitor.tsx deleted file mode 100644 index 60fccd79..00000000 --- a/src/renderer/pages/monitor/Monitor.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import type { GameProcessLog } from "@/main/launch/log-parser"; -import { type RemoteGameProcess, type RemoteGameStatus, useGameProc } from "@/renderer/lib/remote-game"; -import { GameTypeImage } from "@components/GameTypeImage"; -import { Button, Card, CardBody, Popover, PopoverContent, PopoverTrigger, Tab, Tabs } from "@heroui/react"; -import { clsx } from "clsx"; -import { - ArrowLeftIcon, - ArrowRightIcon, - CircleHelpIcon, - CpuIcon, - FileClockIcon, - FolderArchive, - OctagonXIcon -} from "lucide-react"; -import React, { useContext, useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { VList, type VListHandle } from "virtua"; -import { useLocation } from "wouter"; - -const GameProcessContext = React.createContext(null); - -export function Monitor({ procId }: { procId: string }) { - const proc = useGameProc(procId, 200); - - return -
-
- -
-
- -
-
-
; -} - -function ContentArea() { - const { t } = useTranslation("pages", { keyPrefix: "monitor" }); - - return - - - {t("log-view")} -
- } - > - - - - - {t("perf-view")} - - } - > - - ; -} - -function LogsDisplay() { - const autoScroll = useRef(true); - const prevOffset = useRef(0); - const ref = useRef(null); - const { id: procId, logs } = useContext(GameProcessContext)!; - - function scrollToBottom() { - ref.current?.scrollToIndex(Number.MAX_SAFE_INTEGER, { smooth: true }); - } - - useEffect(() => { - if (autoScroll.current) { - scrollToBottom(); - } - }, [logs]); - - function updateAutoScroll(offset: number) { - if (ref.current) { - if (offset < prevOffset.current) { - // Cancel auto scroll when scrolling up - autoScroll.current = false; - return; - } else if (ref.current.scrollOffset - ref.current.scrollSize + ref.current.viewportSize >= -10) { - // Enable auto scroll when not scrolling up & reached the bottom - // But does not cancel it if not at the bottom (due to smooth scrolling) - autoScroll.current = true; - } - - prevOffset.current = offset; - } - } - - return { - // Prevent misjudged scroll up when layout shifts - prevOffset.current = 0; - }} - > - { - logs.map(log => - // The key includes process ID to correctly handle props changes when switching between monitors - - ) - } - ; -} - -const LogLineMemo = React.memo(LogLine, (prevProps, nextProps) => { - // Log objects are not changed once parsed - // By comparing props via index we save lots of re-renders - return nextProps.log.index === prevProps.log.index; -}); - -function LogLine({ log }: { log: GameProcessLog }) { - const { time, level, message, throwable } = log; - - return
-
-
- {new Date(time).toLocaleTimeString()} -
-
- {message} -
{throwable}
-
-
-
; -} - -function ControlPanel() { - const proc = useContext(GameProcessContext)!; - const { detail: { name, modLoader, stable }, status } = proc; - - return
-
- -
- -
; -} - -interface GameInfoCardProps { - name: string; - modLoader: string; - stable: boolean; - status: RemoteGameStatus; -} - -const GameInfoCardMemo = React.memo(GameInfoCard); - -function GameInfoCard({ name, modLoader, stable, status }: GameInfoCardProps) { - const { t } = useTranslation("pages", { keyPrefix: "monitor.status" }); - - return - -
-
- -
-
{name}
-
{t(status)}
-
-
-
; -} - -interface MonitorActionsProps { - procId: string; - gameId: string; - status: RemoteGameStatus; -} - -const MonitorActionsMemo = React.memo(MonitorActions); - -function MonitorActions({ procId, gameId, status }: MonitorActionsProps) { - const { t } = useTranslation("pages", { keyPrefix: "monitor.actions" }); - const [stopConfirmOpen, setStopConfirmOpen] = useState(false); - const [, nav] = useLocation(); - - function handleStopAction() { - native.launcher.stop(procId); - setStopConfirmOpen(false); - } - - - const stopDisabled = status !== "running"; - - return
- - - - - - - -
-
- -
{t("stop-title")}
-
- -
{t("stop-sub")}
- -
-
-
-
; -} diff --git a/src/renderer/pages/monitor/MonitorView.tsx b/src/renderer/pages/monitor/MonitorView.tsx index b94cabdf..87c28e07 100644 --- a/src/renderer/pages/monitor/MonitorView.tsx +++ b/src/renderer/pages/monitor/MonitorView.tsx @@ -1,9 +1,20 @@ -import { type RemoteGameProcess, type RemoteGameStatus, useGameProcList } from "@/renderer/lib/remote-game"; +import type { GameProcessLog } from "@/main/launch/log-parser"; +import { type RemoteGameProcess, type RemoteGameStatus, useGameProc } from "@/renderer/services/proc"; import { GameTypeImage } from "@components/GameTypeImage"; -import { Button, Card, CardBody, Chip } from "@heroui/react"; -import { Monitor } from "@pages/monitor/Monitor"; -import { ArrowRightIcon } from "lucide-react"; +import { Button, Card, CardBody, Popover, PopoverContent, PopoverTrigger, Tab, Tabs } from "@heroui/react"; +import { clsx } from "clsx"; +import { + ArrowLeftIcon, + ArrowRightIcon, + CircleHelpIcon, + CpuIcon, + FileClockIcon, + FolderArchive, + OctagonXIcon +} from "lucide-react"; +import React, { useContext, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { VList, type VListHandle } from "virtua"; import { useLocation, useParams } from "wouter"; export function MonitorView() { @@ -11,66 +22,244 @@ export function MonitorView() { return ; } -export function MonitorListView() { - const procs = useGameProcList(); +const GameProcessContext = React.createContext(null); - return
-
- { - procs.map(p => ) +function Monitor({ procId }: { procId: string }) { + const proc = useGameProc(procId, 200); + + return +
+
+ +
+
+ +
+
+
; +} + +function ContentArea() { + const { t } = useTranslation("pages", { keyPrefix: "monitor" }); + + return + + + {t("log-view")} +
+ } + > + + + + + {t("perf-view")} +
+ } + > + + ; +} + +function LogsDisplay() { + const autoScroll = useRef(true); + const prevOffset = useRef(0); + const ref = useRef(null); + const { id: procId, logs } = useContext(GameProcessContext)!; + + function scrollToBottom() { + ref.current?.scrollToIndex(Number.MAX_SAFE_INTEGER, { smooth: true }); + } + + useEffect(() => { + if (autoScroll.current) { + scrollToBottom(); + } + }, [logs]); + + function updateAutoScroll(offset: number) { + if (ref.current) { + if (offset < prevOffset.current) { + // Cancel auto scroll when scrolling up + autoScroll.current = false; + return; + } else if (ref.current.scrollOffset - ref.current.scrollSize + ref.current.viewportSize >= -10) { + // Enable auto scroll when not scrolling up & reached the bottom + // But does not cancel it if not at the bottom (due to smooth scrolling) + autoScroll.current = true; } + + prevOffset.current = offset; + } + } + + return { + // Prevent misjudged scroll up when layout shifts + prevOffset.current = 0; + }} + > + { + logs.map(log => + // The key includes process ID to correctly handle props changes when switching between monitors + + ) + } + ; +} + +const LogLineMemo = React.memo(LogLine, (prevProps, nextProps) => { + // Log objects are not changed once parsed + // By comparing props via index we save lots of re-renders + return nextProps.log.index === prevProps.log.index; +}); + +function LogLine({ log }: { log: GameProcessLog }) { + const { time, level, message, throwable } = log; + + return
+
+
+ {new Date(time).toLocaleTimeString()} +
+
+ {message} +
{throwable}
+
; } -const statusColors = { - running: "success", - crashed: "danger", - exited: "default" -} as const; +function ControlPanel() { + const proc = useContext(GameProcessContext)!; + const { detail: { name, modLoader, stable }, status } = proc; -function StatusChip({ status }: { status: RemoteGameStatus }) { - const { t } = useTranslation("pages", { keyPrefix: "monitor.status" }); + return
+
+ +
+ +
; +} - return - {t(status)} - ; +interface GameInfoCardProps { + name: string; + modLoader: string; + stable: boolean; + status: RemoteGameStatus; } -function MonitorItem({ proc }: { proc: RemoteGameProcess }) { - const { detail: { modLoader, stable, name, versionId }, status } = proc; - const [, nav] = useLocation(); +const GameInfoCardMemo = React.memo(GameInfoCard); - function revealProc() { - nav(`/monitor/${proc.id}`); - } +function GameInfoCard({ name, modLoader, stable, status }: GameInfoCardProps) { + const { t } = useTranslation("pages", { keyPrefix: "monitor.status" }); - return + return -
-
+
+
+
{name}
+
{t(status)}
+
+ + ; +} -
-
{name}
-
{versionId}
-
+interface MonitorActionsProps { + procId: string; + gameId: string; + status: RemoteGameStatus; +} -
- -
+const MonitorActionsMemo = React.memo(MonitorActions); + +function MonitorActions({ procId, gameId, status }: MonitorActionsProps) { + const { t } = useTranslation("pages", { keyPrefix: "monitor.actions" }); + const [stopConfirmOpen, setStopConfirmOpen] = useState(false); + const [, nav] = useLocation(); + + function handleStopAction() { + native.launcher.stop(procId); + setStopConfirmOpen(false); + } -
- + + + + + + +
+
+ +
{t("stop-title")}
+
+ +
{t("stop-sub")}
+
-
- - ; + + +
; } diff --git a/src/renderer/services/game.ts b/src/renderer/services/game.ts new file mode 100644 index 00000000..01c42c20 --- /dev/null +++ b/src/renderer/services/game.ts @@ -0,0 +1,12 @@ +import type { GameProfile } from "@/main/game/spec"; +import { useEffect, useState } from "react"; + +export function useGameList(): GameProfile[] { + const [games, setGames] = useState([]); + + useEffect(() => { + native.game.list().then(setGames); + }, []); + + return games; +} diff --git a/src/renderer/lib/remote-game.ts b/src/renderer/services/proc.ts similarity index 99% rename from src/renderer/lib/remote-game.ts rename to src/renderer/services/proc.ts index 7d2899fa..8ac864b5 100644 --- a/src/renderer/lib/remote-game.ts +++ b/src/renderer/services/proc.ts @@ -196,6 +196,6 @@ export function useGameProcList(): RemoteGameProcess[] { return procs; } -export const remoteGame = { +export const procService = { create }; diff --git a/src/renderer/services/sources.ts b/src/renderer/services/sources.ts new file mode 100644 index 00000000..f6a55903 --- /dev/null +++ b/src/renderer/services/sources.ts @@ -0,0 +1,12 @@ +import type { VersionManifest } from "@/main/install/vanilla"; +import { useEffect, useState } from "react"; + +export function useVersionManifest(): VersionManifest | null { + const [versionManifest, setVersionManifest] = useState(null); + + useEffect(() => { + native.sources.getVersionManifest().then(setVersionManifest); + }, []); + + return versionManifest; +}