Heroic-Games-Launcher / HeroicGamesLauncher

A games launcher for GOG, Amazon and Epic Games for Linux, Windows and macOS.
https://heroicgameslauncher.com
GNU General Public License v3.0
7.89k stars 417 forks source link

[macOS] Alternative way to find Wine and GPTK binaries #3025

Open blackxfiied opened 1 year ago

blackxfiied commented 1 year ago

Describe the bug

hi, sorry to be annoying with this one, honestly i have no idea how pull requests work, but i made some modifications to compatibility_layers.ts to make it find crossover and gptk using other more conventional methods than mdfind, as mdfind has proven to be unreliable

instead, this uses find, and fs +: more compatibility i guess -: find and fs assume that the compatibility layers are installed to their default location.

Add logs

here's the improved compatibility_layers.ts

import { GlobalConfig } from 'backend/config'
import {
  configPath,
  getSteamLibraries,
  isMac,
  toolsPath,
  userHome
} from 'backend/constants'
import { logError, LogPrefix, logInfo } from 'backend/logger/logger'
import { execAsync } from 'backend/utils'
import { execSync } from 'child_process'
import { WineInstallation } from 'common/types'
import { existsSync, mkdirSync, readFileSync, readdirSync } from 'graceful-fs'
import { homedir } from 'os'
import { dirname, join } from 'path'
import { PlistObject, parse as plistParse } from 'plist'
import * as fs from 'fs';
import { promisify } from 'util';

/**
 * Loads the default wine installation path and version.
 *
 * @returns Promise<WineInstallation>
 */
export function getDefaultWine(): WineInstallation {
  const defaultWine: WineInstallation = {
    bin: '',
    name: 'Default Wine - Not Found',
    type: 'wine'
  }

  try {
    let stdout = execSync(`which wine`).toString()
    const wineBin = stdout.split('\n')[0]
    defaultWine.bin = wineBin

    stdout = execSync(`wine --version`).toString()
    const version = stdout.split('\n')[0]
    defaultWine.name = `Wine Default - ${version}`

    return {
      ...defaultWine,
      ...getWineExecs(wineBin)
    }
  } catch {
    return defaultWine
  }
}

function getCustomWinePaths(): Set<WineInstallation> {
  const customPaths = new Set<WineInstallation>()
  // skips this on new installations to avoid infinite loops
  if (existsSync(configPath)) {
    const { customWinePaths = [] } = GlobalConfig.get().getSettings()
    customWinePaths.forEach((path: string) => {
      if (path.endsWith('proton')) {
        return customPaths.add({
          bin: path,
          name: `Custom Proton - ${path}`,
          type: 'proton'
        })
      }
      return customPaths.add({
        bin: path,
        name: `Custom Wine - ${path}`,
        type: 'wine',
        ...getWineExecs(path)
      })
    })
  }
  return customPaths
}

/**
 * Checks if a Wine version has the Wineserver executable and returns the path to it if it's present
 * @param wineBin The unquoted path to the Wine binary ('wine')
 * @returns The quoted path to wineserver, if present
 */
export function getWineExecs(wineBin: string): { wineserver: string } {
  const wineDir = dirname(wineBin)
  const ret = { wineserver: '' }
  const potWineserverPath = join(wineDir, 'wineserver')
  if (existsSync(potWineserverPath)) {
    ret.wineserver = potWineserverPath
  }
  return ret
}

/**
 * Checks if a Wine version has lib/lib32 folders and returns the path to those if they're present
 * @param wineBin The unquoted path to the Wine binary ('wine')
 * @returns The paths to lib and lib32, if present
 */
export function getWineLibs(wineBin: string): {
  lib: string
  lib32: string
} {
  const wineDir = dirname(wineBin)
  const ret = { lib: '', lib32: '' }
  const potLib32Path = join(wineDir, '../lib')
  if (existsSync(potLib32Path)) {
    ret.lib32 = potLib32Path
  }
  const potLibPath = join(wineDir, '../lib64')
  if (existsSync(potLibPath)) {
    ret.lib = potLibPath
  }
  return ret
}

export async function getLinuxWineSet(
  scanCustom?: boolean
): Promise<Set<WineInstallation>> {
  if (!existsSync(`${toolsPath}/wine`)) {
    mkdirSync(`${toolsPath}/wine`, { recursive: true })
  }

  if (!existsSync(`${toolsPath}/proton`)) {
    mkdirSync(`${toolsPath}/proton`, { recursive: true })
  }

  const altWine = new Set<WineInstallation>()

  readdirSync(`${toolsPath}/wine/`).forEach((version) => {
    const wineBin = join(toolsPath, 'wine', version, 'bin', 'wine')
    altWine.add({
      bin: wineBin,
      name: `Wine - ${version}`,
      type: 'wine',
      ...getWineLibs(wineBin),
      ...getWineExecs(wineBin)
    })
  })

  const lutrisPath = `${homedir()}/.local/share/lutris`
  const lutrisCompatPath = `${lutrisPath}/runners/wine/`

  if (existsSync(lutrisCompatPath)) {
    readdirSync(lutrisCompatPath).forEach((version) => {
      const wineBin = join(lutrisCompatPath, version, 'bin', 'wine')
      altWine.add({
        bin: wineBin,
        name: `Wine - ${version}`,
        type: 'wine',
        ...getWineLibs(wineBin),
        ...getWineExecs(wineBin)
      })
    })
  }

  const protonPaths = [`${toolsPath}/proton/`]

  await getSteamLibraries().then((libs) => {
    libs.forEach((path) => {
      protonPaths.push(`${path}/steam/steamapps/common`)
      protonPaths.push(`${path}/steamapps/common`)
      protonPaths.push(`${path}/root/compatibilitytools.d`)
      protonPaths.push(`${path}/compatibilitytools.d`)
      return
    })
  })

  const proton = new Set<WineInstallation>()

  protonPaths.forEach((path) => {
    if (existsSync(path)) {
      readdirSync(path).forEach((version) => {
        const protonBin = join(path, version, 'proton')
        // check if bin exists to avoid false positives
        if (existsSync(protonBin)) {
          proton.add({
            bin: protonBin,
            name: `Proton - ${version}`,
            type: 'proton'
            // No need to run this.getWineExecs here since Proton ships neither Wineboot nor Wineserver
          })
        }
      })
    }
  })

  const defaultWineSet = new Set<WineInstallation>()
  const defaultWine = await getDefaultWine()
  if (!defaultWine.name.includes('Not Found')) {
    defaultWineSet.add(defaultWine)
  }

  let customWineSet = new Set<WineInstallation>()
  if (scanCustom) {
    customWineSet = getCustomWinePaths()
  }

  return new Set([...defaultWineSet, ...altWine, ...proton, ...customWineSet])
}

/// --------------- MACOS ------------------

/**
 * Detects Wine installed on home application folder on Mac
 *
 * @returns Promise<Set<WineInstallation>>
 */
export async function getWineOnMac(): Promise<Set<WineInstallation>> {
  const wineSet = new Set<WineInstallation>()
  if (!isMac) {
    return wineSet
  }

  const winePaths = new Set<string>()

  // search for wine installed on $HOME/Library/Application Support/heroic/tools/wine
  const wineToolsPath = `${toolsPath}/wine/`
  if (existsSync(wineToolsPath)) {
    readdirSync(wineToolsPath).forEach((path) => {
      winePaths.add(join(wineToolsPath, path))
    })
  }

  // search for wine installed around the system
  await execAsync('mdfind kMDItemCFBundleIdentifier = "*.wine"').then(
    async ({ stdout }) => {
      stdout.split('\n').forEach((winePath) => {
        winePaths.add(winePath)
      })
    }
  )

  winePaths.forEach((winePath) => {
    const infoFilePath = join(winePath, 'Contents/Info.plist')
    if (winePath && existsSync(infoFilePath)) {
      const info = plistParse(
        readFileSync(infoFilePath, 'utf-8')
      ) as PlistObject
      const version = info['CFBundleShortVersionString'] || ''
      const name = info['CFBundleName'] || ''
      const wineBin = join(winePath, '/Contents/Resources/wine/bin/wine64')
      if (existsSync(wineBin)) {
        wineSet.add({
          ...getWineExecs(wineBin),
          lib: `${winePath}/Contents/Resources/wine/lib`,
          lib32: `${winePath}/Contents/Resources/wine/lib`,
          bin: wineBin,
          name: `${name} - ${version}`,
          type: 'wine',
          ...getWineExecs(wineBin)
        })
      }
    }
  })

  return wineSet
}

export async function getWineskinWine(): Promise<Set<WineInstallation>> {
  const wineSet = new Set<WineInstallation>()
  if (!isMac) {
    return wineSet
  }
  const wineSkinPath = `${userHome}/Applications/Wineskin`
  if (existsSync(wineSkinPath)) {
    const apps = readdirSync(wineSkinPath)
    for (const app of apps) {
      if (app.includes('.app')) {
        const wineBin = `${userHome}/Applications/Wineskin/${app}/Contents/SharedSupport/wine/bin/wine64`
        if (existsSync(wineBin)) {
          try {
            const { stdout: out } = await execAsync(`'${wineBin}' --version`)
            const version = out.split('\n')[0]
            wineSet.add({
              ...getWineExecs(wineBin),
              lib: `${userHome}/Applications/Wineskin/${app}/Contents/SharedSupport/wine/lib`,
              lib32: `${userHome}/Applications/Wineskin/${app}/Contents/SharedSupport/wine/lib`,
              name: `Wineskin - ${version}`,
              type: 'wine',
              bin: wineBin
            })
          } catch (error) {
            logError(
              `Error getting wine version for ${wineBin}`,
              LogPrefix.GlobalConfig
            )
          }
        }
      }
    }
  }
  return wineSet
}

/**
 * Detects CrossOver installs on Mac
 *
 * @returns Promise<Set<WineInstallation>>
 */

/*
export async function getCrossover(): Promise<Set<WineInstallation>> {
  const crossover = new Set<WineInstallation>()

  if (!isMac) {
    return crossover
  }

  await execAsync(
    'mdfind kMDItemCFBundleIdentifier = "com.codeweavers.CrossOver"'
  )
    .then(async ({ stdout }) => {
      stdout.split('\n').forEach((crossoverMacPath) => {
        const infoFilePath = join(crossoverMacPath, 'Contents/Info.plist')
        if (crossoverMacPath && existsSync(infoFilePath)) {
          const info = plistParse(
            readFileSync(infoFilePath, 'utf-8')
          ) as PlistObject
          const version = info['CFBundleShortVersionString'] || ''
          const crossoverWineBin = join(
            crossoverMacPath,
            'Contents/SharedSupport/CrossOver/bin/wine'
          )
          crossover.add({
            bin: crossoverWineBin,
            name: `CrossOver - ${version}`,
            type: 'crossover',
            ...getWineExecs(crossoverWineBin)
          })
        }
      })
    })
    .catch(() => {
      logInfo('CrossOver not found', LogPrefix.GlobalConfig)
    })
  return crossover
}
*/

const lstat = promisify(fs.lstat);
const readFile = promisify(fs.readFile);

export async function getCrossover(): Promise<Set<WineInstallation>> {
  const crossover = new Set<WineInstallation>();

  if (!isMac) {
    return crossover;
  }

  // Define the default installation path for CrossOver
  const defaultCrossoverPath = '/Applications/CrossOver.app';

  try {
    // Check if the CrossOver app directory exists
    const stats = await lstat(defaultCrossoverPath);
    if (stats.isDirectory()) {
      // Check if the Info.plist file exists
      const infoFilePath = `${defaultCrossoverPath}/Contents/Info.plist`;
      try {
        // Read and parse the Info.plist file
        const infoPlistContent = await readFile(infoFilePath, 'utf-8');
        if (infoPlistContent.includes('com.codeweavers.CrossOver')) {
          const info = plistParse(infoPlistContent) as PlistObject;
          const version = info['CFBundleShortVersionString'] || '';
          const crossoverWineBin = `${defaultCrossoverPath}/Contents/SharedSupport/CrossOver/bin/wine`;

          crossover.add({
            bin: crossoverWineBin,
            name: `CrossOver - ${version}`,
            type: 'crossover',
            ...getWineExecs(crossoverWineBin),
          });
        }
      } catch (plistError: unknown) {
        if (plistError instanceof Error) {
          // Handle plist parsing errors if needed
          logError(`Error parsing Info.plist: ${plistError.message}`, LogPrefix.GlobalConfig);
        }
      }
    }
  } catch (error) {
    if (error instanceof Error) {
      // Handle errors or access denied cases if needed
      if (error.message.includes('ENOENT')) {
        logInfo('CrossOver not found', LogPrefix.GlobalConfig);
      } else {
        logError(`Error searching for CrossOver: ${error}`, LogPrefix.GlobalConfig);
      }
    }
  }

  return crossover;
}

/**
 * Detects Game Porting Toolkit Wine installs on Mac
 * @returns Promise<Set<WineInstallation>>
 **/
export async function getGamingPortingToolkitWine(): Promise<Set<WineInstallation>> {
  const gamingPortingToolkitWine = new Set<WineInstallation>();
  if (!isMac) {
    return gamingPortingToolkitWine;
  }

  logInfo('Searching for Game Porting Toolkit Wine', LogPrefix.GlobalConfig);

  const findWineBinCommand = `find /usr/local/Cellar -type f -name wine64 2>/dev/null | grep '/game-porting-toolkit.*\/wine64$'`;
  const { stdout } = await execAsync(findWineBinCommand);

  const wineBin = stdout.split('\n')[0];

  if (wineBin) {
    logInfo(
      `Found Game Porting Toolkit Wine at ${wineBin}`,
      LogPrefix.GlobalConfig
    );

    try {
      const { stdout: out } = await execAsync(`'${wineBin}' --version`);
      const version = out.split('\n')[0];
      gamingPortingToolkitWine.add({
        ...getWineExecs(wineBin),
        name: `GPTK Wine (DX11/DX12 Only) - ${version}`,
        type: 'toolkit',
        lib: `${dirname(wineBin)}/../lib`,
        lib32: `${dirname(wineBin)}/../lib`,
        bin: wineBin
      });
    } catch (error) {
      logError(
        `Error getting wine version for ${wineBin}`,
        LogPrefix.GlobalConfig
      );
    }
  } else {
    logInfo('Game Porting Toolkit Wine not found', LogPrefix.GlobalConfig);
  }

  return gamingPortingToolkitWine;
}

export function getWineFlags(
  wineBin: string,
  wineType: WineInstallation['type'],
  wrapper: string
) {
  switch (wineType) {
    case 'wine':
    case 'toolkit':
      return ['--wine', wineBin, ...(wrapper ? ['--wrapper', wrapper] : [])]
    case 'proton':
      return ['--no-wine', '--wrapper', `${wrapper} '${wineBin}' run`]
    default:
      return []
  }
}

Steps to reproduce

n/a

Expected behavior

n/a

Screenshots

No response

Heroic Version

Latest Stable

System Information

Additional information

No response

flavioislima commented 11 months ago

mdfind is not reliable but also hardcoding paths is not good as well, because some people might have installed them in different paths.

mdfind will fail only if you have the macOS searching tool disabled.

vvuk commented 7 months ago

mdfind is not reliable but also hardcoding paths is not good as well, because some people might have installed them in different paths.

mdfind will fail only if you have the macOS searching tool disabled.

Seems reasonable to try both, though? Having spotlight disabled is not that uncommon, and I'd wager 99.9% of people will install GPTK in the default path, especially "end users" that just want to play games

blackxfiied commented 7 months ago

Yeah, so if the macOS searching tool is disabled, then poof! heroic can't find gptk or crossover that's kinda detrimental to a pretty large component of a game launcher, wouldn't you argue?