#!/usr/bin/env bun /** * This file is gib: a TUI application which aims to automate installing BepInEx * to a Unity game. * * Currently only macOS is supported, as the process of manual BepInEx * installation is exceptionally cumbersome on this operating system. * * gib aims to automate whatever it can, and hold the user's hand * through whatever it cannot. * * USAGE: * * Full usage instructions can be found in the README. gib itself will try to * guide you through usage, though it also attempts to be concise. Read the * README if you get stuck. * * Recommended command to run gib: * * curl -fsSL https://cdn.jsdelivr.net/gh/toebeann/gib/gib.sh | bash * ****************************************************************************** * * ISC License * * Copyright 2023 Tobey Blaber * * Permission to use, copy, modify and/or distribute this software for any * purpose with or without fee is hereby granted, provided that the above * copyright notice and this permission notice appear in all copies. * * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. * *****************************************************************************/ import { $, color, env, file, Glob, semver, wrapAnsi, write } from "bun"; import { W_OK } from "node:constants"; import { access, appendFile, chmod, mkdir } from "node:fs/promises"; import { EOL, homedir } from "node:os"; import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep, } from "node:path"; import { execPath, exit, kill } from "node:process"; import chalk from "chalk"; import { watch } from "chokidar"; import cliWidth from "cli-width"; import JSZip from "jszip"; import open from "open"; import { pathEqual } from "path-equal"; import { build as buildPlist } from "plist"; import terminalLink from "terminal-link"; import untildify from "untildify"; import { z } from "zod"; import { version } from "../../package.json" with { type: "json" }; import { DoorstopScriptMissingPlatformSupport, InvalidBepInExPack, } from "../bepinex/errors.ts"; import { getBepInExScriptPath } from "../bepinex/script.ts"; import { PathNotAFolderError, PathNotFoundError } from "../fs/errors.ts"; import { getAppsByPath, launch } from "../launchers/steam/app.ts"; import { isInstalled } from "../launchers/steam/launcher.ts"; import { setLaunchOptions } from "../launchers/steam/launchOption.ts"; import { getMostRecentUser } from "../launchers/steam/loginusers.ts"; import { isOpen, quit } from "../launchers/steam/process.ts"; import { addShortcut, getShortcuts, setShortcuts, type Shortcut, updateShortcut, } from "../launchers/steam/shortcut.ts"; import { getUnityAppPath } from "../unity/app.ts"; import { InvalidUnityApp, MultipleUnityAppsFoundError, NotAUnityAppError, } from "../unity/errors.ts"; import { find } from "../utils/process.ts"; import { parsePlistFromFile, type Plist } from "../utils/plist.ts"; import { quote } from "../utils/quote.ts"; import { config } from "./config.ts"; import { errorline, printline } from "./print.ts"; import { alert, confirm, prompt } from "./prompt.ts"; import { renderLogo } from "./renderLogo.ts"; const code = chalk.yellowBright.bold; const orange = chalk.hex(color("orange", "hex")!); const pink = chalk.hex("#ae0956"); const width = () => cliWidth({ defaultWidth: 80 }); const wrap = ( str: string | (string | null | undefined)[], columns = width(), options?: Parameters[2], ) => wrapAnsi(typeof str === "string" ? str : str.join(EOL), columns, options); export interface Options { bepinexScriptPath?: string; unityAppPath?: string; yes?: boolean; } export const run = async (options: Options = {}) => { const { yes } = options; const link = ( label: string, url: string = label, short: string = url, options?: Parameters[2], ) => terminalLink(label, url, { fallback: () => `${label} [ ${short} ]`, ...options, }); const list = (items: string[], ordered: boolean) => { const padding = ordered ? items.length.toString().length + 2 : 3; const indent = ordered ? padding + 2 : 4; let output = ""; for (const [index, item] of items.entries()) { const n = index + 1; output += `${ordered ? (`${n.toString().padStart(padding)}.`) : " •"} ${ wrap(item, width() - indent) .split(EOL).join( `${EOL}${" ".repeat(indent)}`, ) }`; if (n < items.length) { output += EOL.repeat(2); } } return output; }; await renderLogo(); const run_bepinex_sh = code("run_bepinex.sh"); printline(wrap(chalk.bold("gib will:"))); if (width() < 40) printline(); printline( list( [ "install and configure BepInEx for a compatible game", "configure Steam to launch the game with BepInEx", "test that BepInEx is working", ].map((s) => chalk.green(s)), false, ), ); const pressHeartToContinue = (message = "to continue") => { if (!yes) { alert(wrap([null, chalk.yellowBright(`Press enter ${message}`)])); } printline(); }; pressHeartToContinue(); printline( wrap( "If you haven't already, go ahead and download (and unzip) the relevant BepInEx pack for the game.", ), ); const err = `${chalk.red("error")}${chalk.gray(":")}`; const promptUntilValid = async ( message: string, validator: | ((value: string) => string | false) | ((value: string) => Promise), defaultValue?: string, ) => { let value: string | undefined; do { value = (prompt(message, defaultValue))?.trim(); if (!value) { errorline( wrap( `${EOL}${err} No input detected. If you would like to exit gib, press ${ code("Control C") }. Otherwise, please try again.`, ), ); continue; } value = await validator(value) || ""; } while (!value); return value; }; const copyPath = code("Option Command C"); const paste = code("Command V"); const providePathInstructions = list([ "Drag it into this window, or", `Select it and press ${copyPath} to copy its path, then press ${paste} to paste the path here.`, ], false); const bepinexScriptPath = options.bepinexScriptPath || await promptUntilValid( wrap([ null, `In Finder, locate the folder containing the ${run_bepinex_sh} (or similar) script file within your downloaded BepInEx pack, then either:`, null, providePathInstructions, null, `Path to ${run_bepinex_sh} (or similar):`, ]), async (value) => { try { return await getBepInExScriptPath(value); } catch (e) { errorline( wrap([ null, `${err} ${ e instanceof Error ? e.message : "An unknown error was encountered processing input" }:`, chalk.yellowBright( e instanceof Error && "path" in e && typeof e.path === "string" ? e.path : value, ), ...e instanceof Error && e.cause instanceof Error ? [null, chalk.dim(`${e.cause.name}: ${e.cause.message}`)] : e instanceof Error && e.cause ? [null, chalk.dim(`${e.cause}`)] : [], ...e instanceof PathNotFoundError ? [ chalk.bold( `Please try using ${copyPath} to copy the ${run_bepinex_sh} path from Finder, then ${paste} to paste it here.`, ), ] : e instanceof DoorstopScriptMissingPlatformSupport ? [ chalk.bold( "Try downloading a macOS build of BepInEx 5 from https://github.com/BepInEx/BepInEx/releases/latest and installing it with gib.", ), ] : e instanceof InvalidBepInExPack ? [ chalk.bold( `Please make sure you are selecting the ${run_bepinex_sh} script and try again.`, ), ] : [], ]), ); return false; } }, ); let bepinexScriptName = basename(bepinexScriptPath); const bepinexFolderPath = dirname(bepinexScriptPath); printline( wrap([ null, "macOS BepInEx pack successfully detected at:", chalk.green.bold(bepinexFolderPath), null, "Next, we need to know the location of the Unity game.", ]), ); const gameAppPath = options.unityAppPath || await promptUntilValid( wrap([ null, `Open a Finder window at the game's location, (e.g. by clicking ${ code("Manage -> Browse local files") } in Steam), find the game's app (e.g. ${code("Subnautica.app")}) or ${ code("Contents") } folder and do the same thing as last time - either:`, null, providePathInstructions, null, "Path to Unity game app:", ]), async (value) => { try { return await getUnityAppPath(value); } catch (e) { errorline( wrap([ null, `${err} ${ e instanceof Error ? e.message : "An unknown error was encountered processing input" }:`, e instanceof MultipleUnityAppsFoundError ? list(e.apps.map((app) => chalk.yellowBright(app)), false) : chalk.yellowBright( e instanceof Error && "path" in e && typeof e.path === "string" ? e.path : value, ), ...e instanceof Error && e.cause instanceof Error ? [null, chalk.dim(`${e.cause.name}: ${e.cause.message}`)] : e instanceof Error && e.cause ? [null, chalk.dim(`${e.cause}`)] : [], ...e instanceof PathNotFoundError ? [ chalk.bold( `Please try using ${copyPath} to copy the ${run_bepinex_sh} path from Finder, then ${paste} to paste it here.`, ), ] : e instanceof InvalidUnityApp || e instanceof PathNotAFolderError ? [ chalk.bold( "Please make sure you are selecting a Unity app and try again.", ), ] : e instanceof MultipleUnityAppsFoundError ? [ chalk.bold( "Please specify which Unity app you would like to target by providing its path.", ), ] : e instanceof NotAUnityAppError ? [ chalk.bold( "BepInEx only works for Unity games. Please make sure you are selecting a Unity app and try again.", ), ] : [], ]), ); return false; } }, ); const gamePath = extname(gameAppPath) === ".app" ? dirname(gameAppPath) : join(gameAppPath, "..", "..", ".."); printline( wrap([ null, "Unity app successfully detected at:", chalk.green.bold(gamePath), null, ]), ); const configureBepInExScript = async ( path: string, executablePath: string, ) => { const bepinexScriptContents = await file(path).text(); let output = bepinexScriptContents; // fix CRLF line endings if needed output = output.replaceAll("\r\n", "\n"); // configure run_bepinex.sh output = output.replace( '\nexecutable_name=""', `\nexecutable_name="${relative(dirname(path), executablePath)}"`, ); // workaround for issue with BepInEx v5.4.23 run_bepinex.sh script not working // for some games unless ran from the game folder for some reason const basedirIndex = output.indexOf("BASEDIR="); if (basedirIndex !== -1 && !output.includes('cd "$BASEDIR"')) { const insertIndex = output.indexOf("\n", basedirIndex); output = `${ output.slice(0, insertIndex) }\ncd "$BASEDIR" # GIB: workaround for some games only working if script is run from game dir${ output.slice(insertIndex) }`; } // workaround for issue with doorstop 4 where specifying the direct path to // the executable only works for games correctly packaged in a .app folder output = output.replace( String .raw`if ! echo "$real_executable_name" | grep "^.*\.app/Contents/MacOS/.*";`, String .raw`if ! echo "$real_executable_name" | grep "^.*/Contents/MacOS/.*";`, ); // workaround to ensure the game's Content folder is packaged in an .app folder if (!output.includes("mkdir -p")) { const lines = output.split("\n"); const runExecutablePathIndex = lines.findLastIndex((line) => line.includes("executable_path") ); const codesignIndex = lines.findLastIndex( (line) => line.includes("codesign"), runExecutablePathIndex, ); const emptyLineIndex = lines.findLastIndex((line, i, lines) => i > 0 && i < (codesignIndex > -1 ? codesignIndex : runExecutablePathIndex) && !/\S/.test(line) && !/^\s/.test(lines[i - 1]) ); output = lines.toSpliced( emptyLineIndex, 0, "", "# gib: workaround to ensure game content is packaged in an .app folder", 'app_path="${executable_path%/Contents/MacOS*}"', 'if [[ $(basename "$app_path") != *.app ]]; then', ' real_executable_name=$(basename "$executable_path")', ' executable_path="${app_path}/${real_executable_name}.app/Contents/MacOS/${real_executable_name}"', ' target_path="${app_path}/${real_executable_name}.app/Contents"', ' mkdir -p "$target_path"', ' cp -ca "${app_path}/Contents/" "${target_path}/"', "fi", ).join("\n"); } // workaround for issue with codesigned apps preventing doorstop injection if (!output.includes("codesign --remove-signature")) { const lines = output.split("\n"); const runExecutablePathIndex = lines.findLastIndex((line) => line.includes("executable_path") ); const emptyLineIndex = lines.findLastIndex((line, i, lines) => i > 0 && i < runExecutablePathIndex && !/\S/.test(line) && !/^\s/.test(lines[i - 1]) ); output = lines .toSpliced( emptyLineIndex, 0, "", "# gib: workaround to ensure game is not codesigned so that doorstop can inject BepInEx", 'app_path="${executable_path%/Contents/MacOS*}"', 'if command -v codesign &>/dev/null && codesign -d "$app_path"; then', ' codesign --remove-signature "$app_path"', "fi", ).join("\n"); } // write the changes, if any if (output !== bepinexScriptContents) { await write(path, output); } await chmod(path, 0o764); }; const installBepInEx = async () => { const i = bepinexFolderPath.split(sep).length; const glob = new Glob("**/*"); for await ( const origin of glob.scan({ absolute: true, dot: true, onlyFiles: true, cwd: bepinexFolderPath, }) ) { if (basename(origin) === ".DS_Store") continue; const destination = join(gamePath, origin.split(sep).slice(i).join(sep)); if (!pathEqual(origin, destination)) { await write(destination, file(origin)); } if (pathEqual(origin, bepinexScriptPath)) { await configureBepInExScript(destination, gameAppPath); } } await $`xattr -rd com.apple.quarantine ${gamePath}`.nothrow().quiet(); }; const [steamApps, plist, switchSupported] = await Promise.all([ Array.fromAsync(getAppsByPath(gamePath)), parsePlistFromFile( join( extname(gameAppPath) === ".app" ? gameAppPath : gamePath, "Contents", "Info.plist", ), ), (async () => { if (!await file(join(bepinexFolderPath, "libdoorstop.dylib")).exists()) { return false; } try { return (await file(bepinexScriptPath).text()) .includes("--doorstop_enabled)"); } catch { return false; } })(), ]); const operations: Promise[] = []; const shortcutPath = join( homedir(), "Applications", `${ extname(gameAppPath) === ".app" ? parse(gameAppPath).name : plist.CFBundleName || parse(plist.CFBundleExecutable).name } (${steamApps.length === 0 ? "BepInEx" : "Vanilla"}).app`, ); let shouldAddShortcut: boolean; if (steamApps.length === 1) { /** * - prompt user to let them know that we will configure steam to launch the * game, and if steam is open we will quit it, confirm y/N, N = quit * * - also prompt to ask if user wants us to set up a shortcut to launch the * game without mods, let them know this is experimental and will require * closing steam, confirm y/N * * - terminate steam * * - get current launch options, if not empty confirm overwrite * * - set new launch options * * - optionally set up the shortcut to launch unmodded, to do so we make a * .app in the game folder that runs a bash script which simply executes * the original .app, copy it to ~/Applications */ const app = steamApps[0]; const { name, id } = app; const game = code(name); const [userId, { PersonaName, AccountName }] = await getMostRecentUser(); const username = code(PersonaName ?? AccountName); printline( wrap([ [ game, "appears to be installed with Steam. gib will need to configure Steam", "to launch it modded with BepInEx, which will overwrite any launch", "options you currently have set for the game, and will require Steam", "to be closed.", ].join(" "), null, [ "Additionally, gib can optionally add a Steam shortcut to launch the", "game vanilla (without mods). This feature is experimental, and is", "only supported for BepInEx packs which support the", code("--doorstop_enabled"), "flag.", ].join(" "), null, [ code("--doorstop_enabled"), "support", switchSupported ? chalk.green.bold("detected") : chalk.redBright.bold("not found"), "in your BepInEx pack.", ].join(" "), null, switchSupported ? [ "gib will set this flag in the vanilla shortcut. Steam may prompt", "you about this flag whenever you launch the vanilla shortcut.", ].join(" ") : [ "As your BepInEx pack does not support this feature, gib can", "attempt to add support by downloading the latest version of", "BepInEx and updating the provided pack. This will allow gib to set", "up the vanilla shortcut while retaining any game-specific", "customisations from the pack.", ].join(" "), null, ]), ); shouldAddShortcut = confirm( wrap( switchSupported ? `Add experimental Steam shortcut to launch ${game} without mods?` : [ `Add experimental Steam shortcut to launch ${game} without mods by`, "updating this pack to the latest BepInEx 5 release?", ].join(" "), ), yes, ); printline( wrap([ null, chalk.bold(`gib will now perform the following operations:${EOL}`), ]), ); printline( list( [ "install and configure BepInEx for the selected Unity app", "quit Steam if it is open", [ `configure Steam for user ${username} to launch ${game} modded`, "with BepInEx", ].join(" "), shouldAddShortcut && !switchSupported && "download the latest BepInEx 5 release to update your BepInex pack", shouldAddShortcut && [ `add a Steam shortcut for user ${username} to launch ${game}`, "vanilla", ].join(" "), ].filter(Boolean), false, ), ); printline( wrap([ null, chalk.bold.yellowBright( "This will potentially overwrite files in the process.", ), null, "You may be required to grant permission to the Terminal.", null, ]), ); if (!confirm(wrap(chalk.yellowBright("Proceed?")), yes)) { throw wrap("User cancelled installation"); } printline(); operations.push( (async () => { const installing = installBepInEx(); if (shouldAddShortcut && !switchSupported) { let response: Promise; try { const releases = z.object({ target_commitish: z.string(), prerelease: z.boolean(), assets: z.object({ name: z.string(), browser_download_url: z.string(), }).array(), }).array() .parse( await (await fetch( "https://api.github.com/repos/BepInEx/BepInEx/releases", )).json(), ); const { assets } = releases .find(({ target_commitish, prerelease, assets }) => !prerelease && target_commitish === "v5-lts" && assets.find(({ name }) => name.toLowerCase().includes("macos_x64") && name.toLowerCase().endsWith(".zip") ) ) ?? {}; const { browser_download_url } = assets?.find(({ name }) => name.toLowerCase().includes("macos_x64") && name.toLowerCase().endsWith(".zip") ) ?? {}; if (!browser_download_url) { throw "Couldn't get latest BepInEx 5 release asset"; } response = fetch(browser_download_url); } catch { response = fetch( "https://github.com/BepInEx/BepInEx/releases/download/v5.4.23.5/BepInEx_macos_universal_5.4.23.5.zip", ); } const [archive] = await Promise.all([ response.then(async (response) => await JSZip.loadAsync(await response.arrayBuffer()) ), installing, ]); bepinexScriptName = "run_bepinex.sh"; const filenames = Object.keys(archive.files); if (!filenames.includes(bepinexScriptName)) { throw "Downloaded BepInEx pack appears invalid"; } if (basename(bepinexScriptPath) !== bepinexScriptName) { await file(resolve(gamePath, basename(bepinexScriptPath))).delete(); } await Promise.all(filenames .map((filename) => archive.file(filename)!.async("arraybuffer") .then(async (data) => { await write(resolve(gamePath, filename), data); if (filename === bepinexScriptName) { configureBepInExScript( resolve(gamePath, filename), gameAppPath, ); } }) )); } else { await installing; } })(), isOpen() .then(async (isOpen) => { if (isOpen && !await quit()) { throw wrap([ "Failed to terminate Steam", chalk.reset("Please ensure Steam is fully closed and try again."), ]); } return await Promise.all([ setLaunchOptions( app, quote([ "/usr/bin/arch", "-x86_64", "/bin/bash", resolve(gamePath, bepinexScriptName), "%command%", ]), userId, ).then((success) => { if (!success) throw wrap("Failed to set launch options"); }), shouldAddShortcut && getShortcuts(userId) .then(async (shortcuts) => { const shortcut = { AppName: `${name} (Vanilla)`, Exe: quote([shortcutPath]), LaunchOptions: `# gib v${version}`, icon: join( shortcutPath, "Contents", "Resources", "PlayerIcon.png", ), } satisfies Shortcut; if (shortcuts) { const key = Object.keys(shortcuts.shortcuts).find((key) => { const { AppName, LaunchOptions, Exe } = shortcuts.shortcuts[key] ?? {}; if ( AppName?.localeCompare( shortcut.AppName, undefined, { sensitivity: "accent" }, ) !== 0 ) return false; return (LaunchOptions && /#\s*gib v.*/.test(LaunchOptions)) || (Exe && pathEqual(Exe.trim(), shortcut.Exe.trim())); }); return await setShortcuts( key ? updateShortcut(key, shortcut, shortcuts) : addShortcut(shortcut, shortcuts), userId, ); } return await setShortcuts( { shortcuts: { 0: shortcut } }, userId, ); }) .then((success) => { if (!success) throw wrap("Failed to add shortcut"); }), ].filter(Boolean)); }), shouldAddShortcut ? Promise.all([ write( join(shortcutPath, "Contents", "MacOS", "run.sh"), [ "#!/bin/bash", "# autogenerated file - do not edit", `${ $.escape(execPath) } --launch=${id} -- --doorstop_enabled false "$@"`, ].join(EOL), { createPath: true, mode: 0o764 }, ) .then(() => chmod(join(shortcutPath, "Contents", "MacOS", "run.sh"), 0o764) ), write( join(shortcutPath, "Contents", "Info.plist"), buildPlist( { CFBundleIconFile: "PlayerIcon.icns", CFBundleName: `${ typeof plist.CFBundleName === "string" ? plist.CFBundleName : name } (Vanilla)`, CFBundleInfoDictionaryVersion: "1.0", CFBundlePackageType: "APPL", CFBundleVersion: "1.0", CFBundleExecutable: "run.sh", } satisfies Plist, ), { createPath: true }, ), mkdir(join(shortcutPath, "Contents", "Resources"), { recursive: true, }) .then(() => { const { CFBundleIconFile } = plist; if (CFBundleIconFile) { return Promise.all([ $`sips -s format png ${ join( extname(gameAppPath) === ".app" ? gameAppPath : gamePath, "Contents", "Resources", CFBundleIconFile, ) } --out ${ join( shortcutPath, "Contents", "Resources", "PlayerIcon.png", ) }`.quiet(), write( join( shortcutPath, "Contents", "Resources", "PlayerIcon.icns", ), file( join( extname(gameAppPath) === ".app" ? gameAppPath : gamePath, "Contents", "Resources", CFBundleIconFile, ), ), ), ].filter(Boolean)); } }), ]) : Promise.resolve(), ); } else if (steamApps.length > 1) { /** * - for now, regrettably tell user we can't handle this and advise them to * install manually * * - in future, we can try to narrow it down by parsing steamcmd to figure * out which executable is for which app etc., and later UI stuff */ throw wrap("Multiple Steam apps detected in the same path"); } else { /** * - make a .app in the game folder that runs a bash script which simply * executes the run_bepinex.sh with the path to the original .app as arg, * copy it to ~/Applications, maybe offer to copy it to desktop? * * - if Steam installed, prompt user to ask if they want us to make a Steam * shortcut to launch game modded, let them know this will require closing * steam, let them know this is experimental, confirm y/N, if y: * * - terminate steam * * - get current shortcuts, add new shortcut */ const { CFBundleName, CFBundleIconFile, CFBundleExecutable } = plist; const gameName = typeof CFBundleName === "string" ? CFBundleName : extname(gameAppPath) === ".app" ? parse(gameAppPath).name : parse(CFBundleExecutable).name; const game = code(gameName); const steamInstalled = await isInstalled(); const [userId, user] = steamInstalled ? await getMostRecentUser() : [undefined, undefined]; const username = user && code( typeof user.PersonaName === "string" ? user.PersonaName : user.AccountName, ); shouldAddShortcut = await isInstalled() && confirm( wrap([ [ game, "appears to be a non-Steam game. gib can optionally add a Steam", "shortcut to launch the game modded with BepInEx. This feature is", "experimental and will require Steam to be closed.", ].join(" "), null, `Add experimental Steam shortcut to launch ${game} with BepInEx?`, ]), yes, ); printline( wrap( chalk.bold( `${EOL}gib will now perform the following operations:${EOL}`, ), ), ); printline( list( [ "install and configure BepInEx for the selected Unity app", shouldAddShortcut && "quit Steam if it is open", shouldAddShortcut && [ "add a Steam shortcut for user", username, "to launch", game, "with BepInEx", ].join(" "), ].filter(Boolean), false, ), ); printline( wrap([ null, chalk.bold.yellowBright( "This will potentially overwrite files in the process.", ), null, "You may be required to grant permission to the Terminal.", null, ]), ); if (!confirm(wrap(chalk.yellowBright("Proceed?")), yes)) { throw wrap("User cancelled installation"); } printline(); const shortcutPath = join( homedir(), "Applications", `${ extname(gameAppPath) === ".app" ? parse(gameAppPath).name : CFBundleName || parse(CFBundleExecutable).name } (BepInEx).app`, ); operations.push( installBepInEx(), shouldAddShortcut ? isOpen() .then(async (isOpen) => { if (isOpen && !await quit()) { throw wrap([ "Failed to terminate Steam", chalk.reset( "Please ensure Steam is fully closed and try again.", ), ]); } const shortcut = { AppName: `${gameName} (BepInEx)`, Exe: quote([shortcutPath]), LaunchOptions: `# gib v${version}`, icon: join( shortcutPath, "Contents", "Resources", "PlayerIcon.png", ), } satisfies Shortcut; const shortcuts = await getShortcuts(userId); if (shortcuts) { const key = Object.keys(shortcuts.shortcuts).find((key) => { const { AppName, LaunchOptions, Exe } = shortcuts.shortcuts[key] ?? {}; if ( AppName?.localeCompare( shortcut.AppName, undefined, { sensitivity: "accent" }, ) !== 0 ) return false; return (LaunchOptions && /#\s*gib v.*/.test(LaunchOptions)) || (Exe && pathEqual(Exe.trim(), shortcut.Exe.trim())); }); return await setShortcuts( key ? updateShortcut(key, shortcut, shortcuts) : addShortcut(shortcut, shortcuts), userId, ); } return await setShortcuts({ shortcuts: { 0: shortcut } }, userId); }) .then((success) => { if (!success) throw wrap("Failed to add shortcut"); }) : Promise.resolve(), Promise.all([ write( join(shortcutPath, "Contents", "MacOS", "run.sh"), [ "#!/bin/bash", "# autogenerated file - do not edit", quote([ "/usr/bin/arch", "-x86_64", "/bin/bash", join(gamePath, bepinexScriptName), gameAppPath, ]), ].join(EOL), { createPath: true, mode: 0o764 }, ) .then(() => chmod( join(shortcutPath, "Contents", "MacOS", "run.sh"), 0o764, ) ), mkdir(join(shortcutPath, "Contents", "Resources"), { recursive: true }) .then(async () => { if (CFBundleIconFile) { await mkdir(join(shortcutPath, "Contents", "Resources"), { recursive: true, }); await Promise.all([ shouldAddShortcut && $`sips -s format png ${ join( extname(gameAppPath) === ".app" ? gameAppPath : gamePath, "Contents", "Resources", CFBundleIconFile, ) } --out ${ join(shortcutPath, "Contents", "Resources", "PlayerIcon.png") }`.quiet(), write( join( shortcutPath, "Contents", "Resources", "PlayerIcon.icns", ), file( join( extname(gameAppPath) === ".app" ? gameAppPath : gamePath, "Contents", "Resources", CFBundleIconFile, ), ), ), write( join(shortcutPath, "Contents", "Info.plist"), buildPlist( { CFBundleIconFile, CFBundleName: `${gameName} (BepInEx)`, CFBundleInfoDictionaryVersion: "1.0", CFBundlePackageType: "APPL", CFBundleVersion: "1.0", CFBundleExecutable: "run.sh", } satisfies Plist, ), ), ].filter(Boolean)); } }), ]), ); } await Promise.all(operations); printline( wrap([ "Finally, let's test that everything is working.", null, "To perform the test, gib will automatically launch the game, wait up to 30 seconds for signs of BepInEx activity, and then force quit the game.", null, "Return to this Terminal window once the game has closed.", ]), ); pressHeartToContinue("when you're ready to run the test"); const steamApp = steamApps[0]; if (steamApp) { await launch(steamApp); printline(wrap([`Launching ${code(steamApp.name)} with Steam...`, null])); } else { await open(shortcutPath); } var { detectedGame, detectedBepInEx } = await new Promise< { detectedGame: boolean; detectedBepInEx: boolean } >( (resolve) => { const watcher = watch(join(gamePath, "BepInEx", "LogOutput.log"), { ignoreInitial: true, ignorePermissionErrors: true, }); let detectedGame = false, detectedBepInEx = false; const getProcesses = async () => { const processes = await find( "name", plist.CFBundleExecutable ?? basename(gameAppPath), ); return processes.filter( (process) => { if (!("bin" in process) || typeof process.bin !== "string") { return false; } const { bin } = process; const relativePath = relative(gamePath, bin); return relativePath && !relativePath.startsWith("..") && !isAbsolute(relativePath); }, ); }; const finish = async () => { await watcher.removeAllListeners().close(); clearTimeout(timeout); clearInterval(interval); (await getProcesses()).map(({ pid }) => kill(pid, "SIGKILL")); resolve({ detectedGame, detectedBepInEx }); }; let timeout = setTimeout(finish, 30_000); const interval = setInterval(async () => { const processes = await getProcesses(); if (!detectedGame && processes.length) { clearTimeout(timeout); detectedGame = true; timeout = setTimeout(finish, 30_000); printline(`${code(basename(gameAppPath))} running...`); } else if (detectedGame && !processes.length) { printline(`${code(basename(gameAppPath))} closed.`); await finish(); } }, 200); const handleChange = async () => { detectedBepInEx = true; (await getProcesses()).map(({ pid }) => kill(pid, "SIGKILL")); await watcher.removeAllListeners().close(); }; watcher .on("add", handleChange) .on("change", handleChange); }, ); printline(); if (!detectedGame && !detectedBepInEx) { throw wrap( `Timed out waiting for the game to launch. Test cancelled.${EOL}` + chalk.reset( "Unable to verify whether BepInEx is correctly installed. We " + "recommend running gib again, making sure to run the right game.", ), ); } else if (!detectedBepInEx) { throw wrap([ "Failed to detect BepInEx", chalk.reset( [ "It seems BepInEx failed to inject into the game. Unfortunately,", "some Unity games don't work with BepInEx on macOS for unknown", "reasons, and this appears to be one of them 😔", ].join(" "), ), ]); } else { await open("https://github.com/toebeann/gib/?sponsor=1", { background: true, }); printline( wrap([ chalk.green.bold("Successfully detected BepInEx running!"), null, "Congratulations, you're now ready to go wild installing mods!", null, ...steamApp ? [ "To launch the game modded, simply launch it from Steam as usual.", ...shouldAddShortcut ? [ null, [ "We also added a Steam shortcut to launch the vanilla game.", "You can find it in your Steam library named", code(`${steamApp.name} (Vanilla)`), ].join(" "), chalk.italic([ "Please be aware this feature is experimental.", "Steam may prompt you about the", code("--doorstop_enabled"), "flag whenever you launch the vanilla shortcut.", ].join(" ")), ] : switchSupported ? [] : chalk.italic([ "We recommend running gib again with an official release of the", "latest BepInEx 5, so that gib can add a Steam shortcut to", "launch the game vanilla.", ].join(" ")), ] : [ "To launch the game modded, launch the app found at:", chalk.green(shortcutPath), null, ...shouldAddShortcut ? [ [ "We also added a shortcut to Steam to launch the game with", "BepInEx. You can find it in your Steam library named", code(parse(shortcutPath).name), ].join(" "), null, ] : [], [ "Please be aware that platform-specific features such as", "achievements or in-game overlays will be unavailable when", "running mods with non-Steam games.", ].join(" "), null, [ "To launch the game vanilla, simply launch it as you normally", "would, e.g. via the Epic Games launcher, etc.", ].join(" "), ], null, "If you found gib helpful, please consider donating:", null, ]), ); printline( list([ link( chalk.hex("#00457C")("PayPal"), "https://paypal.me/tobeyblaber", ), link( chalk.hex("#ff5e5b")("Ko-fi"), "https://ko-fi.com/toebean_", ), link( chalk.hex("#4078c0")("GitHub"), "https://github.com/sponsors/toebeann", ), ], false), ); printline(wrap([null, pink("- tobey ♥"), null])); } }; export const setup = async () => { const command = basename(execPath); const { wantsHelp, wantsVersion, wantsAutoUpdate, wantsUpdateExitStatus, wantsCheckPath, launch: launchId, yes, positionals, } = config(); const printHelp = () => { printline( wrap( [ `${ pink("gib") } is a TUI app for automating the installation of BepInEx on macOS. ${ chalk.dim(`(${version})`) }`, null, chalk.bold( `Usage: gib ${chalk.cyan("[...flags]")} ${ chalk.greenBright.dim("[bepinexScriptPath] [unityAppPath]") }`, ), null, chalk.bold("Flags:"), ], ), ); printline( [ ` ${ wrap( `${chalk.cyan("-v")}, ${ chalk.cyan("--version") } Print version and exit`, width() - 2, ) }`, ` ${ wrap( `${chalk.cyan("-s")}, ${ chalk.cyan("--status") } Print update status and exit`, width() - 2, ) }`, ` ${ wrap( `${ chalk.cyan(`--launch${chalk.dim("=")}`) } Immediately launch Steam app with the specified id and exit`, width() - 6, ) }`, ` ${ wrap( `${chalk.cyan("--no-update")} Disable update check`, width() - 6, ) }`, command === "gib" && ` ${ wrap( `${chalk.cyan("--no-path-check")} Disable $PATH check`, width() - 6, ) }`, ` ${ wrap( `${chalk.cyan("-y")}, ${ chalk.cyan("--yes") } Accept all defaults and automatically progress`, width() - 2, ) }`, ` ${ wrap( `${chalk.cyan("-h")}, ${ chalk.cyan("--help") } Display usage information and exit`, width() - 2, ) }`, ].filter(Boolean).join(EOL), ); printline(wrap([null, chalk.bold("Examples:")])); printline( [ ` ${ wrap( chalk.dim("Interactivly install BepInEx to a Unity game"), width() - 2, ) }`, ` ${wrap(`${chalk.bold.greenBright("gib")} `, width() - 2)}`, null, ` ${ wrap( chalk.dim( "Install BepInEx from the provided path to the Unity game at the provided path, accepting all default options", ), width() - 2, ) }`, ` ${ wrap( `${chalk.bold.greenBright("gib")} ${chalk.cyan("-y")} ${ chalk.blue( "~/Downloads/Tobey.s.BepInEx.Pack.for.Subnautica '~/Library/Application Support/Steam/steamapps/common/Subnautica'", ) }`, width() - 2, ) }`, null, ` ${ wrap( chalk.dim( "(Re-)install BepInEx from the current working directory to the Unity game at the current working directory, accepting all default options", ), width() - 2, ) }`, ` ${ wrap( `${chalk.bold.greenBright("gib")} ${chalk.cyan("-y")} ${ chalk.blue(".") }`, width() - 2, ) }`, ].join(EOL), ); printline( wrap([ null, `Learn more about gib: ${ pink("https://github.com/toebeann/gib#readme") }`, ]), ); }; if (launchId) { const parts = launchId.split(":"); const id = parts[1] ?? parts[0]; const launcher = parts[0].toLowerCase(); switch (launcher) { case "steam": default: return await launch(id, positionals); } } if (wantsHelp) { printHelp(); return; } if (wantsVersion) { printline(version); return; } let latest: string | undefined; if (wantsAutoUpdate || wantsUpdateExitStatus) { try { const resolvedPackageMetadata = await fetch( "https://data.jsdelivr.com/v1/packages/gh/toebeann/gib/resolved", ); const parsed = z .looseObject({ version: z.string() }) .safeParse(await resolvedPackageMetadata.json()); if (parsed.success) { latest = parsed.data.version; } } catch {} } const updateAvailable = latest !== undefined && semver.satisfies(version, `<${latest}`); const updateCommand = `curl -fsSL https://cdn.jsdelivr.net/gh/toebeann/gib@v${latest}/gib.sh | bash -s v${latest}`; if (wantsUpdateExitStatus) { if (updateAvailable) { await $`echo -n ${updateCommand.trim()} | pbcopy`.nothrow().quiet(); errorline( wrap(`gib ${chalk.bold.underline(`v${latest}`)} is available.`), ); printline( wrap([ `Changelog: https://github.com/toebeann/gib/releases/tag/v${latest}`, "Run the following command to update and relaunch gib:", ]), ); printline(` ${wrap(chalk.dim(updateCommand), width() - 2)}`); printline( wrap( `The command has been placed in your clipboard so you can simply paste it.`, ), ); return exit(1); } else { printline(wrap("gib is up-to-date.")); return exit(0); } } if (updateAvailable) { printline( wrap([ null, `gib ${orange.bold.underline(`v${latest}`)} is available.`, `Changelog: https://github.com/toebeann/gib/releases/tag/v${latest}`, chalk.dim( `You currently have ${ chalk.bold.underline(`v${version}`) } installed.`, ), null, ]), ); if (confirm(chalk.yellowBright("Would you like to update?"), yes)) { await $`echo -n ${updateCommand.trim()} | pbcopy`.nothrow().quiet(); printline( wrap([null, "Run the following command to update and relaunch gib:"]), ); printline(` ${wrap(chalk.dim(updateCommand), width() - 2)}`); printline( wrap([ `The command has been placed in your clipboard so you can simply paste it.`, null, ]), ); return; } printline(); } if (wantsCheckPath) { const commandResult = await $`command -v ${command}`.nothrow().quiet(); const commandExists = commandResult.exitCode === 0; if (!commandExists) { if (!updateAvailable) printline(); const attemptAddCommandToPath = async () => { const pathText = chalk.yellowBright.bold("$PATH"); const { SHELL } = env; const quotedInstallFolder = quote([dirname(dirname(execPath))]); const quotedBinFolder = quote([dirname(execPath)]); const promptToManuallyEditConfig = ( configPath: string, commands: string[], ) => { printline( wrap([ `${command} not found in ${pathText}.`, `We recommend adding ${command} to ${pathText} for ease of use.`, null, `To do so, manually add the equivalent commands to ${ chalk.yellowBright.bold(configPath) } (or similar):`, ]), ); for (const command of commands) { printline(` ${wrap(code(command), width() - 2)}`); } }; const configCommandsNeeded = async ( configPath: string, commands: string[], ) => { const absoluteConfigPath = untildify(configPath); await access(absoluteConfigPath, W_OK); const lines = (await file(absoluteConfigPath).text()) .split(EOL) .map((line) => line.trim()); return commands.filter((command) => !lines.includes(command)); }; const appendCommandsToConfig = async ( configPath: string, commands: string[], ) => { const absoluteConfigPath = untildify(configPath); await appendFile( absoluteConfigPath, ["", "# gib", ...commands, ""].join(EOL), ); printline( wrap([ `${command} has been added to ${pathText} in ${ code(configPath) }.`, null, "The next time you want to launch gib, you can simply run:", ]), ); printline(` ${wrap(code(command), width() - 2)}`); printline( wrap( chalk.dim( "You will need to reload your terminal for this command to be available.", ), ), ); }; if ( !SHELL || !(["fish", "zsh", "bash"] as const).includes(basename(SHELL)) ) { promptToManuallyEditConfig("~/.bashrc", [ `export GIB_INSTALL=${quotedInstallFolder}`, `export PATH=${quotedBinFolder}:$PATH`, ]); } else if (basename(SHELL) === "fish") { const config = join("~", ".config", "fish", "config.fish"); const commands = [ `set --export GIB_INSTALL ${quotedInstallFolder}`, `set --export PATH ${quotedBinFolder} $PATH`, ]; try { const commandsToAppend = await configCommandsNeeded( config, commands, ); if (commandsToAppend.length) { await appendCommandsToConfig(config, commandsToAppend); } else { return; } } catch { promptToManuallyEditConfig(config, commands); } } else if (basename(SHELL) === "zsh") { const config = join("~", ".zshrc"); const commands = [ `export GIB_INSTALL=${quotedInstallFolder}`, `export PATH=${quotedBinFolder}:$PATH`, ]; try { const commandsToAppend = await configCommandsNeeded( config, commands, ); if (commandsToAppend.length) { await appendCommandsToConfig(config, commandsToAppend); } else { return; } } catch { promptToManuallyEditConfig(config, commands); } } else if (basename(SHELL) === "bash") { const { XDG_CONFIG_HOME } = env; const configs = [ join("~", ".bash_profile"), join("~", ".bashrc"), ]; if (XDG_CONFIG_HOME) { configs.concat( join(XDG_CONFIG_HOME, ".bash_profile"), join(XDG_CONFIG_HOME, ".bashrc"), join(XDG_CONFIG_HOME, "bash_profile"), join(XDG_CONFIG_HOME, "bashrc"), ); } const commands = [ `export GIB_INSTALL=${quotedInstallFolder}`, `export PATH=${quotedBinFolder}:$PATH`, ]; let done = false; for (const config of configs) { try { const commandsToAppend = await configCommandsNeeded( config, commands, ); if (commandsToAppend.length) { await appendCommandsToConfig(config, commandsToAppend); } else { return; } done = true; break; } catch {} } if (!done) promptToManuallyEditConfig("~/.bashrc", commands); } if (!yes) { alert(wrap([null, chalk.yellowBright("Press enter to continue")])); } printline(); }; await attemptAddCommandToPath(); } } switch (positionals.length) { case 0: return await run({ yes }); case 1: try { const bepinexScriptPath = await getBepInExScriptPath(positionals[0]) .catch(() => getBepInExScriptPath(".")); const unityAppPath = await getUnityAppPath(positionals[0]) .catch(() => getUnityAppPath(".")); return await run({ bepinexScriptPath, unityAppPath, yes }); } catch (e) { console.error(e); return printHelp(); } case 2: try { const bepinexScriptPath = await getBepInExScriptPath(positionals[0]); const unityAppPath = await getUnityAppPath(positionals[1]); return await run({ bepinexScriptPath, unityAppPath, yes }); } catch (e) { console.error(e); return printHelp(); } default: return printHelp(); } }; if (import.meta.main) setup();