From df7fcb829a739e0186d053d8304f5fbd15509766 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 28 Jun 2026 22:30:05 +0200 Subject: [PATCH] Add macOS CrossOver Steam detection --- gameScanner.py | 92 ++++++++++++++++++++++++++++++- higurashiInstaller.py | 8 ++- installConfiguration.py | 2 +- installerTests/testGameScanner.py | 92 +++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 installerTests/testGameScanner.py diff --git a/gameScanner.py b/gameScanner.py index cb60b803..16b42afd 100644 --- a/gameScanner.py +++ b/gameScanner.py @@ -17,6 +17,39 @@ except ImportError: pass # Just needed for pycharm comments +def _decodeVDFPath(vdfPath): + # type: (str) -> str + """ + Decode the small amount of escaping Steam uses for paths in VDF files. + """ + return vdfPath.replace("\\\\", "\\") + +def _winePathToHostPath(winePath, bottlePath): + # type: (str, str) -> Optional[str] + """ + Convert a Windows path inside a Wine/CrossOver bottle to a host path. + + For example: + C:\\Program Files (x86)\\Steam -> /drive_c/Program Files (x86)/Steam + """ + winePath = _decodeVDFPath(winePath) + match = re.match(r'^([a-zA-Z]):[\\/]*(.*)$', winePath) + if not match: + return os.path.realpath(os.path.expanduser(winePath)) + + driveLetter = match.group(1).lower() + relativePath = match.group(2).replace("\\", "/") + + dosDevicePath = os.path.join(bottlePath, "dosdevices", "{}:".format(driveLetter)) + if os.path.exists(dosDevicePath): + hostDrivePath = os.path.realpath(dosDevicePath) + elif driveLetter == 'c': + hostDrivePath = os.path.join(bottlePath, "drive_c") + else: + return None + + return os.path.realpath(os.path.join(hostDrivePath, relativePath)) + def findAdditionalSteamLibraries(mainSteamPath): # type: (str) -> List[str] r""" @@ -39,6 +72,59 @@ def findAdditionalSteamLibraries(mainSteamPath): traceback.print_exc() return [] +def findAdditionalSteamLibrariesInCrossOverBottle(mainSteamPath, bottlePath): + # type: (str, str) -> List[str] + r""" + Try to locate additional Steam libraries for a Windows Steam install inside + a CrossOver bottle, returning native macOS paths. + """ + try: + vdf = os.path.join(mainSteamPath, "steamapps", "libraryfolders.vdf") + if not os.path.exists(vdf): + return [] + + import io + baseInstallFolderRegex = re.compile(r'^\s*"path"\s+"([^"]+)"', re.MULTILINE) + with io.open(vdf, 'r', encoding='UTF-8') as vdfFile: + libraries = [] + for steamLibraryPath in baseInstallFolderRegex.findall(vdfFile.read()): + hostPath = _winePathToHostPath(steamLibraryPath, bottlePath) + if hostPath and os.path.isdir(os.path.join(hostPath, "steamapps")): + libraries.append(hostPath) + return libraries + except: + traceback.print_exc() + return [] + +def findPossibleCrossOverSteamPathsMac(crossoverBottlesPath=None): + # type: (Optional[str]) -> List[str] + r""" + Find Windows Steam installs inside CrossOver bottles on macOS. + Returns Steam root paths, not the steamapps/common folders. + """ + if crossoverBottlesPath is None: + crossoverBottlesPath = os.path.expanduser("~/Library/Application Support/CrossOver/Bottles") + + if not os.path.isdir(crossoverBottlesPath): + return [] + + allSteamPaths = [] + for bottlePath in glob.glob(os.path.join(crossoverBottlesPath, "*")): + if not os.path.isdir(bottlePath): + continue + + candidateSteamPaths = [ + os.path.join(bottlePath, "drive_c", "Program Files (x86)", "Steam"), + os.path.join(bottlePath, "drive_c", "Program Files", "Steam") + ] + + for steamPath in candidateSteamPaths: + if os.path.isdir(os.path.join(steamPath, "steamapps")): + allSteamPaths.append(os.path.realpath(steamPath)) + allSteamPaths += findAdditionalSteamLibrariesInCrossOverBottle(steamPath, bottlePath) + + return deDuplicatePaths(allSteamPaths) + def getSecondarySteamPaths(baseSteamPaths): # type: (List[str]) -> List[str] r""" @@ -170,6 +256,10 @@ def getMaybeGamePaths(): if common.Globals.IS_MAC: hardCodedGameContainingPaths.append("~/Library/Application Support/Steam/steamapps/common") hardCodedGameContainingPaths.append("~/GOG Games") # Not sure if this is correct for MacOS + hardCodedGameContainingPaths += [ + os.path.join(steamPath, "steamapps", "common") + for steamPath in findPossibleCrossOverSteamPathsMac() + ] if common.Globals.IS_WINDOWS: hardCodedGameContainingPaths.append("c:/games/Mangagamer") hardCodedGameContainingPaths.append("c:/GOG Games") @@ -184,7 +274,7 @@ def getMaybeGamePaths(): # Try to find secondary steam folders. Need to remove the 'steamapps/common' part of path to get base steam path try: - baseHardCodedSteamPaths = [os.path.split(os.path.split(p)[0])[0] for p in hardCodedGameContainingPaths if 'steam' in p.lower()] + baseHardCodedSteamPaths = [os.path.split(os.path.split(p)[0])[0] for p in hardCodedGameContainingPaths if 'steam' in p.lower() and 'crossover/bottles' not in p.lower()] hardCodedGameContainingPaths += [os.path.realpath(os.path.join(p, "steamapps", "common")) for p in getSecondarySteamPaths(baseHardCodedSteamPaths)] except: traceback.print_exc() diff --git a/higurashiInstaller.py b/higurashiInstaller.py index 68c0e108..a9aade01 100644 --- a/higurashiInstaller.py +++ b/higurashiInstaller.py @@ -335,7 +335,7 @@ def _applyLanguageSpecificSharedAssets(self, folderToApply): osString = common.Globals.OS_STRING if self.isWine: osString = "windows" - print("Language Patch UI: Proton/Wine detected! Forcing install of Windows sharedassets0.assets.") + print("Language Patch UI: Proton/Wine/CrossOver detected! Forcing install of Windows sharedassets0.assets.") # TODO: use the sharedassets0.assets.backup to determine store name? # For now, only differentiate steam/non-steam @@ -498,8 +498,10 @@ def saveFileVersionInfoFinished(self, forcedSaveFolder=None): def main(fullInstallConfiguration): # type: (installConfiguration.FullInstallConfiguration) -> None - if common.Globals.IS_LINUX: - print("Linux Compatibility Layer: {}".format("YES: Using Wine or Proton" if fullInstallConfiguration.isWine else "no: Using Native")) + if fullInstallConfiguration.isWine: + print("Compatibility Layer: YES: Using Wine, Proton, or CrossOver") + elif common.Globals.IS_LINUX: + print("Linux Compatibility Layer: no: Using Native") isVoiceOnly = fullInstallConfiguration.subModConfig.subModName == 'voice-only' if isVoiceOnly: diff --git a/installConfiguration.py b/installConfiguration.py index 5acc3534..11a92f88 100644 --- a/installConfiguration.py +++ b/installConfiguration.py @@ -95,7 +95,7 @@ def __init__(self, subModConfig, path, isSteam): self.useIPV6 = False self.unityVersion = None - if self.subModConfig.family == "higurashi" and common.Globals.IS_LINUX and higurashiWindowsExecutableExists(self.subModConfig.dataName, self.installPath): + if self.subModConfig.family == "higurashi" and (not common.Globals.IS_WINDOWS) and higurashiWindowsExecutableExists(self.subModConfig.dataName, self.installPath): self.isWine = True else: self.isWine = False diff --git a/installerTests/testGameScanner.py b/installerTests/testGameScanner.py new file mode 100644 index 00000000..509e9d6a --- /dev/null +++ b/installerTests/testGameScanner.py @@ -0,0 +1,92 @@ +import os +import shutil +import tempfile +import unittest + +import common +import gameScanner +import installConfiguration + + +class DummySubModConfig: + autodetect = True + family = "higurashi" + dataName = "HigurashiEp08_Data" + identifiers = ["HigurashiEp08_Data"] + modName = "Matsuribayashi Ch.8" + + +class TestCrossOverGameScanner(unittest.TestCase): + def setUp(self): + self.tempDir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tempDir) + + def test_finds_windows_steam_in_crossover_bottle(self): + bottlesPath = os.path.join(self.tempDir, "Bottles") + bottlePath = os.path.join(bottlesPath, "Steam") + steamPath = os.path.join(bottlePath, "drive_c", "Program Files (x86)", "Steam") + secondarySteamPath = os.path.join(bottlePath, "drive_c", "SteamLibrary") + commonPath = os.path.join(steamPath, "steamapps", "common") + secondaryCommonPath = os.path.join(secondarySteamPath, "steamapps", "common") + os.makedirs(commonPath) + os.makedirs(secondaryCommonPath) + + libraryFoldersPath = os.path.join(steamPath, "steamapps", "libraryfolders.vdf") + with open(libraryFoldersPath, "w") as libraryFoldersFile: + libraryFoldersFile.write( + '"libraryfolders"\n' + '{\n' + '\t"0"\n' + '\t{\n' + '\t\t"path"\t\t"C:\\\\Program Files (x86)\\\\Steam"\n' + '\t}\n' + '\t"1"\n' + '\t{\n' + '\t\t"path"\t\t"C:\\\\SteamLibrary"\n' + '\t}\n' + '}\n' + ) + + self.assertEqual( + gameScanner.findPossibleCrossOverSteamPathsMac(bottlesPath), + [os.path.realpath(steamPath), os.path.realpath(secondarySteamPath)] + ) + + def test_scan_marks_crossover_higurashi_install_as_wine(self): + gamePath = os.path.join(self.tempDir, "Higurashi When They Cry Hou - Ch.8 Matsuribayashi") + os.makedirs(os.path.join(gamePath, "HigurashiEp08_Data")) + open(os.path.join(gamePath, "HigurashiEp08.exe"), "w").close() + open(os.path.join(gamePath, "steam_api.dll"), "w").close() + + originalIsWindows = common.Globals.IS_WINDOWS + try: + common.Globals.IS_WINDOWS = False + fullConfigs, partiallyUninstalledPaths = gameScanner.scanForFullInstallConfigs( + [DummySubModConfig()], + possiblePaths=[gamePath] + ) + self.assertEqual(partiallyUninstalledPaths, []) + self.assertEqual(len(fullConfigs), 1) + self.assertTrue(fullConfigs[0].isWine) + self.assertTrue(fullConfigs[0].isSteam) + finally: + common.Globals.IS_WINDOWS = originalIsWindows + + def test_higurashi_windows_exe_is_wine_on_non_windows(self): + gamePath = os.path.join(self.tempDir, "Higurashi When They Cry Hou - Ch.8 Matsuribayashi") + os.makedirs(gamePath) + open(os.path.join(gamePath, "HigurashiEp08.exe"), "w").close() + + originalIsWindows = common.Globals.IS_WINDOWS + try: + common.Globals.IS_WINDOWS = False + fullConfig = installConfiguration.FullInstallConfiguration(DummySubModConfig(), gamePath, True) + self.assertTrue(fullConfig.isWine) + + common.Globals.IS_WINDOWS = True + fullConfig = installConfiguration.FullInstallConfiguration(DummySubModConfig(), gamePath, True) + self.assertFalse(fullConfig.isWine) + finally: + common.Globals.IS_WINDOWS = originalIsWindows