import { App , ipcMain , IpcMainEvent } from 'electron' ; import { exec } from 'child_process' ; import * as vdf from '@node-steam/vdf' ; import os from 'os' ; import path from 'path' ; import * as registry from 'registry-js' ; import fs from 'fs' ; import GameLaunchArgs , { LaunchWrapperType } from '@/electronMain/models/gameLaunchArgs' ; import { getAmongUsVersion , getModMetadata } from '@/electronMain/native' ; import ModMetadata from '@/electronMain/models/modMetadata' ; import { DownloadModArgs , RemoveModArgs } from '@/electronMain/models/modArgs' ; import axios from 'axios' ; import chokidar from 'chokidar' ; import normalize from 'normalize-path' ; import escapeGlob from 'glob-escape' ; import ServerArgs from '@/electronMain/models/serverArgs' ; import RegionInfoService from '@/electronMain/regionInfoService' ; import RegionInfo from '@/electronMain/models/regionInfo' ; import AdmZip , { IZipEntry } from 'adm-zip' ; import InstallBepinexArgs from '@/electronMain/models/installBepinexArgs' ; import * as dns from 'dns' ; import fg from 'fast-glob' ; import { ELECTRON_APP_ID } from '@/consts' ; import { v4 as uuidv4 } from 'uuid' ; import { BackgroundTask , TaskState , TaskUpdate } from '@/electronMain/models/backgroundTask' ; import GameVersion from '@/services/gameVersionService' ; export default class ElectronMain { fileWatcher ! : chokidar . FSWatcher ; constructor ( app ) { this . registerIPC ( ) ; if ( process . platform === 'win32' ) { app . setAppUserModelId ( ELECTRON_APP_ID ) ; } } registerIPC ( ) { ipcMain . on ( 'launch-game' , async ( event , args ) => { await this . launchGame ( event , args ) ; } ) ; ipcMain . on ( 'autodetect-path' , ( event ) => { event . reply ( 'autodetect-path' , this . detectGamePath ( ) ) ; } ) ; ipcMain . on ( 'download-mod' , this . downloadMod ) ; ipcMain . on ( 'remove-mod' , this . removeMod ) ; ipcMain . on ( 'refresh-mods' , async ( event , location ) => { event . reply ( 'refresh-mods' , await this . refreshMods ( location ) ) ; } ) ; ipcMain . on ( 'write-server-file' , this . writeServersToFile ) ; ipcMain . on ( 'dns-query' , this . dnsQuery ) ; ipcMain . on ( 'start-file-watcher' , async ( event , location ) => { await this . startFileWatcher ( event , location ) ; } ) ; ipcMain . on ( 'stop-file-watcher' , this . stopFileWatcher ) ; ipcMain . on ( 'install-bepinex' , this . installBepinex ) ; ipcMain . on ( 'get-game-version' , this . getGameVersion ) ; } async launchGame ( event , args ) { console . log ( 'Launching:' , args ) ; function launchCallback ( err , data ) { event . reply ( 'launch-game' , err ) ; console . log ( err ) ; console . log ( data . toString ( ) ) ; } if ( ! args . modded ) { process . env . DOORSTOP_DISABLE = 'true' ; } if ( os . platform ( ) === 'linux' || os . platform ( ) === 'darwin' ) { process . env . WINEDLLOVERRIDES = 'winhttp=n,b' ; } switch ( args . launchWrapper ) { case LaunchWrapperType . Steam : { let open ; if ( os . platform ( ) === 'win32' ) { open = 'start' ; } else if ( os . platform ( ) === 'darwin' ) { open = 'open' ; } else if ( os . platform ( ) === 'linux' ) { open = 'xdg-open' ; } else { launchCallback ( new Error ( 'Unsupported os platform' ) , undefined ) ; break ; } exec ( `${ open } "steam://run/945360//--doorstop-enable=${ args . modded }"` , launchCallback ) ; break ; } case LaunchWrapperType . Standard : if ( os . platform ( ) === 'linux' || os . platform ( ) === 'darwin' ) { exec ( `wine "${ path . join ( args . location , 'Among Us.exe' ) }" --doorstop-enable=${ args . modded }` , launchCallback ) ; } else { exec ( `"${ path . join ( args . location , 'Among Us.exe' ) }" --doorstop-enable=${ args . modded }` , launchCallback ) ; } break ; case LaunchWrapperType . Custom : if ( args . customExecLine ) { exec ( `${ args . customExecLine } --doorstop-enable=${ args . modded }` , launchCallback ) ; } else { launchCallback ( new Error ( 'Custom exec line is empty' ) , undefined ) ; } break ; } } detectGamePath ( ) { let steamApps ; const platform = os . platform ( ) ; switch ( platform ) { case 'linux' : steamApps = path . resolve ( os . homedir ( ) , '.steam' , 'steam' , 'steamapps' ) ; break ; case 'darwin' : steamApps = path . resolve ( os . homedir ( ) , 'Library' , 'Application Support' , 'Steam' , 'steamapps' ) ; break ; case 'win32' : { const values = registry . enumerateValues ( registry . HKEY . HKEY_CURRENT_USER , 'Software\\Valve\\Steam' ) ; const steamPath = values . find ( v => v . name === 'SteamPath' ) ; if ( steamPath === undefined || steamPath . type !== registry . RegistryValueType . REG_SZ ) { return undefined ; } steamApps = path . resolve ( steamPath . data , 'steamapps' ) ; break ; } default : return undefined ; } const libraryFoldersVdf = path . resolve ( steamApps , 'libraryfolders.vdf' ) ; if ( ! fs . existsSync ( libraryFoldersVdf ) ) { return undefined ; } const parsed = vdf . parse ( fs . readFileSync ( libraryFoldersVdf , 'utf8' ) ) as { LibraryFolders : { TimeNextStatsReport : number ; ContentStatsID : number ; [ key ] : string | number ; } ; } ; const libraryFolders = [ steamApps ] ; for ( const key of Object . keys ( parsed . LibraryFolders ) ) { const libraryFolder = parsed . LibraryFolders [ key ] ; if ( ! isNaN ( Number ( key ) ) && fs . existsSync ( libraryFolder as string ) ) { libraryFolders . push ( path . resolve ( libraryFolder as string , 'steamapps' ) ) ; } } for ( const libraryFolder of libraryFolders ) { const gamePath = path . resolve ( libraryFolder , 'common' , 'Among Us' ) ; if ( fs . existsSync ( gamePath ) ) { return gamePath ; } } return undefined ; } async downloadMod ( event , { location , fileName , downloadUrl } ) { try { const downloadedFile = await axios . get ( downloadUrl , { responseType : 'stream' } ) ; if ( downloadedFile . status !== 200 ) { console . warn ( `Download mod request returned status code ${ downloadedFile . status }` ) ; event . reply ( 'download-mod' , false ) ; } const modFile = path . join ( location , 'BepInEx' , 'plugins' , fileName ) ; const task = { uuid : uuidv4 ( ) , name : fileName , state : TaskState . Created , currentProgress : 0 , totalProgress : parseInt ( downloadedFile . headers [ 'content-length' ] ) || 0 } ; event . reply ( 'task-create' , task ) ; try { const writer = fs . createWriteStream ( modFile ) ; downloadedFile . data . pipe ( writer ) ; downloadedFile . data . on ( 'data' , ( data ) => { task . currentProgress += Buffer . byteLength ( data ) ; const taskUpdate = { uuid : task . uuid , currentProgress : task . currentProgress , state : TaskState . Running } ; event . reply ( 'task-update' , taskUpdate ) ; } ) ; writer . on ( 'finish' , ( ) => { const taskUpdate = { uuid : task . uuid , currentProgress : task . totalProgress , state : TaskState . Success } ; event . reply ( 'task-update' , taskUpdate ) ; event . reply ( 'download-mod' , true ) ; } ) ; writer . on ( 'error' , ( ) => { const taskUpdate = { uuid : task . uuid , currentProgress : task . currentProgress , state : TaskState . Error } ; event . reply ( 'task-update' , taskUpdate ) ; event . reply ( 'download-mod' , false ) ; } ) ; } catch { console . warn ( `Could not open write stream to file ${ modFile }` ) ; const taskUpdate = { uuid : task . uuid , currentProgress : task . currentProgress , state : TaskState . Error } ; event . reply ( 'task-update' , taskUpdate ) ; event . reply ( 'download-mod' , false ) ; } } catch ( e ) { console . warn ( `Error downloading mod at: ${ downloadUrl }` ) ; event . reply ( 'download-mod' , false ) ; } } async removeMod ( event , { location , guid , version } ) { try { const globifiedLocation = escapeGlob ( normalize ( location ) ) ; const modFiles = await fg ( path . posix . join ( globifiedLocation , 'BepInEx/plugins/**/*.dll' ) ) ; for ( const modFile of modFiles ) { try { const metadata = getModMetadata ( modFile ) ; if ( metadata . id === guid && metadata . version === version ) { await fs . promises . unlink ( modFile ) ; event . reply ( 'remove-mod' , true ) ; return ; } } catch ( e ) { console . warn ( `Could not parse and delete mod: ${ modFile }, In plugin directory: ${ location }/BepInEx/plugins` ) ; event . reply ( 'remove-mod' , false ) ; } } event . reply ( 'remove-mod' , false ) ; } catch ( e ) { console . warn ( `Error reading into directory: ${ location }. Error: ${ e }` ) ; event . reply ( 'remove-mod' , false ) ; } } async refreshMods ( location ) { try { const globifiedLocation = escapeGlob ( normalize ( location ) ) ; const modFiles = await fg ( path . posix . join ( globifiedLocation , 'BepInEx/plugins/**/*.dll' ) ) ; const modMetadata = [ ] ; for ( const modFile of modFiles ) { try { modMetadata . push ( getModMetadata ( modFile ) ) ; } catch ( e ) { console . warn ( `Could not parse mod: ${ modFile }, In plugin directory: ${ location }/BepInEx/plugins. Error: ${ e }` ) ; } } return modMetadata ; } catch ( e ) { console . warn ( `Could not read into directory ${ location }` ) ; return [ ] ; } } async stopFileWatcher ( event ) { try { if ( this . fileWatcher ) { await this . fileWatcher . close ( ) ; event . reply ( 'stop-file-watcher' , true ) ; } } catch ( e ) { event . reply ( 'stop-file-watcher' , false ) ; console . warn ( 'Could not stop file watcher' ) ; } } async startFileWatcher ( event , location ) { const modDir = path . join ( location , 'BepInEx' , 'plugins' ) ; try { if ( this . fileWatcher ) { await this . fileWatcher . close ( ) ; } this . fileWatcher = chokidar . watch ( modDir , { disableGlobbing : true } ) ; this . fileWatcher . on ( 'add' , modFile => { try { event . reply ( 'file-added' , { fileName : modFile , metadata : getModMetadata ( modFile ) } ) ; } catch ( e ) { console . warn ( `Non mod .dll placed into mod directory (${ modFile }). ${ e }` ) ; } } ) ; this . fileWatcher . on ( 'unlink' , modFile => event . reply ( 'file-removed' , modFile ) ) ; event . reply ( 'start-file-watcher' , true ) ; } catch ( e ) { console . warn ( `Error accessing plugin directory under location: ${ modDir }` ) ; event . reply ( 'start-file-watcher' , false ) ; } } async writeServersToFile ( event , { location , servers } ) { const regionInfoPath = path . join ( location , 'regionInfo.dat' ) ; try { await fs . promises . unlink ( regionInfoPath ) ; } catch ( e ) { console . log ( `regionInfo.dat did not exist at path ${ regionInfoPath } initially` ) ; } try { const regionInfo = { header : 0 , nameBuffer : { length : 'Dropship' . length , name : 'Dropship' } , pingIp : { length : '66.175.220.120' . length , ipaddr : '66.175.220.120' } , serverCount : 1 , serverInfo : servers . map ( s => ( { nameBuffer : { length : s . name . length , name : s . name } , ipaddr : s . ipAddress , port : s . port } ) ) } ; await fs . promises . writeFile ( regionInfoPath , RegionInfoService . serialize ( regionInfo ) ) ; event . reply ( 'write-server-file' , true ) ; } catch ( e ) { console . warn ( `Could not write file ${ regionInfoPath } with servers ${ servers }` ) ; event . reply ( 'write-server-file' , false ) ; } } async dnsQuery ( event , query ) { dns . lookup ( query , ( err , address ) => { event . reply ( 'dns-query' , { err , address } ) ; } ) ; } async installBepinex ( event , { location , downloadUrl } ) { try { await fs . promises . rm ( path . join ( location , 'mono' ) , { recursive : true , force : true } ) ; await fs . promises . rm ( path . join ( location , 'winhttp.dll' ) ) ; await fs . promises . rm ( path . join ( location , 'BepInEx' , 'core' ) , { recursive : true , force : true } ) ; } catch ( e ) { console . warn ( 'Could not delete old BepInEx installation, continuing with overwrite.' ) ; } try { const body = await axios . get ( downloadUrl , { responseType : 'arraybuffer' } ) ; const zip = new AdmZip ( Buffer . from ( body . data as ArrayBuffer ) ) ; zip . getEntries ( ) . forEach ( ( zipFile ) => { console . log ( `Extracting file: ${ zipFile . entryName }` ) ; zip . extractEntryTo ( zipFile , location , true , true ) ; } ) ; event . reply ( 'install-bepinex' , true ) ; } catch ( e ) { console . warn ( 'Could not download and install BepInEx' ) ; event . reply ( 'install-bepinex' , false ) ; } } async getGameVersion ( event , location ) { const globalgamemanagerPath = path . join ( location , 'Among Us_Data' , 'globalgamemanagers' ) ; try { if ( fs . existsSync ( globalgamemanagerPath ) ) { const versionString = getAmongUsVersion ( location ) ; const gamePlatform = GameVersion . fromPlatformString ( versionString . slice ( - 1 ) ) ; const gameVersion = versionString . split ( '.' ) . map ( i => parseInt ( i ) ) ; event . reply ( 'get-game-version' , { gameVersion , gamePlatform } ) ; return ; } } catch ( e ) { } console . warn ( `Cannot read game version from ${ globalgamemanagerPath }` ) ; event . reply ( 'get-game-version' , false ) ; } }