Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions electron/launch/binary-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,19 +30,20 @@ export async function verifyBinary(
reason: "Install folder not set",
}
}

// Check if install folder exists
if (!(await pathExists(installFolder))) {
return {
ok: false,
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 {
Expand All @@ -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(", ")}`,
Expand Down
57 changes: 34 additions & 23 deletions electron/launch/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,76 +296,87 @@ async function injectLoaderFiles(
*/
export async function launchGame(options: LaunchOptions): Promise<LaunchResult> {
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 {
success: false,
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 {
success: false,
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,
}
} 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}`,
Expand Down
13 changes: 10 additions & 3 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
})

Expand Down
10 changes: 9 additions & 1 deletion electron/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading