feat: ipc handling

This commit is contained in:
Vinicius Rangel
2025-07-31 11:36:39 -03:00
parent 8613acd561
commit 90086873cb
21 changed files with 421 additions and 159 deletions

22
src-tauri/Cargo.lock generated
View File

@@ -4352,6 +4352,7 @@ dependencies = [
"serde",
"serde_json",
"static_assertions",
"strum",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
@@ -4774,6 +4775,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "subtle"
version = "2.6.1"

View File

@@ -26,6 +26,7 @@ regex = "1.11.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
static_assertions = "1.1.0"
strum = { version = "0.27.2", features = ["derive"] }
tauri = { version = "2", features = ["devtools", "protocol-asset"] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = { version = "2", features = ["watch"] }

View File

@@ -1,6 +1,5 @@
pub(crate) mod command;
mod game_process;
mod ipc;
mod log;
pub mod state;

View File

@@ -41,7 +41,24 @@ pub async fn game_process_kill(state: GameBridgeState<'_>, pid: u32) -> anyhow_t
bail!("pid not found");
};
Ok(proc.kill().await)
proc.kill().await?;
Ok(())
}
#[tauri::command]
pub async fn game_process_send(
state: GameBridgeState<'_>,
pid: u32,
value: &str,
) -> anyhow_tauri::TAResult<()> {
let state = state.lock().await;
let Some(proc) = state.process_list.get(&pid) else {
debug!("process not found: pid={}", pid);
bail!("pid not found");
};
proc.send(value).await?;
Ok(())
}
#[tauri::command]

View File

@@ -1,4 +1,3 @@
use crate::game_process::ipc::GameCommand;
use crate::game_process::log::{Entry, LogData, LogEntry};
use crate::game_process::{log, GameBridgeStateType};
use anyhow::Context;
@@ -21,6 +20,7 @@ pub enum GameEvent<'a> {
AddLogClass { value: &'a str },
GameExit { status: i32 },
IOError { err: String },
IpcLine { value: &'a str },
}
enum InnerCommand {
@@ -33,8 +33,7 @@ pub struct GameProcess {
#[allow(dead_code)]
data: ProcessData,
#[allow(dead_code)]
sender: Arc<Mutex<Sender<GameCommand>>>, // These are commands sent to the emulator
sender: Arc<Mutex<Sender<String>>>, // These are commands sent to the emulator
inner_sender: Arc<Mutex<Sender<InnerCommand>>>, // These are commands sent to the launcher
}
@@ -60,6 +59,7 @@ impl GameProcess {
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("SHADPS4_ENABLE_IPC", "true")
.spawn()?;
let pid = c.id().expect("failed to get process id");
@@ -96,12 +96,22 @@ impl GameProcess {
&self.data
}
pub async fn kill(&self) {
pub async fn kill(&self) -> anyhow::Result<()> {
let inner_sender = self.inner_sender.lock().await;
inner_sender
.send(InnerCommand::Kill)
.await
.expect("receiver is closed");
.context("receiver is closed")?;
Ok(())
}
pub async fn send(&self, value: &str) -> anyhow::Result<()> {
let sender = self.sender.lock().await;
sender
.send(value.to_string())
.await
.context("failed to send command to the game process")?;
Ok(())
}
async fn handle_events(
@@ -109,8 +119,8 @@ impl GameProcess {
app_handle: AppHandle,
callback: impl Fn(GameEvent) + Send + 'static,
data: ProcessData,
) -> (Sender<GameCommand>, Sender<InnerCommand>) {
let (tx, mut rx) = channel::<GameCommand>(1);
) -> (Sender<String>, Sender<InnerCommand>) {
let (tx, mut rx) = channel::<String>(1);
let (inner_tx, mut inner_rx) = channel::<InnerCommand>(1);
// let pid = c.id().expect("failed to get process id");
@@ -166,6 +176,12 @@ impl GameProcess {
}
Ok(None) => break,
Ok(Some(line)) => {
if line.starts_with(';') {
callback(GameEvent::IpcLine {
value: &line[1..],
});
continue;
}
let mut log_data = data.log_data.lock().await;
let entry = Entry {
time: OffsetDateTime::now_utc(),
@@ -185,8 +201,13 @@ impl GameProcess {
break;
}
Some(cmd) = rx.recv() => {
let line = cmd.gen_send_line();
let r = stdin.write_all(line.as_bytes()).await.context("failed to write to stdin");
let r = stdin.write_all(cmd.as_bytes()).await.context("failed to write to stdin");
if let Err(err) = r {
io_err = Some(err);
break;
}
let _ = stdin.write_u8(b'\n').await;
let r = stdin.flush().await.context("failed to flush stdin");
if let Err(err) = r {
io_err = Some(err);
break;

View File

@@ -1,7 +0,0 @@
pub enum GameCommand {}
impl GameCommand {
pub fn gen_send_line(&self) -> String {
todo!("no command implemented yet")
}
}

View File

@@ -6,6 +6,7 @@ pub fn all_handlers() -> Box<dyn Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Se
game_process::command::game_process_delete,
game_process::command::game_process_get_log,
game_process::command::game_process_kill,
game_process::command::game_process_send,
game_process::command::game_process_spawn,
utility_commands::extract_zip,
utility_commands::make_it_executable,

View File

@@ -21,7 +21,7 @@ import {
} from "react";
import { useThemeStyle } from "@/lib/hooks/useThemeStyle";
import { type LogEntry, LogLevel } from "@/lib/native/game-process";
import type { RunningGame } from "@/store/running-games";
import type { GameProcessState } from "@/store/running-games";
const theme = {
bgHeader: "#ffffff",
@@ -134,7 +134,7 @@ const baseColumns: GridColumn[] = [
];
type Props = {
runningGame: RunningGame;
runningGame: GameProcessState;
levelFilter?: LogLevel[] | undefined;
classFilter?: string[] | undefined;
};

View File

@@ -22,7 +22,7 @@ import { capitalize } from "@/lib/utils/strings";
import { cn } from "@/lib/utils/ui";
import {
atomShowingRunningGame,
type RunningGame,
type GameProcessState,
removeRunningGame,
} from "@/store/running-games";
import { LogList } from "../log-list";
@@ -62,7 +62,7 @@ import { Skeleton } from "../ui/skeleton";
export function RunningGameDialog({
runningGame,
}: {
runningGame: RunningGame;
runningGame: GameProcessState;
}) {
const setShowingGame = useSetAtom(atomShowingRunningGame);

View File

@@ -5,14 +5,14 @@ import { cn } from "@/lib/utils/ui";
import {
atomRunningGames,
atomShowingRunningGame,
type RunningGame,
type GameProcessState,
} from "@/store/running-games";
function SingleGameIcon({
runningGame,
className,
...props
}: ComponentProps<"div"> & { runningGame: RunningGame }) {
}: ComponentProps<"div"> & { runningGame: GameProcessState }) {
const { game } = runningGame;
const setShowingRunningGame = useSetAtom(atomShowingRunningGame);
const state = useAtomValue(runningGame.atomRunning);

View File

@@ -0,0 +1,79 @@
import type { ResultAsync } from "neverthrow";
import { makeDeferred } from "@/lib/utils/events";
import { defaultStore, type JotaiStore } from "@/store";
import type { Capabilities, GameProcessState } from "@/store/running-games";
export function handleGameProcess(
state: GameProcessState,
store: JotaiStore = defaultStore,
): {
onEmuRun: ResultAsync<void, never>;
} {
const emuRunEvent = makeDeferred();
const { process } = state;
const addCapability = (capability: Capabilities) => {
store.set(state.atomCapabilities, (prev) => [...prev, capability]);
};
let isReadingCapabilities = false;
let isFirstLine = true;
const onIpc = (line: string) => {
if (isFirstLine) {
isFirstLine = false;
state.hasIpc = true;
}
if (line === "#IPC_ENABLED") {
isReadingCapabilities = true;
return;
}
if (isReadingCapabilities) {
if (line === "#IPC_END") {
isReadingCapabilities = false;
process.send("RUN");
emuRunEvent.resolve();
return;
}
addCapability(line as Capabilities);
return;
}
};
process.onMessage = (ev) => {
switch (ev.event) {
case "log":
for (const c of store.get(state.log.atomCallback)) {
c(ev);
}
break;
case "addLogClass":
if (isFirstLine) {
isFirstLine = false;
emuRunEvent.resolve();
}
store.set(state.log.atomClassList, (prev) => [
...prev,
ev.value,
]);
break;
case "gameExit":
store.set(state.atomRunning, ev.status);
break;
case "iOError":
store.set(state.atomError, ev.err);
break;
case "ipcLine":
onIpc(ev.value);
break;
default: {
// exaustive switch
const a: never = ev;
return a;
}
}
};
return {
onEmuRun: emuRunEvent.result,
};
}

View File

@@ -1,8 +1,10 @@
import { dirname, join } from "@tauri-apps/api/path";
import { exists, mkdir } from "@tauri-apps/plugin-fs";
import { ok, safeTry } from "neverthrow";
import { toast } from "sonner";
import { GameProcess } from "@/lib/native/game-process";
import { stringifyError } from "@/lib/utils/error";
import { withTimeout } from "@/lib/nt/timeout";
import { errWarning, stringifyError, WarningError } from "@/lib/utils/error";
import type { JotaiStore } from "@/store";
import {
atomAvailablePatches,
@@ -10,71 +12,102 @@ import {
} from "@/store/cheats-and-patches";
import type { GameEntry } from "@/store/db";
import { atomEmuUserPath, atomPatchPath } from "@/store/paths";
import { addRunningGame, type RunningGame } from "@/store/running-games";
import {
createGameProcesState,
type GameProcessState,
removeRunningGame,
} from "@/store/running-games";
import { atomSelectedVersion } from "@/store/version-manager";
import { handleGameProcess } from "./game-process";
export async function startGame(
store: JotaiStore,
game: GameEntry,
): Promise<RunningGame | null> {
const emu = store.get(atomSelectedVersion);
if (!emu) {
toast.warning("No emulator selected");
return null;
}
const userBaseDir = store.get(atomEmuUserPath);
const gameDir = game.path;
const gameBinary = await join(gameDir, "eboot.bin");
if (!(await exists(gameBinary))) {
const msg = "Game binary (eboot.bin) not found";
toast.error(msg);
console.warn(msg);
return null;
}
if (!(await exists(emu.path))) {
const msg = "Emulator binary not found";
toast.error(msg);
console.warn(msg);
return null;
}
const workDir =
typeof userBaseDir === "string" ? userBaseDir : await dirname(emu.path);
const userDir = await join(workDir, "user");
if (!(await exists(userDir))) {
await mkdir(userDir, { recursive: true });
}
let patchFile: string | undefined;
const enabledRepo = store.get(atomPatchRepoEnabledByGame)[game.cusa];
if (enabledRepo) {
const availablePatches = store.get(atomAvailablePatches);
patchFile = availablePatches[enabledRepo]?.[game.cusa];
if (patchFile) {
const patchFolder = await store.get(atomPatchPath);
patchFile = await join(patchFolder, enabledRepo, patchFile);
): Promise<GameProcessState | null> {
const result = await safeTry(async function* () {
const emu = store.get(atomSelectedVersion);
if (!emu) {
return errWarning("No emulator selected");
}
}
const args = [gameBinary];
const userBaseDir = store.get(atomEmuUserPath);
if (patchFile) {
args.push("-p", patchFile);
}
const gameDir = game.path;
const gameBinary = await join(gameDir, "eboot.bin");
if (!(await exists(gameBinary))) {
return errWarning("Game binary (eboot.bin) not found");
}
try {
const process = await GameProcess.startGame(emu.path, workDir, args);
const r = addRunningGame(game, process);
toast.success("Game started");
return r;
} catch (e) {
const msg = `Couldn't start the game: ${stringifyError(e)}`;
console.error(msg);
toast.error(msg);
if (!(await exists(emu.path))) {
return errWarning("Emulator binary not found");
}
const workDir =
typeof userBaseDir === "string"
? userBaseDir
: await dirname(emu.path);
const userDir = await join(workDir, "user");
if (!(await exists(userDir))) {
await mkdir(userDir, { recursive: true });
}
let patchFile: string | undefined;
const enabledRepo = store.get(atomPatchRepoEnabledByGame)[game.cusa];
if (enabledRepo) {
const availablePatches = store.get(atomAvailablePatches);
patchFile = availablePatches[enabledRepo]?.[game.cusa];
if (patchFile) {
const patchFolder = await store.get(atomPatchPath);
patchFile = await join(patchFolder, enabledRepo, patchFile);
}
}
const args = [];
if (patchFile) {
args.push("-p", patchFile);
}
args.push(gameBinary);
const process = yield* await GameProcess.startGame(
emu.path,
workDir,
args,
);
const state = createGameProcesState(game, process, store);
const { onEmuRun } = handleGameProcess(state);
// FIXME Remove this 1e6
yield* await withTimeout(onEmuRun, 5000 * 1e6).orTee(() => {
removeRunningGame(state);
});
if (state.hasIpc) {
// TODO Send Memory Patches here
process.send("START");
}
toast.info("Game started");
return ok(state);
});
if (result.isErr()) {
const err = result.error;
if (err instanceof WarningError) {
toast.warning(err.message);
console.warn(err.message);
} else {
const msg = `Couldn't start the game: ${stringifyError(err)}`;
toast.error(msg);
console.error("Couldn't start the game:", err);
}
return null;
}
return result.value;
}

View File

@@ -1,4 +1,23 @@
import { Channel, invoke } from "@tauri-apps/api/core";
import { ResultAsync } from "neverthrow";
export class GameStartError extends Error {
constructor(
exe: string,
workingDir: string,
args: string[],
cause?: unknown,
) {
super(
`Failed to start game process: ${exe} in ${workingDir} with args: ${args.join(
" ",
)}`,
);
this.name = "GameStartError";
this.cause = cause;
Object.setPrototypeOf(this, GameStartError.prototype);
}
}
export enum LogLevel {
UNKNOWN = "unknown",
@@ -22,7 +41,8 @@ export type GameEvent =
| ({ event: "log" } & LogEntry)
| { event: "addLogClass"; value: string }
| { event: "gameExit"; status: number }
| { event: "iOError"; err: string };
| { event: "iOError"; err: string }
| { event: "ipcLine"; value: string };
export class GameProcess {
#pid: number;
@@ -33,19 +53,24 @@ export class GameProcess {
this.#ch = ch;
}
static async startGame(
static startGame(
exe: string,
workingDir: string,
args: string[],
): Promise<GameProcess> {
const ch = new Channel<GameEvent>();
const pid = await invoke<number>("game_process_spawn", {
exe,
wd: workingDir,
args,
onEvent: ch,
});
return new GameProcess(pid, ch);
): ResultAsync<GameProcess, GameStartError> {
return ResultAsync.fromPromise(
(async () => {
const ch = new Channel<GameEvent>();
const pid = await invoke<number>("game_process_spawn", {
exe,
wd: workingDir,
args,
onEvent: ch,
});
return new GameProcess(pid, ch);
})(),
(err) => new GameStartError(exe, workingDir, args, err),
);
}
get pid() {
@@ -64,6 +89,10 @@ export class GameProcess {
await invoke("game_process_delete", { pid: this.#pid });
}
async send(value: string) {
await invoke("game_process_send", { pid: this.pid, value });
}
async getLog({
level,
logClass,
@@ -79,4 +108,29 @@ export class GameProcess {
}),
);
}
send_patch_memory(
modName: string,
offset: string,
value: string,
target = "",
size = "",
isOffset = true,
littleEndian = false,
patchMask = 0,
patchSize = 0,
) {
return this.send(
"PATCH_MEMORY\n" +
`${modName}\n` +
`${offset}\n` +
`${value}\n` +
`${target}\n` +
`${size}\n` +
`${+isOffset}\n` +
`${+littleEndian}\n` +
`${patchMask}\n` +
`${patchSize}\n`,
);
}
}

View File

@@ -6,6 +6,7 @@ export class FetchError extends Error {
public readonly status?: number,
) {
super(message);
this.name = "FetchError";
Object.setPrototypeOf(this, FetchError.prototype);
}
}

41
src/lib/nt/timeout.ts Normal file
View File

@@ -0,0 +1,41 @@
import { ResultAsync } from "neverthrow";
export class TimeoutError extends Error {
constructor(timeout: number) {
super(`Operation timed out after ${timeout}ms`);
this.name = "TimeoutError";
Object.setPrototypeOf(this, TimeoutError.prototype);
}
}
/**
* Races `ra` against a timeout of `ms` milliseconds.
* - If `ra` yields `Ok`, you get that value.
* - If `ra` yields `Err`, you get its error.
* - If the timer fires first, you get `Err(TimeoutError)`.
*/
export function withTimeout<T, E extends Error>(
ra: ResultAsync<T, E>,
ms: number,
): ResultAsync<T, E | TimeoutError> {
const originalPromise: PromiseLike<T> = ra.then((result) => {
if (result.isOk()) {
return result.value;
}
throw result.error;
});
// A promise that rejects with TimeoutError after `ms`
const timeoutPromise = new Promise<T>((_, rej) =>
setTimeout(() => rej(new TimeoutError(ms)), ms),
);
const raced = Promise.race([originalPromise, timeoutPromise]);
// Wrap back into a ResultAsync, unioning the error types
return ResultAsync.fromPromise<T, E | TimeoutError>(
raced,
(err: unknown) =>
err instanceof Error
? (err as E | TimeoutError)
: new TimeoutError(ms),
);
}

View File

@@ -1,3 +1,5 @@
import { type Err, err } from "neverthrow";
export function stringifyError(e: unknown): string {
if (typeof e === "string") {
return e;
@@ -14,3 +16,16 @@ export function stringifyError(e: unknown): string {
return JSON.stringify(e);
}
// This is for Warning propagation instead of error
export class WarningError extends Error {
constructor(message: string) {
super(message);
this.name = "WarningError";
Object.setPrototypeOf(this, WarningError.prototype);
}
}
export function errWarning(message: string): Err<never, WarningError> {
return err(new WarningError(message));
}

View File

@@ -1,3 +1,5 @@
import { ResultAsync } from "neverthrow";
export const createAbort = () => {
const controller = new AbortController();
return {
@@ -5,3 +7,24 @@ export const createAbort = () => {
abort: () => controller.abort(),
};
};
export function makeDeferred<
T = void,
E = never,
CB = E extends never ? never : (reason: E) => void,
>(): {
resolve: (value: T | PromiseLike<T>) => void;
reject: CB;
result: ResultAsync<T, E>;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: CB;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej as CB;
});
const result = ResultAsync.fromPromise(promise, (err) => err as E);
return { resolve, reject, result };
}

View File

@@ -1,26 +1,3 @@
import { ResultAsync } from "neverthrow";
export function withTimeout<T, E>(
action: ResultAsync<T, E>,
ms: number,
timeoutError: E,
): ResultAsync<T, E> {
const timedPromise = new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(timeoutError), ms);
action.then((r) => {
clearTimeout(timer);
if (r.isOk()) {
resolve(r.value);
} else {
reject(r.error);
}
});
});
return ResultAsync.fromPromise(timedPromise, (err) => err as E);
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -7,3 +7,7 @@ export type Tuple<
export type Callback<P extends unknown[] = [], R = void> = (...args: P) => R;
export type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};

View File

@@ -1,79 +1,61 @@
import { type Atom, atom, type PrimitiveAtom } from "jotai";
import { atom, type PrimitiveAtom } from "jotai";
import type { GameProcess, LogEntry } from "@/lib/native/game-process";
import type { Callback } from "@/lib/utils/types";
import { defaultStore } from ".";
import { defaultStore, type JotaiStore } from ".";
import type { GameEntry } from "./db";
export type RunningGame = {
export type Capabilities = "ENABLE_MEMORY_PATCH";
export type GameProcessState = {
game: GameEntry;
process: GameProcess;
atomRunning: Atom<true | number>; // true or exit code
atomError: Atom<string | null>;
hasIpc: boolean;
atomRunning: PrimitiveAtom<true | number>; // true or exit code
atomError: PrimitiveAtom<string | null>;
log: {
atomCallback: PrimitiveAtom<Callback<[LogEntry]>[]>;
atomClassList: Atom<string[]>;
atomClassList: PrimitiveAtom<string[]>;
};
atomCapabilities: PrimitiveAtom<Capabilities[]>;
};
export const atomRunningGames = atom<RunningGame[]>([]);
export const atomShowingRunningGame = atom<RunningGame | null>(null);
export const atomRunningGames = atom<GameProcessState[]>([]);
export const atomShowingRunningGame = atom<GameProcessState | null>(null);
export function addRunningGame(
export function createGameProcesState(
game: GameEntry,
process: GameProcess,
): RunningGame {
const store = defaultStore;
store: JotaiStore = defaultStore,
): GameProcessState {
const atomRunning = atom<true | number>(true);
const atomError = atom<string | null>(null);
const atomLogCallback = atom<Callback<[LogEntry]>[]>([]);
const atomLogClassList = atom<string[]>(["STDERR"]);
const atomCapabilities = atom<Capabilities[]>([]);
const runningGame = {
game: game,
process: process,
hasIpc: false,
atomRunning,
atomError,
log: {
atomCallback: atomLogCallback,
atomClassList: atomLogClassList,
},
} satisfies RunningGame;
atomCapabilities,
} satisfies GameProcessState;
store.set(atomRunningGames, (prev) => [...prev, runningGame]);
process.onMessage = (ev) => {
switch (ev.event) {
case "log":
for (const c of store.get(atomLogCallback)) {
c(ev);
}
break;
case "addLogClass":
store.set(atomLogClassList, (prev) => [...prev, ev.value]);
break;
case "gameExit":
store.set(atomRunning, ev.status);
break;
case "iOError":
store.set(atomError, ev.err);
break;
default: {
// exaustive switch
const a: never = ev;
return a;
}
}
};
return runningGame;
}
export function removeRunningGame(runningGame: RunningGame) {
runningGame.process.delete();
delete (runningGame as Partial<RunningGame>).log;
delete (runningGame as Partial<RunningGame>).process;
export function removeRunningGame(state: GameProcessState) {
state.process.delete();
delete (state as Partial<GameProcessState>).log;
delete (state as Partial<GameProcessState>).process;
defaultStore.set(atomRunningGames, (prev) =>
prev.filter((e) => e !== runningGame),
prev.filter((e) => e !== state),
);
}

View File

@@ -4,8 +4,8 @@ import { unwrap } from "jotai/utils";
import { atomWithQuery } from "jotai-tanstack-query";
import { ResultAsync } from "neverthrow";
import { Octokit } from "octokit";
import { withTimeout } from "@/lib/nt/timeout";
import { stringifyError } from "@/lib/utils/error";
import { withTimeout } from "@/lib/utils/flow";
import { atomWithTauriStore } from "@/lib/utils/jotai/tauri-store";
import { oficialRepo } from "./common";
@@ -114,7 +114,6 @@ export const atomAvailableVersions = atomWithQuery((get) => ({
),
),
10000,
new Error("GitHub Timeout"),
);
if (result.isErr() || result.value.status !== 200) {
throw new Error(