From 9fdd92a28392f0bd4ae9ff4b579b278a9be17360 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:49:26 +0000 Subject: [PATCH 1/2] Initial plan From cece78965cfb29def25c9d3efd818d1a3a766baa Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:53:20 +0000 Subject: [PATCH 2/2] Add macOS .app bundle launch support - Update folder selection dialog to allow selecting .app bundles on macOS - Update binary verifier to check inside .app/Contents/MacOS/ for executables - Update launcher to support macOS platform with correct working directory - Note: BepInEx injection for macOS not yet implemented (vanilla launch only) Co-authored-by: danielchim <12156547+danielchim@users.noreply.github.com> --- electron/launch/binary-verifier.ts | 53 ++++++++++++++++++++++++--- electron/launch/launcher.ts | 57 ++++++++++++++++++------------ electron/main.ts | 13 +++++-- electron/trpc/router.ts | 10 +++++- 4 files changed, 102 insertions(+), 31 deletions(-) diff --git a/electron/launch/binary-verifier.ts b/electron/launch/binary-verifier.ts index d191d16..dcc9ffe 100644 --- a/electron/launch/binary-verifier.ts +++ b/electron/launch/binary-verifier.ts @@ -18,6 +18,7 @@ export interface VerifyBinaryResult { /** * Verifies that a game binary exists in the install folder * Checks all possible exe names and returns the first match + * On macOS, handles .app bundles by checking inside Contents/MacOS/ */ export async function verifyBinary( installFolder: string, @@ -29,7 +30,7 @@ export async function verifyBinary( reason: "Install folder not set", } } - + // Check if install folder exists if (!(await pathExists(installFolder))) { return { @@ -37,11 +38,12 @@ export async function verifyBinary( reason: "Install folder does not exist", } } - + // Check each exe name for (const exeName of exeNames) { + // First check at root level (Windows, Linux) const exePath = join(installFolder, exeName) - + if (await pathExists(exePath)) { // Verify it's a file try { @@ -57,8 +59,51 @@ export async function verifyBinary( continue } } + + // On macOS, if exeName is a .app bundle, check inside Contents/MacOS/ + if (process.platform === "darwin" && exeName.endsWith(".app")) { + // The .app bundle might be the install folder itself + if (installFolder.endsWith(".app")) { + // Extract the executable name from the .app name + // e.g., "Game.app" -> "Game" + const appBaseName = exeName.replace(/\.app$/, "") + const macosPath = join(installFolder, "Contents", "MacOS", appBaseName) + + if (await pathExists(macosPath)) { + try { + const stat = await fs.stat(macosPath) + if (stat.isFile()) { + return { + ok: true, + exePath: macosPath, + } + } + } catch (error) { + // Continue to next candidate + continue + } + } + + // Also try the full .app name as executable + const macosPathWithApp = join(installFolder, "Contents", "MacOS", exeName.replace(/\.app$/, "")) + if (await pathExists(macosPathWithApp)) { + try { + const stat = await fs.stat(macosPathWithApp) + if (stat.isFile()) { + return { + ok: true, + exePath: macosPathWithApp, + } + } + } catch (error) { + // Continue to next candidate + continue + } + } + } + } } - + return { ok: false, reason: `Game executable not found. Expected one of: ${exeNames.join(", ")}`, diff --git a/electron/launch/launcher.ts b/electron/launch/launcher.ts index b52a35b..9ed320a 100644 --- a/electron/launch/launcher.ts +++ b/electron/launch/launcher.ts @@ -296,15 +296,15 @@ async function injectLoaderFiles( */ export async function launchGame(options: LaunchOptions): Promise { const { gameId, profileId, mode, installFolder, exePath, profileRoot, packageIndexUrl, modloaderPackage } = options - + const logger = getLogger() logger.info(`Launching game ${gameId} in ${mode} mode`, { profileId, exePath }) - + try { - // Step 1: Ensure BepInEx pack is available (Windows only) + // Step 1: Ensure BepInEx pack is available (Windows only for now) if (process.platform === "win32") { const bepInExResult = await ensureBepInExPack(gameId, packageIndexUrl, modloaderPackage) - + if (!bepInExResult.available) { logger.error(`BepInEx preparation failed for ${gameId}: ${bepInExResult.error}`) return { @@ -312,39 +312,50 @@ export async function launchGame(options: LaunchOptions): Promise error: bepInExResult.error || "Failed to prepare BepInEx", } } - + logger.debug(`BepInEx pack ready at ${bepInExResult.bootstrapRoot}`) - + // Step 2: Copy BepInEx to profile root (idempotent) await copyBepInExToProfile(bepInExResult.bootstrapRoot!, profileRoot) - + // Step 3: Inject loader files into game install folder await injectLoaderFiles(gameId, installFolder, profileRoot, mode) + } else if (process.platform === "darwin") { + // macOS support - BepInEx injection for macOS .app bundles + // TODO: Implement BepInEx injection for macOS if needed + logger.info("Launching on macOS (BepInEx injection not yet implemented)") } else { - logger.warn("Launch attempted on non-Windows platform") + logger.warn(`Launch attempted on unsupported platform: ${process.platform}`) return { success: false, - error: "Launch is only supported on Windows", + error: `Launch is only supported on Windows and macOS, not ${process.platform}`, } } - + // Step 4: Build launch arguments const args = await buildLaunchArgs(options.launchParameters) - - logger.debug(`Spawning game process: ${exePath}`, { args }) - - // Step 5: Spawn the game process + + // Step 5: Determine correct working directory + // For macOS .app bundles, cwd should be the parent directory, not the .app itself + let cwd = installFolder + if (process.platform === "darwin" && installFolder.endsWith(".app")) { + cwd = join(installFolder, "..") + } + + logger.debug(`Spawning game process: ${exePath}`, { args, cwd }) + + // Step 6: Spawn the game process const child = spawn(exePath, args, { - cwd: installFolder, + cwd, detached: true, stdio: "ignore", }) - + // Unref so parent doesn't wait child.unref() - + const pid = child.pid - + if (!pid) { logger.error("Failed to get process ID after spawn") return { @@ -352,12 +363,12 @@ export async function launchGame(options: LaunchOptions): Promise error: "Failed to get process ID", } } - - // Step 6: Track the process + + // Step 7: Track the process trackProcess(gameId, pid) - + logger.info(`Game launched successfully: ${gameId}`, { pid }) - + return { success: true, pid, @@ -365,7 +376,7 @@ export async function launchGame(options: LaunchOptions): Promise } catch (error) { const message = error instanceof Error ? error.message : String(error) logger.error(`Launch failed for ${gameId}: ${message}`, { error }) - + return { success: false, error: `Launch failed: ${message}`, diff --git a/electron/main.ts b/electron/main.ts index 899900c..dbed2af 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -130,14 +130,21 @@ app.whenReady().then(() => { // IPC Handlers for desktop features ipcMain.handle("dialog:selectFolder", async () => { + // On macOS, allow selecting .app bundles in addition to directories + // .app bundles are technically directories but appear as files to users + const properties: ("openDirectory" | "openFile")[] = ["openDirectory"] + if (process.platform === "darwin") { + properties.push("openFile") + } + const result = await dialog.showOpenDialog({ - properties: ["openDirectory"], + properties, }) - + if (result.canceled) { return null } - + return result.filePaths[0] }) diff --git a/electron/trpc/router.ts b/electron/trpc/router.ts index 795798e..08ff889 100644 --- a/electron/trpc/router.ts +++ b/electron/trpc/router.ts @@ -57,10 +57,18 @@ const desktopRouter = t.router({ /** * Open native folder selection dialog * Returns selected folder path or null if cancelled + * On macOS, also allows selecting .app bundles */ selectFolder: publicProcedure.query(async () => { + // On macOS, allow selecting .app bundles in addition to directories + // .app bundles are technically directories but appear as files to users + const properties: ("openDirectory" | "openFile")[] = ["openDirectory"] + if (process.platform === "darwin") { + properties.push("openFile") + } + const result = await dialog.showOpenDialog({ - properties: ["openDirectory"], + properties, }) if (result.canceled) {