mirror of
https://github.com/shadps4-emu/shadPS4-launcher.git
synced 2026-01-31 00:55:20 +01:00
feat: ipc handling
This commit is contained in:
22
src-tauri/Cargo.lock
generated
22
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
pub(crate) mod command;
|
||||
mod game_process;
|
||||
mod ipc;
|
||||
mod log;
|
||||
pub mod state;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
pub enum GameCommand {}
|
||||
|
||||
impl GameCommand {
|
||||
pub fn gen_send_line(&self) -> String {
|
||||
todo!("no command implemented yet")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
79
src/handlers/game-process.ts
Normal file
79
src/handlers/game-process.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
41
src/lib/nt/timeout.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
} & {};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user