Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
blackxfiied opened this issue Sep 3, 2023 · 3 comments · May be fixed by #3728
Open

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

blackxfiied opened this issue Sep 3, 2023 · 3 comments · May be fixed by #3728
Labels
bug:unconfirmed Someone works on identifying the issue

Comments

@blackxfiied
Copy link

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

  • OS [e. g. "Ubuntu"]:

Additional information

No response

@blackxfiied blackxfiied added the bug:unconfirmed Someone works on identifying the issue label Sep 3, 2023
@flavioislima flavioislima changed the title compatibility_layers.ts [macOS] Alternative way to find Wine and GPTK binaries Sep 13, 2023
@flavioislima
Copy link
Member

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
Copy link

vvuk commented Feb 8, 2024

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
Copy link
Author

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?

WangEdward added a commit to WangEdward/HeroicGamesLauncher that referenced this issue Apr 28, 2024
If user disabled spotlight, `mdfind` will not work.
Fixes Heroic-Games-Launcher#3025
@WangEdward WangEdward linked a pull request Apr 28, 2024 that will close this issue
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug:unconfirmed Someone works on identifying the issue
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants