From ef3a6142ab315d47a770fccfb50d4df73302d6fa Mon Sep 17 00:00:00 2001 From: GB609 <39741460+GB609@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:52:12 +0000 Subject: [PATCH 01/10] update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f51689ec..bc758352 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ debian/minigalaxy.substvars debian/minigalaxy.debhelper.log debian/minigalaxy.postinst.debhelper debian/minigalaxy.prerm.debhelper +.vscode/settings.json From 0ad702af32f5f8353611eb6a2a90079a371eb280 Mon Sep 17 00:00:00 2001 From: GB609 <39741460+GB609@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:00:14 +0000 Subject: [PATCH 02/10] reworked wine installer * All installations now try to use a fixed folder 'c:\game' within the wineprefix as install target. * Wine installer tries unattended mode first and falls back to wizard dialog on failure The fallback will break if user changes the target directory in the wizard, but it's better then not working at all and the directory requirement can be documented. * Adjust launcher.py to use wine-internal paths whereever possible --- minigalaxy/installer.py | 79 +++++++++++++++++++++++++++-------------- minigalaxy/launcher.py | 20 ++++++++--- tests/test_launcher.py | 16 ++++++--- 3 files changed, 79 insertions(+), 36 deletions(-) diff --git a/minigalaxy/installer.py b/minigalaxy/installer.py index 375e3037..b8341ead 100644 --- a/minigalaxy/installer.py +++ b/minigalaxy/installer.py @@ -1,10 +1,12 @@ import sys import os import shutil +import shlex import subprocess import hashlib import textwrap +from minigalaxy.config import Config from minigalaxy.game import Game from minigalaxy.logger import logger from minigalaxy.translation import _ @@ -179,40 +181,65 @@ def extract_by_innoextract(installer: str, temp_dir: str, language: str, use_inn return err_msg -def extract_by_wine(game, installer, temp_dir): - err_msg = "" +def extract_by_wine(game, installer, temp_dir, config=Config()): # Set the prefix for Windows games prefix_dir = os.path.join(game.install_dir, "prefix") - """pick a letter that is unlikely to create collisions with the actual mount/hw setup: - wine creates links for mounted media and optical drives - this might lead to errors because wine knows 2 names for these - d: and d:: - (difference: : exposes directory, :: exposes the block device itself) - But they can't exist at the same time within a prefix. - Changing this letter is a temporary fix, the entire install method requires an overhaul in the long run""" - drive = os.path.join(prefix_dir, "dosdevices", "t:") + game_dir = os.path.join(prefix_dir, "dosdevices", 'c:', 'game') + wine_env = [ + "WINEPREFIX={}".format(prefix_dir), + "WINEDLLOVERRIDES=winemenubuilder.exe=d" + ] + wine_bin = shutil.which('wine') + if not os.path.exists(prefix_dir): os.makedirs(prefix_dir, mode=0o755) # Creating the prefix before modifying dosdevices - command = ["env", "WINEPREFIX={}".format(prefix_dir), "wine", "start", "/B", "cmd", "/C", "exit"] - stdout, stderr, exitcode = _exe_cmd(command) - if exitcode not in [0]: - print(stderr, file=sys.stderr) + command = ["env", *wine_env, wine_bin, "wineboot", "-u"] + if not try_wine_command(command): return _("Wineprefix creation failed.") - if os.path.exists(drive): - os.unlink(drive) - os.symlink(temp_dir, drive) - _dir = os.path.join(temp_dir, os.path.basename(game.install_dir)) # can't install to drive root + + # calculate relative link from prefix-internal folder to game.install_dir + # keeping it relative makes sure that the game can be moved around without stuff breaking + if not os.path.exists(game_dir): + # 'game' directory itself does not count + canonical_prefix = os.path.realpath(os.path.join(game_dir, '..')) + relative = os.path.relpath(game.install_dir, canonical_prefix) + os.symlink(relative, game_dir) # It's possible to set install dir as argument before installation - command = ["env", "WINEPREFIX={}".format(prefix_dir), "wine", installer, "/dir={}".format(_dir), "/VERYSILENT"] - stdout, stderr, exitcode = _exe_cmd(command) + installer_cmd_basic = [ + 'env', *wine_env, wine_bin, installer, + # use hard-coded directory name within wine, its just a backlink to game.install_dir + # this avoids issues with varying path and spaces + "/DIR=c:\\game", + # capture information for debugging during install + "/LOG=c:\\install.log", + ] + installer_args_full = [ + f"/LANG={config.lang}", + "/SAVEINF=c:\\setup.inf", + # installers can run very long, give at least a bit of visual feedback + '/SILENT' + ] + + success = try_wine_command(installer_cmd_basic + installer_args_full) + if not success: + print('Unattended install failed. Try install with wizard dialog.', file=sys.stderr) + try_wine_command(installer_cmd_basic) + if not success: + return _("Wine extraction failed.") + + return "" + + +def try_wine_command(command_arr): + print('trying to run wine command:', shlex.join(command_arr)) + stdout, stderr, exitcode = _exe_cmd(command_arr) + print(stdout) if exitcode not in [0]: - err_msg = _("Wine extraction failed.") - elif os.path.exists(drive): - """check for existence as a pure safety-measure in case - some power-user has pre-configured the letter we picked with double colon""" - os.unlink(drive) - os.symlink("../../..", drive) - return err_msg + print(stderr, file=sys.stderr) + return False + + return True def move_and_overwrite(game, temp_dir, use_innoextract): diff --git a/minigalaxy/launcher.py b/minigalaxy/launcher.py index 641db775..cd570297 100644 --- a/minigalaxy/launcher.py +++ b/minigalaxy/launcher.py @@ -4,6 +4,7 @@ import re import json import glob +import shlex import threading from minigalaxy.logger import logger @@ -100,8 +101,8 @@ def determine_launcher_type(files): def get_exe_cmd_with_var_command(game, exe_cmd): - var_list = game.get_info("variable").split() - command_list = game.get_info("command").split() + var_list = shlex.split(game.get_info("variable")) + command_list = shlex.split(game.get_info("command")) if var_list: if var_list[0] not in ["env"]: @@ -130,10 +131,11 @@ def get_windows_exe_cmd(game, files): if "category" in task and task["category"] == "game" and "path" in task: working_dir = task["workingDir"] if "workingDir" in task else "." path = task["path"] - exe_cmd = [get_wine_path(game), "start", "/b", "/wait", "/d", working_dir, - path] + exe_cmd = [get_wine_path(game), "start", "/b", "/wait", + "/d", f'c:\\game\\{working_dir}', + f'c:\\game\\{path}'] if "arguments" in task: - exe_cmd += task["arguments"].split(" ") + exe_cmd += shlex.split(task["arguments"]) break if exe_cmd == [""]: # in case no goggame info file was found @@ -142,6 +144,14 @@ def get_windows_exe_cmd(game, files): filename = os.path.splitext(os.path.basename(executables[0]))[0] + '.exe' exe_cmd = [get_wine_path(game), filename] + # Backwards compatibility with windows games installed before installer fixes. + # Will not fix games requiring registry keys, since the paths will already + # be borked through the old installer. + gamelink = os.path.join(prefix, 'dosdevices', 'c:', 'game') + if not os.path.exists(gamelink): + os.makedirs(os.path.join(prefix, 'dosdevices', 'c:')) + os.symlink('../..', gamelink) + return exe_cmd diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 2663f026..114dcaaa 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -41,18 +41,21 @@ def test5_determine_launcher_type(self): obs = launcher.determine_launcher_type(files) self.assertEqual(exp, obs) + @mock.patch('os.path.exists') @mock.patch('glob.glob') - def test1_get_windows_exe_cmd(self, mock_glob): + def test1_get_windows_exe_cmd(self, mock_glob, mock_exists): mock_glob.return_value = ["/test/install/dir/start.exe", "/test/install/dir/unins000.exe"] + mock_exists.return_value = True files = ['thumbnail.jpg', 'docs', 'support', 'game', 'minigalaxy-dlc.json', 'start.exe', 'unins000.exe'] game = Game("Test Game", install_dir="/test/install/dir") exp = ["wine", "start.exe"] obs = launcher.get_windows_exe_cmd(game, files) self.assertEqual(exp, obs) + @mock.patch('os.path.exists') @mock.patch('builtins.open', new_callable=mock_open, read_data="") @mock.patch('os.chdir') - def test2_get_windows_exe_cmd(self, mock_os_chdir, mo): + def test2_get_windows_exe_cmd(self, mock_os_chdir, mo, mock_exists): goggame_1414471894_info_content = """{ "buildId": "53350324452482937", "clientId": "53185732904249211", @@ -101,16 +104,18 @@ def test2_get_windows_exe_cmd(self, mock_os_chdir, mo): }""" handlers = (mock_open(read_data=goggame_1414471894_info_content).return_value, mock_open(read_data=goggame_1407287452_info_content).return_value) mo.side_effect = handlers + mock_exists.return_value = True files = ['thumbnail.jpg', 'docs', 'support', 'game', 'minigalaxy-dlc.json', 'MetroExodus.exe', 'unins000.exe', 'goggame-1407287452.info', 'goggame-1414471894.info'] game = Game("Test Game", install_dir="/test/install/dir") - exp = ['wine', 'start', '/b', '/wait', '/d', '.', 'MetroExodus.exe'] + exp = ['wine', 'start', '/b', '/wait', '/d', 'c:\\game\\.', 'c:\\game\\MetroExodus.exe'] obs = launcher.get_windows_exe_cmd(game, files) self.assertEqual(exp, obs) + @mock.patch('os.path.exists') @mock.patch('builtins.open', new_callable=mock_open, read_data="") @mock.patch('os.chdir') - def test3_get_windows_exe_cmd(self, mock_os_chdir, mo): + def test3_get_windows_exe_cmd(self, mock_os_chdir, mo, mock_exists): goggame_1207658919_info_content = """{ "buildId": "52095557858882770", "clientId": "49843178982252086", @@ -177,6 +182,7 @@ def test3_get_windows_exe_cmd(self, mock_os_chdir, mo): "version": 1 }""" mo.side_effect = (mock_open(read_data=goggame_1207658919_info_content).return_value,) + mock_exists.return_value = True files = ['goggame-1207658919.script', 'DOSBOX', 'thumbnail.jpg', 'game.gog', 'unins000.dat', 'webcache.zip', 'EULA.txt', 'Music', 'dosboxRayman_single.conf', 'Rayman', 'unins000.exe', 'support.ico', 'prefix', 'goggame-1207658919.info', 'Manual.pdf', 'gog.ico', 'unins000.msg', 'goggame-1207658919.hashdb', @@ -184,7 +190,7 @@ def test3_get_windows_exe_cmd(self, mock_os_chdir, mo): 'goggame-1207658919.ico', 'goglog.ini', 'Launch Rayman Forever.lnk', 'cloud_saves', 'thumbnail_196.jpg'] game = Game("Test Game", install_dir="/test/install/dir") - exp = ['wine', 'start', '/b', '/wait', '/d', 'DOSBOX', 'DOSBOX\\dosbox.exe', '-conf', '"..\\dosboxRayman.conf"', + exp = ['wine', 'start', '/b', '/wait', '/d', 'c:\\game\\DOSBOX', 'c:\\game\\DOSBOX\\dosbox.exe', '-conf', '"..\\dosboxRayman.conf"', '-conf', '"..\\dosboxRayman_single.conf"', '-noconsole', '-c', '"exit"'] obs = launcher.get_windows_exe_cmd(game, files) self.assertEqual(exp, obs) From b7a1b53b46381c91192c6d98675e6d8f58d16279 Mon Sep 17 00:00:00 2001 From: GB609 <39741460+GB609@users.noreply.github.com> Date: Mon, 2 Dec 2024 20:58:51 +0000 Subject: [PATCH 03/10] read output line-by-line use env for wine when launching --- minigalaxy/installer.py | 39 ++++++++++++++++++++++++++++++--------- minigalaxy/launcher.py | 4 ++-- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/minigalaxy/installer.py b/minigalaxy/installer.py index b8341ead..c0a3ee2c 100644 --- a/minigalaxy/installer.py +++ b/minigalaxy/installer.py @@ -186,7 +186,7 @@ def extract_by_wine(game, installer, temp_dir, config=Config()): prefix_dir = os.path.join(game.install_dir, "prefix") game_dir = os.path.join(prefix_dir, "dosdevices", 'c:', 'game') wine_env = [ - "WINEPREFIX={}".format(prefix_dir), + f"WINEPREFIX={prefix_dir}", "WINEDLLOVERRIDES=winemenubuilder.exe=d" ] wine_bin = shutil.which('wine') @@ -224,7 +224,8 @@ def extract_by_wine(game, installer, temp_dir, config=Config()): success = try_wine_command(installer_cmd_basic + installer_args_full) if not success: print('Unattended install failed. Try install with wizard dialog.', file=sys.stderr) - try_wine_command(installer_cmd_basic) + success = try_wine_command(installer_cmd_basic) + if not success: return _("Wine extraction failed.") @@ -233,7 +234,7 @@ def extract_by_wine(game, installer, temp_dir, config=Config()): def try_wine_command(command_arr): print('trying to run wine command:', shlex.join(command_arr)) - stdout, stderr, exitcode = _exe_cmd(command_arr) + stdout, stderr, exitcode = _exe_cmd(command_arr, False, True) print(stdout) if exitcode not in [0]: print(stderr, file=sys.stderr) @@ -376,12 +377,32 @@ def uninstall_game(game): os.remove(path_to_shortcut) -def _exe_cmd(cmd): - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - stdout, stderr = process.communicate() - stdout = stdout.decode("utf-8") - stderr = stderr.decode("utf-8") - return stdout, stderr, process.returncode +def _exe_cmd(cmd, capture_output=True, print_output=False): + print(f'executing command: {shlex.join(cmd)}') + std_out = [] + process = subprocess.Popen(cmd, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + bufsize=1, universal_newlines=True, encoding="utf-8") + rc = process.poll() + while rc is None: + out_line = process.stdout.readline() + if capture_output and out_line != '': + std_out.append(out_line) + + if print_output: + print(out_line, end='') + + rc = process.poll() + + print('command finished, read remaining output (if any)') + for line in process.stdout.readlines(): + std_out.append(line) + print('done') + + process.stdout.close() + + output = ''.join(std_out) + return output, output, rc def _mv(source_dir, target_dir): diff --git a/minigalaxy/launcher.py b/minigalaxy/launcher.py index cd570297..f671d191 100644 --- a/minigalaxy/launcher.py +++ b/minigalaxy/launcher.py @@ -80,6 +80,7 @@ def get_execute_command(game) -> list: exe_cmd.insert(1, "--dlsym") exe_cmd = get_exe_cmd_with_var_command(game, exe_cmd) logger.info("Launch command for %s: %s", game.name, " ".join(exe_cmd)) + print("Launch command for {}: {}".format(game.name, " ".join(exe_cmd))) return exe_cmd @@ -115,7 +116,6 @@ def get_exe_cmd_with_var_command(game, exe_cmd): def get_windows_exe_cmd(game, files): exe_cmd = [""] prefix = os.path.join(game.install_dir, "prefix") - os.environ["WINEPREFIX"] = prefix # Find game executable file for file in files: @@ -152,7 +152,7 @@ def get_windows_exe_cmd(game, files): os.makedirs(os.path.join(prefix, 'dosdevices', 'c:')) os.symlink('../..', gamelink) - return exe_cmd + return ['env', f'WINEPREFIX={prefix}'] + exe_cmd def get_dosbox_exe_cmd(game, files): From 6e239b96eca7b9e7c3a5e14a5b79e175b4ff685a Mon Sep 17 00:00:00 2001 From: GB609 <39741460+GB609@users.noreply.github.com> Date: Mon, 2 Dec 2024 22:04:58 +0000 Subject: [PATCH 04/10] fix shortcut creation and launch commands --- minigalaxy/installer.py | 20 ++++++------ minigalaxy/launcher.py | 21 +++++-------- tests/test_installer.py | 70 +++++++++++++++-------------------------- tests/test_launcher.py | 14 +++++---- 4 files changed, 51 insertions(+), 74 deletions(-) diff --git a/minigalaxy/installer.py b/minigalaxy/installer.py index c0a3ee2c..6b567f05 100644 --- a/minigalaxy/installer.py +++ b/minigalaxy/installer.py @@ -221,8 +221,10 @@ def extract_by_wine(game, installer, temp_dir, config=Config()): '/SILENT' ] + #first try full, unattended install success = try_wine_command(installer_cmd_basic + installer_args_full) if not success: + #some games will reject the /SILENT flag. Open normal installer as fallback and hope for the best print('Unattended install failed. Try install with wizard dialog.', file=sys.stderr) success = try_wine_command(installer_cmd_basic) @@ -272,20 +274,13 @@ def copy_thumbnail(game): return error_message -def get_exec_line(game): - exe_cmd_list = get_execute_command(game) - for i in range(len(exe_cmd_list)): - exe_cmd_list[i] = exe_cmd_list[i].replace(" ", "\\ ") - return " ".join(exe_cmd_list) - - def create_applications_file(game): error_message = "" path_to_shortcut = os.path.join(APPLICATIONS_DIR, "{}.desktop".format(game.get_stripped_name(to_path=True))) - exe_cmd = get_exec_line(game) + exe_cmd = shlex.join(get_execute_command(game)) # Create desktop file definition desktop_context = { - "game_bin_path": os.path.join('"{}"'.format(game.install_dir.replace('"', '\\"')), exe_cmd), + "game_bin_path": exe_cmd, "game_name": game.name, "game_install_dir": game.install_dir, "game_icon_path": os.path.join(game.install_dir, 'support/icon.png') @@ -298,7 +293,8 @@ def create_applications_file(game): Exec={game_bin_path} Path={game_install_dir} Name={game_name} - Icon={game_icon_path}""".format(**desktop_context) + Icon={game_icon_path} + Category=Game""".format(**desktop_context) if not os.path.isfile(path_to_shortcut): try: with open(path_to_shortcut, 'w+') as desktop_file: @@ -378,7 +374,9 @@ def uninstall_game(game): def _exe_cmd(cmd, capture_output=True, print_output=False): - print(f'executing command: {shlex.join(cmd)}') + """Wine commands are very verbose. + We should consume the output in regularly to prevent buffers (and minigalaxy memory) from filling up""" + print(f'executing wine command: {shlex.join(cmd)}') std_out = [] process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, diff --git a/minigalaxy/launcher.py b/minigalaxy/launcher.py index f671d191..d555a53c 100644 --- a/minigalaxy/launcher.py +++ b/minigalaxy/launcher.py @@ -21,23 +21,17 @@ def get_wine_path(game): def config_game(game): prefix = os.path.join(game.install_dir, "prefix") - - os.environ["WINEPREFIX"] = prefix - subprocess.Popen([get_wine_path(game), 'winecfg']) + subprocess.Popen(['env', f'WINEPREFIX={prefix}', get_wine_path(game), 'winecfg']) def regedit_game(game): prefix = os.path.join(game.install_dir, "prefix") - - os.environ["WINEPREFIX"] = prefix - subprocess.Popen([get_wine_path(game), 'regedit']) + subprocess.Popen(['env', f'WINEPREFIX={prefix}', get_wine_path(game), 'regedit']) def winetricks_game(game): prefix = os.path.join(game.install_dir, "prefix") - - os.environ["WINEPREFIX"] = prefix - subprocess.Popen(['winetricks']) + subprocess.Popen(['env', f'WINEPREFIX={prefix}', 'winetricks']) def start_game(game): @@ -61,7 +55,7 @@ def get_execute_command(game) -> list: files = os.listdir(game.install_dir) launcher_type = determine_launcher_type(files) if launcher_type in ["start_script", "wine"]: - exe_cmd = get_start_script_exe_cmd() + exe_cmd = get_start_script_exe_cmd(game) elif launcher_type == "windows": exe_cmd = get_windows_exe_cmd(game, files) elif launcher_type == "dosbox": @@ -80,7 +74,6 @@ def get_execute_command(game) -> list: exe_cmd.insert(1, "--dlsym") exe_cmd = get_exe_cmd_with_var_command(game, exe_cmd) logger.info("Launch command for %s: %s", game.name, " ".join(exe_cmd)) - print("Launch command for {}: {}".format(game.name, " ".join(exe_cmd))) return exe_cmd @@ -108,6 +101,8 @@ def get_exe_cmd_with_var_command(game, exe_cmd): if var_list: if var_list[0] not in ["env"]: var_list.insert(0, "env") + if 'env' == exe_cmd[0]: + exe_cmd = exe_cmd[1:] exe_cmd = var_list + exe_cmd + command_list return exe_cmd @@ -177,8 +172,8 @@ def get_scummvm_exe_cmd(game, files): return ["scummvm", "-c", scummvm_config] -def get_start_script_exe_cmd(): - return ["./start.sh"] +def get_start_script_exe_cmd(game): + return [os.path.join(game.install_dir, "start.sh")] def get_final_resort_exe_cmd(game, files): diff --git a/tests/test_installer.py b/tests/test_installer.py index 8e6cb769..9152de39 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -60,8 +60,8 @@ def test2_verify_installer_integrity(self, mock_listdir, mock_hash, mock_is_file def test1_extract_installer(self, mock_subprocess, mock_listdir, mock_is_file): """[scenario: linux installer, unpack success]""" mock_is_file.return_value = True - mock_subprocess().returncode = 0 - mock_subprocess().communicate.return_value = [b"stdout", b"stderr"] + mock_subprocess().poll.return_value = 0 + mock_subprocess().stdout.readlines.return_value = ["\n"] mock_listdir.return_value = ["object1", "object2"] game = Game("Beneath A Steel Sky", install_dir="/home/makson/GOG Games/Beneath a Steel Sky") installer_path = "/home/makson/.cache/minigalaxy/download/Beneath a Steel Sky/beneath_a_steel_sky_en_gog_2_20150.sh" @@ -76,8 +76,8 @@ def test1_extract_installer(self, mock_subprocess, mock_listdir, mock_is_file): def test2_extract_installer(self, mock_subprocess, mock_listdir, mock_is_file): """[scenario: linux installer, unpack failed]""" mock_is_file.return_value = True - mock_subprocess().returncode = 2 - mock_subprocess().communicate.return_value = [b"stdout", b"stderr"] + mock_subprocess().poll.return_value = 2 + mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] mock_listdir.return_value = ["object1", "object2"] game = Game("Beneath A Steel Sky", install_dir="/home/makson/GOG Games/Beneath a Steel Sky") installer_path = "/home/makson/.cache/minigalaxy/download/Beneath a Steel Sky/beneath_a_steel_sky_en_gog_2_20150.sh" @@ -91,8 +91,8 @@ def test2_extract_installer(self, mock_subprocess, mock_listdir, mock_is_file): def test3_extract_installer(self, mock_which, mock_subprocess): """[scenario: innoextract, unpack success]""" mock_which.return_value = True - mock_subprocess().returncode = 0 - mock_subprocess().communicate.return_value = [b"stdout", b"stderr"] + mock_subprocess().poll.return_value = 0 + mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") installer_path = "/home/makson/.cache/minigalaxy/download/Absolute Drift/setup_absolute_drift_1.0f_(64bit)_(47863).exe" temp_dir = "/home/makson/.cache/minigalaxy/extract/1136126792" @@ -105,8 +105,8 @@ def test3_extract_installer(self, mock_which, mock_subprocess): @mock.patch('subprocess.Popen') def test_extract_linux(self, mock_subprocess, mock_listdir, mock_is_file): mock_is_file.return_value = True - mock_subprocess().returncode = 1 - mock_subprocess().communicate.return_value = [b"stdout", b"(attempting to process anyway)"] + mock_subprocess().poll.return_value = 1 + mock_subprocess().stdout.readlines.return_value = ["stdout", "(attempting to process anyway)"] mock_listdir.return_value = ["object1", "object2"] installer_path = "/home/makson/.cache/minigalaxy/download/Beneath a Steel Sky/beneath_a_steel_sky_en_gog_2_20150.sh" temp_dir = "/home/makson/.cache/minigalaxy/extract/1207658695" @@ -117,8 +117,8 @@ def test_extract_linux(self, mock_subprocess, mock_listdir, mock_is_file): @mock.patch('subprocess.Popen') def test_extract_windows(self, mock_subprocess): """[scenario: innoextract, unpack success]""" - mock_subprocess().returncode = 0 - mock_subprocess().communicate.return_value = [b"stdout", b"stderr"] + mock_subprocess().poll.return_value = 0 + mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") installer_path = "/home/makson/.cache/minigalaxy/download/Absolute Drift/setup_absolute_drift_1.0f_(64bit)_(47863).exe" temp_dir = "/home/makson/.cache/minigalaxy/extract/1136126792" @@ -129,8 +129,8 @@ def test_extract_windows(self, mock_subprocess): @mock.patch('subprocess.Popen') def test1_extract_by_innoextract(self, mock_subprocess): """[scenario: success]""" - mock_subprocess().returncode = 0 - mock_subprocess().communicate.return_value = [b"stdout", b"stderr"] + mock_subprocess().poll.return_value = 0 + mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] installer_path = "/home/makson/.cache/minigalaxy/download/Absolute Drift/setup_absolute_drift_1.0f_(64bit)_(47863).exe" temp_dir = "/home/makson/.cache/minigalaxy/extract/1136126792" exp = "" @@ -148,8 +148,8 @@ def test2_extract_by_innoextract(self): @mock.patch('subprocess.Popen') def test3_extract_by_innoextract(self, mock_subprocess): """[scenario: unpack failed]""" - mock_subprocess().returncode = 1 - mock_subprocess().communicate.return_value = [b"stdout", b"stderr"] + mock_subprocess().poll.return_value = 1 + mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] installer_path = "/home/makson/.cache/minigalaxy/download/Absolute Drift/setup_absolute_drift_1.0f_(64bit)_(47863).exe" temp_dir = "/home/makson/.cache/minigalaxy/extract/1136126792" exp = "Innoextract extraction failed." @@ -158,13 +158,12 @@ def test3_extract_by_innoextract(self, mock_subprocess): @mock.patch('subprocess.Popen') @mock.patch("os.path.exists") - @mock.patch("os.unlink") @mock.patch("os.symlink") - def test1_extract_by_wine(self, mock_symlink, mock_unlink, mock_path_exists, mock_subprocess): + def test1_extract_by_wine(self, mock_symlink, mock_path_exists, mock_subprocess): """[scenario: success]""" mock_path_exists.return_value = True - mock_subprocess().returncode = 0 - mock_subprocess().communicate.return_value = [b"stdout", b"stderr"] + mock_subprocess().poll.return_value = 0 + mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") installer_path = "/home/makson/.cache/minigalaxy/download/Absolute Drift/setup_absolute_drift_1.0f_(64bit)_(47863).exe" temp_dir = "/home/makson/.cache/minigalaxy/extract/1136126792" @@ -179,8 +178,8 @@ def test1_extract_by_wine(self, mock_symlink, mock_unlink, mock_path_exists, moc def test2_extract_by_wine(self, mock_symlink, mock_unlink, mock_path_exists, mock_subprocess): """[scenario: install failed]""" mock_path_exists.return_value = True - mock_subprocess().returncode = 1 - mock_subprocess().communicate.return_value = [b"stdout", b"stderr"] + mock_subprocess().poll.return_value = 1 + mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") installer_path = "/home/makson/.cache/minigalaxy/download/Absolute Drift/setup_absolute_drift_1.0f_(64bit)_(47863).exe" temp_dir = "/home/makson/.cache/minigalaxy/extract/1136126792" @@ -192,8 +191,8 @@ def test2_extract_by_wine(self, mock_symlink, mock_unlink, mock_path_exists, moc @mock.patch("os.path.isfile") def test1_postinstaller(self, mock_path_isfile, mock_subprocess): mock_path_isfile.return_value = False - mock_subprocess().returncode = 1 - mock_subprocess().communicate.return_value = [b"stdout", b"stderr"] + mock_subprocess().poll.return_value = 1 + mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift") exp = "" obs = installer.postinstaller(game) @@ -204,8 +203,8 @@ def test1_postinstaller(self, mock_path_isfile, mock_subprocess): @mock.patch("os.chmod") def test2_postinstaller(self, mock_chmod, mock_path_isfile, mock_subprocess): mock_path_isfile.return_value = True - mock_subprocess().returncode = 0 - mock_subprocess().communicate.return_value = [b"stdout", b"stderr"] + mock_subprocess().poll.return_value = 0 + mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift") exp = "" obs = installer.postinstaller(game) @@ -216,8 +215,8 @@ def test2_postinstaller(self, mock_chmod, mock_path_isfile, mock_subprocess): @mock.patch("os.chmod") def test3_postinstaller(self, mock_chmod, mock_path_isfile, mock_subprocess): mock_path_isfile.return_value = True - mock_subprocess().returncode = 1 - mock_subprocess().communicate.return_value = [b"stdout", b"stderr"] + mock_subprocess().poll.return_value = 1 + mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift") exp = "Postinstallation script failed: /home/makson/GOG Games/Absolute Drift/support/postinst.sh" obs = installer.postinstaller(game) @@ -281,29 +280,12 @@ def test_get_game_size_from_unzip(self, mock_subprocess): -------- ------- --- ------- 159236636 104883200 34% 189 files """ - mock_subprocess().communicate.return_value = [stdout, b"stderr"] + mock_subprocess().communicate.return_value = [stdout, "stderr"] installer_path = "/home/i/.cache/minigalaxy/download/Beneath a Steel Sky/beneath_a_steel_sky_en_gog_2_20150.sh" exp = 159236636 obs = installer.get_game_size_from_unzip(installer_path) self.assertEqual(exp, obs) - @mock.patch('shutil.which') - @mock.patch('os.listdir') - def test_get_exec_line(self, mock_list_dir, mock_which): - mock_which.return_value = True - - game1 = Game("Beneath A Steel Sky", install_dir="/home/test/GOG Games/Beneath a Steel Sky", platform="linux") - mock_list_dir.return_value = ["data", "docs", "scummvm", "support", "beneath.ini", "gameinfo", "start.sh"] - - result1 = installer.get_exec_line(game1) - self.assertEqual(result1, "scummvm -c beneath.ini") - - game2 = Game("Blocks That Matter", install_dir="/home/test/GOG Games/Blocks That Matter", platform="linux") - mock_list_dir.return_value = ["data", "docs", "support", "gameinfo", "start.sh"] - - result2 = installer.get_exec_line(game2) - self.assertEqual(result2, "./start.sh") - @mock.patch('os.path.getsize') @mock.patch('os.listdir') @mock.patch('os.path.isdir') diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 114dcaaa..966ceb7e 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -48,7 +48,7 @@ def test1_get_windows_exe_cmd(self, mock_glob, mock_exists): mock_exists.return_value = True files = ['thumbnail.jpg', 'docs', 'support', 'game', 'minigalaxy-dlc.json', 'start.exe', 'unins000.exe'] game = Game("Test Game", install_dir="/test/install/dir") - exp = ["wine", "start.exe"] + exp = ['env', 'WINEPREFIX=/test/install/dir/prefix', "wine", "start.exe"] obs = launcher.get_windows_exe_cmd(game, files) self.assertEqual(exp, obs) @@ -108,7 +108,7 @@ def test2_get_windows_exe_cmd(self, mock_os_chdir, mo, mock_exists): files = ['thumbnail.jpg', 'docs', 'support', 'game', 'minigalaxy-dlc.json', 'MetroExodus.exe', 'unins000.exe', 'goggame-1407287452.info', 'goggame-1414471894.info'] game = Game("Test Game", install_dir="/test/install/dir") - exp = ['wine', 'start', '/b', '/wait', '/d', 'c:\\game\\.', 'c:\\game\\MetroExodus.exe'] + exp = ['env', 'WINEPREFIX=/test/install/dir/prefix', 'wine', 'start', '/b', '/wait', '/d', 'c:\\game\\.', 'c:\\game\\MetroExodus.exe'] obs = launcher.get_windows_exe_cmd(game, files) self.assertEqual(exp, obs) @@ -190,8 +190,9 @@ def test3_get_windows_exe_cmd(self, mock_os_chdir, mo, mock_exists): 'goggame-1207658919.ico', 'goglog.ini', 'Launch Rayman Forever.lnk', 'cloud_saves', 'thumbnail_196.jpg'] game = Game("Test Game", install_dir="/test/install/dir") - exp = ['wine', 'start', '/b', '/wait', '/d', 'c:\\game\\DOSBOX', 'c:\\game\\DOSBOX\\dosbox.exe', '-conf', '"..\\dosboxRayman.conf"', - '-conf', '"..\\dosboxRayman_single.conf"', '-noconsole', '-c', '"exit"'] + exp = ['env', 'WINEPREFIX=/test/install/dir/prefix', + 'wine', 'start', '/b', '/wait', '/d', 'c:\\game\\DOSBOX', 'c:\\game\\DOSBOX\\dosbox.exe', '-conf', '..\\dosboxRayman.conf', + '-conf', '"..\\dosboxRayman_single.conf"', '-noconsole', '-c', 'exit'] obs = launcher.get_windows_exe_cmd(game, files) self.assertEqual(exp, obs) @@ -210,8 +211,9 @@ def test_get_scummvm_exe_cmd(self): self.assertEqual(exp, obs) def test_get_start_script_exe_cmd(self): - exp = ["./start.sh"] - obs = launcher.get_start_script_exe_cmd() + game = Game("Test Game", install_dir="/test/install/dir") + exp = ["/test/install/dir/start.sh"] + obs = launcher.get_start_script_exe_cmd(game) self.assertEqual(exp, obs) @mock.patch('os.getcwd') From ce6886466568c7015572cb12cac2dcbfa1b370a5 Mon Sep 17 00:00:00 2001 From: GB609 <39741460+GB609@users.noreply.github.com> Date: Mon, 2 Dec 2024 22:43:56 +0000 Subject: [PATCH 05/10] fix tests and linting --- minigalaxy/installer.py | 11 +++++------ tests/test_launcher.py | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/minigalaxy/installer.py b/minigalaxy/installer.py index 6b567f05..5f642fdd 100644 --- a/minigalaxy/installer.py +++ b/minigalaxy/installer.py @@ -221,10 +221,11 @@ def extract_by_wine(game, installer, temp_dir, config=Config()): '/SILENT' ] - #first try full, unattended install + # first, try full unattended install success = try_wine_command(installer_cmd_basic + installer_args_full) if not success: - #some games will reject the /SILENT flag. Open normal installer as fallback and hope for the best + # some games will reject the /SILENT flag. + # Open normal installer as fallback and hope for the best print('Unattended install failed. Try install with wizard dialog.', file=sys.stderr) success = try_wine_command(installer_cmd_basic) @@ -422,11 +423,9 @@ def _mv(source_dir, target_dir): def lang_install(installer: str, language: str): languages = [] arg = "" - process = subprocess.Popen(["innoextract", installer, "--list-languages"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - stdout, stderr = process.communicate() - output = stdout.decode("utf-8") + stdout, stderr, ret_code = _exe_cmd(["innoextract", installer, "--list-languages"]) - for line in output.split('\n'): + for line in stdout.split('\n'): if not line.startswith(' -'): continue languages.append(line[3:]) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 966ceb7e..62cac009 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -192,7 +192,7 @@ def test3_get_windows_exe_cmd(self, mock_os_chdir, mo, mock_exists): game = Game("Test Game", install_dir="/test/install/dir") exp = ['env', 'WINEPREFIX=/test/install/dir/prefix', 'wine', 'start', '/b', '/wait', '/d', 'c:\\game\\DOSBOX', 'c:\\game\\DOSBOX\\dosbox.exe', '-conf', '..\\dosboxRayman.conf', - '-conf', '"..\\dosboxRayman_single.conf"', '-noconsole', '-c', 'exit'] + '-conf', '..\\dosboxRayman_single.conf', '-noconsole', '-c', 'exit'] obs = launcher.get_windows_exe_cmd(game, files) self.assertEqual(exp, obs) From 89599cfb658bfcac80b7da1fce40998575ae6bcc Mon Sep 17 00:00:00 2001 From: GB609 <39741460+GB609@users.noreply.github.com> Date: Mon, 2 Dec 2024 23:15:42 +0000 Subject: [PATCH 06/10] update changelog, some important change notes --- CHANGELOG.md | 4 +++- README.md | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index caf2a138..0aaa0a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **1.3.2** -- Fix issue with windows install via wine on systems with optical drives (thanks to GB609) +- Completely reworked windows wine installation. This should solve a lot of problems with failing game installs (thanks to GB609) +- Variables and arguments in game settings can now contain blanks when quoted shell-style (thanks to GB609) +- Minigalaxy will now create working Desktop Shortcuts for wine games (thanks to GB609) **1.3.1** - Fix Windows games with multiple parts not installing with wine diff --git a/README.md b/README.md index 84d5a41a..d38a883b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ In addition to that, Minigalaxy also allows you to: - Use the system's ScummVM or DOSBox installation - Install Windows games using Wine +### Backwards compatibility +Minigalaxy version 1.3.2 or higher changes some aspects of windows game installations through wine. +It will try to adapt already installed games to the new concept when launched through Minigalaxy. +However, this will *likely break games* that save some directory paths in the *windows registry*. +In that case, only a reinstallation will repair the game. +**Please make sure to backup any save games you might have within the game folder** + +The windows installer in wine uses a 2-step attempt to install games. +1. An unattended installer. +2. In case this fails, the regular installation wizard will open. It is mandatory not to change the +install directory 'c:\game' given in the wizard as this an elementary part of the wine fix! + ## Supported languages Currently, Minigalaxy can be displayed in the following languages: From b5a054e8271e010daf74801efe0aeffce3a69f1f Mon Sep 17 00:00:00 2001 From: GB609 <39741460+GB609@users.noreply.github.com> Date: Mon, 2 Dec 2024 23:17:23 +0000 Subject: [PATCH 07/10] readme formatting --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d38a883b..0564c0c8 100644 --- a/README.md +++ b/README.md @@ -26,15 +26,17 @@ In addition to that, Minigalaxy also allows you to: - Install Windows games using Wine ### Backwards compatibility -Minigalaxy version 1.3.2 or higher changes some aspects of windows game installations through wine. +Minigalaxy version 1.3.2 and higher change some aspects of windows game installations through wine. It will try to adapt already installed games to the new concept when launched through Minigalaxy. + However, this will *likely break games* that save some directory paths in the *windows registry*. In that case, only a reinstallation will repair the game. + **Please make sure to backup any save games you might have within the game folder** The windows installer in wine uses a 2-step attempt to install games. 1. An unattended installer. -2. In case this fails, the regular installation wizard will open. It is mandatory not to change the +2. In case this fails, the regular installation wizard will open. **Please do not change** the install directory 'c:\game' given in the wizard as this an elementary part of the wine fix! ## Supported languages From 22de6358e685f736c7adb96dbe14cbec4f9a71d6 Mon Sep 17 00:00:00 2001 From: Wouter Wijsman Date: Sun, 8 Dec 2024 13:18:05 +0100 Subject: [PATCH 08/10] Fix _exe_cmd in installer.py (#2) * Fix _exe_cmd in installer.py * Only print if print_output is on * Set stdout and stderr to non-blocking in exe_cmd function --- minigalaxy/installer.py | 54 +++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/minigalaxy/installer.py b/minigalaxy/installer.py index 5f642fdd..f259ca18 100644 --- a/minigalaxy/installer.py +++ b/minigalaxy/installer.py @@ -5,6 +5,7 @@ import subprocess import hashlib import textwrap +import time from minigalaxy.config import Config from minigalaxy.game import Game @@ -237,7 +238,7 @@ def extract_by_wine(game, installer, temp_dir, config=Config()): def try_wine_command(command_arr): print('trying to run wine command:', shlex.join(command_arr)) - stdout, stderr, exitcode = _exe_cmd(command_arr, False, True) + stdout, stderr, exitcode = _exe_cmd(command_arr, True) print(stdout) if exitcode not in [0]: print(stderr, file=sys.stderr) @@ -374,34 +375,35 @@ def uninstall_game(game): os.remove(path_to_shortcut) -def _exe_cmd(cmd, capture_output=True, print_output=False): - """Wine commands are very verbose. - We should consume the output in regularly to prevent buffers (and minigalaxy memory) from filling up""" - print(f'executing wine command: {shlex.join(cmd)}') - std_out = [] - process = subprocess.Popen(cmd, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - bufsize=1, universal_newlines=True, encoding="utf-8") - rc = process.poll() - while rc is None: - out_line = process.stdout.readline() - if capture_output and out_line != '': - std_out.append(out_line) - - if print_output: - print(out_line, end='') - - rc = process.poll() - - print('command finished, read remaining output (if any)') - for line in process.stdout.readlines(): - std_out.append(line) - print('done') +def _exe_cmd(cmd, print_output=False): + std_out = "" + std_err = "" + done = False + return_code = None + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True, encoding="utf-8" + ) + os.set_blocking(process.stdout.fileno(), False) + os.set_blocking(process.stderr.fileno(), False) + while not done: + if (return_code := process.poll()) is not None: + done = True + if data := process.stdout.readline(): + std_out += data + if print_output: + print(data, end='') + if data := process.stderr.readline(): + std_err += data + if print_output: + print(data, end='') + time.sleep(0.01) process.stdout.close() + process.stderr.close() - output = ''.join(std_out) - return output, output, rc + return std_out, std_err, return_code def _mv(source_dir, target_dir): From 2fde14442768b9891b4543a5b39a2073aef31214 Mon Sep 17 00:00:00 2001 From: GB609 <39741460+GB609@users.noreply.github.com> Date: Sun, 8 Dec 2024 10:22:47 +0000 Subject: [PATCH 09/10] fix review comments --- README.md | 9 ++---- minigalaxy/installer.py | 66 +++++++++++++++++++++-------------------- minigalaxy/launcher.py | 16 +++++++--- tests/test_installer.py | 33 ++++++++++++++------- 4 files changed, 70 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 0564c0c8..06495917 100644 --- a/README.md +++ b/README.md @@ -29,15 +29,10 @@ In addition to that, Minigalaxy also allows you to: Minigalaxy version 1.3.2 and higher change some aspects of windows game installations through wine. It will try to adapt already installed games to the new concept when launched through Minigalaxy. -However, this will *likely break games* that save some directory paths in the *windows registry*. -In that case, only a reinstallation will repair the game. - -**Please make sure to backup any save games you might have within the game folder** - -The windows installer in wine uses a 2-step attempt to install games. +The windows installer in wine now uses a 2-step attempt to install games. 1. An unattended installer. 2. In case this fails, the regular installation wizard will open. **Please do not change** the -install directory 'c:\game' given in the wizard as this an elementary part of the wine fix! +install directory 'c:\game' given in the wizard as this an elementary part of the wine fix. ## Supported languages diff --git a/minigalaxy/installer.py b/minigalaxy/installer.py index f259ca18..f2f80feb 100644 --- a/minigalaxy/installer.py +++ b/minigalaxy/installer.py @@ -11,7 +11,7 @@ from minigalaxy.game import Game from minigalaxy.logger import logger from minigalaxy.translation import _ -from minigalaxy.launcher import get_execute_command +from minigalaxy.launcher import get_execute_command, get_wine_path, wine_restore_game_link from minigalaxy.paths import CACHE_DIR, THUMBNAIL_DIR, APPLICATIONS_DIR @@ -58,7 +58,6 @@ def install_game( # noqa: C901 tmp_dir = "" logger.info("Installing {}".format(game.name)) try: - _use_innoextract = use_innoextract and bool(shutil.which('innoextract')) # single decision point if not error_message: error_message = verify_installer_integrity(game, installer) if not error_message: @@ -66,9 +65,9 @@ def install_game( # noqa: C901 if not error_message: error_message, tmp_dir = make_tmp_dir(game) if not error_message: - error_message = extract_installer(game, installer, tmp_dir, language, _use_innoextract) + error_message, installed_to_tmp = extract_installer(game, installer, tmp_dir, language) if not error_message: - error_message = move_and_overwrite(game, tmp_dir, _use_innoextract) + error_message = move_and_overwrite(game, tmp_dir, installed_to_tmp) if not error_message: error_message = copy_thumbnail(game) if not error_message and create_desktop_file: @@ -126,13 +125,12 @@ def make_tmp_dir(game): return error_message, temp_dir -def extract_installer(game: Game, installer: str, temp_dir: str, language: str, use_innoextract: bool): +def extract_installer(game: Game, installer: str, temp_dir: str, language: str): # Extract the installer if game.platform in ["linux"]: - err_msg = extract_linux(installer, temp_dir) + return extract_linux(installer, temp_dir) else: - err_msg = extract_windows(game, installer, temp_dir, language, use_innoextract) - return err_msg + return extract_windows(game, installer, language) def extract_linux(installer, temp_dir): @@ -144,14 +142,19 @@ def extract_linux(installer, temp_dir): err_msg = _("The installation of {} failed. Please try again.").format(installer) elif len(os.listdir(temp_dir)) == 0: err_msg = _("{} could not be unzipped.".format(installer)) - return err_msg + return err_msg, True -def extract_windows(game: Game, installer: str, temp_dir: str, language: str, use_innoextract: bool): - err_msg = extract_by_innoextract(installer, temp_dir, language, use_innoextract) - if err_msg: - err_msg = extract_by_wine(game, installer, temp_dir) - return err_msg +def extract_windows(game: Game, installer: str, language: str): + if shutil.which("innoextract"): + game_lang = lang_install(installer, language) + game_lang = game_lang.split('=')[1] # lang_install returns '--language=localeCode' + else: + game_lang = 'en-US' + + logger.info(f'use {game_lang} for installer') + + return extract_by_wine(game, installer, game_lang), False def extract_by_innoextract(installer: str, temp_dir: str, language: str, use_innoextract: bool): @@ -182,15 +185,14 @@ def extract_by_innoextract(installer: str, temp_dir: str, language: str, use_inn return err_msg -def extract_by_wine(game, installer, temp_dir, config=Config()): +def extract_by_wine(game, installer, game_lang, config=Config()): # Set the prefix for Windows games prefix_dir = os.path.join(game.install_dir, "prefix") - game_dir = os.path.join(prefix_dir, "dosdevices", 'c:', 'game') wine_env = [ f"WINEPREFIX={prefix_dir}", "WINEDLLOVERRIDES=winemenubuilder.exe=d" ] - wine_bin = shutil.which('wine') + wine_bin = get_wine_path(game) if not os.path.exists(prefix_dir): os.makedirs(prefix_dir, mode=0o755) @@ -199,13 +201,9 @@ def extract_by_wine(game, installer, temp_dir, config=Config()): if not try_wine_command(command): return _("Wineprefix creation failed.") - # calculate relative link from prefix-internal folder to game.install_dir + # calculate relative link prefix/c/game to game.install_dir # keeping it relative makes sure that the game can be moved around without stuff breaking - if not os.path.exists(game_dir): - # 'game' directory itself does not count - canonical_prefix = os.path.realpath(os.path.join(game_dir, '..')) - relative = os.path.relpath(game.install_dir, canonical_prefix) - os.symlink(relative, game_dir) + wine_restore_game_link(game) # It's possible to set install dir as argument before installation installer_cmd_basic = [ 'env', *wine_env, wine_bin, installer, @@ -219,13 +217,15 @@ def extract_by_wine(game, installer, temp_dir, config=Config()): f"/LANG={config.lang}", "/SAVEINF=c:\\setup.inf", # installers can run very long, give at least a bit of visual feedback - '/SILENT' + # by using /SILENT instead of /VERYSILENT + '/SP-', '/SILENT', '/NORESTART', '/SUPPRESSMSGBOXES' ] - # first, try full unattended install + # first, try full unattended install. success = try_wine_command(installer_cmd_basic + installer_args_full) if not success: - # some games will reject the /SILENT flag. + # some games will reject the /SILENT flag + # because they require the user to accept EULA at the beginning # Open normal installer as fallback and hope for the best print('Unattended install failed. Try install with wizard dialog.', file=sys.stderr) success = try_wine_command(installer_cmd_basic) @@ -239,7 +239,6 @@ def extract_by_wine(game, installer, temp_dir, config=Config()): def try_wine_command(command_arr): print('trying to run wine command:', shlex.join(command_arr)) stdout, stderr, exitcode = _exe_cmd(command_arr, True) - print(stdout) if exitcode not in [0]: print(stderr, file=sys.stderr) return False @@ -247,14 +246,17 @@ def try_wine_command(command_arr): return True -def move_and_overwrite(game, temp_dir, use_innoextract): +def move_and_overwrite(game, temp_dir, installed_to_tmp): # Copy the game files into the correct directory error_message = "" source_dir = (os.path.join(temp_dir, "data", "noarch") if game.platform == 'linux' else - temp_dir if use_innoextract else - os.path.join(temp_dir, os.path.basename(game.install_dir))) + temp_dir) target_dir = game.install_dir - _mv(source_dir, target_dir) + + if installed_to_tmp: + _mv(source_dir, target_dir) + else: + logger.info(f'installation of {game.name} did not use temporary directory - nothing to move') # Remove the temporary directory shutil.rmtree(temp_dir, ignore_errors=True) @@ -296,7 +298,7 @@ def create_applications_file(game): Path={game_install_dir} Name={game_name} Icon={game_icon_path} - Category=Game""".format(**desktop_context) + Categories=Game""".format(**desktop_context) if not os.path.isfile(path_to_shortcut): try: with open(path_to_shortcut, 'w+') as desktop_file: diff --git a/minigalaxy/launcher.py b/minigalaxy/launcher.py index d555a53c..790679bf 100644 --- a/minigalaxy/launcher.py +++ b/minigalaxy/launcher.py @@ -19,6 +19,17 @@ def get_wine_path(game): return binary_name +# should go into a separate file or into installer, but not possible ATM because +# it's a circular import otherwise +def wine_restore_game_link(game): + game_dir = os.path.join(game.install_dir, 'prefix', 'dosdevices', 'c:', 'game') + if not os.path.exists(game_dir): + # 'game' directory itself does not count + canonical_prefix = os.path.realpath(os.path.join(game_dir, '..')) + relative = os.path.relpath(game.install_dir, canonical_prefix) + os.symlink(relative, game_dir) + + def config_game(game): prefix = os.path.join(game.install_dir, "prefix") subprocess.Popen(['env', f'WINEPREFIX={prefix}', get_wine_path(game), 'winecfg']) @@ -142,10 +153,7 @@ def get_windows_exe_cmd(game, files): # Backwards compatibility with windows games installed before installer fixes. # Will not fix games requiring registry keys, since the paths will already # be borked through the old installer. - gamelink = os.path.join(prefix, 'dosdevices', 'c:', 'game') - if not os.path.exists(gamelink): - os.makedirs(os.path.join(prefix, 'dosdevices', 'c:')) - os.symlink('../..', gamelink) + wine_restore_game_link(game) return ['env', f'WINEPREFIX={prefix}'] + exe_cmd diff --git a/tests/test_installer.py b/tests/test_installer.py index 9152de39..68352eba 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -8,10 +8,10 @@ class Test(TestCase): - @mock.patch('shutil.which') - def test_install_game(self, mock_which): + @mock.patch('os.path.exists') + def test_install_game(self, mock_exists): """[scenario: unhandled error]""" - mock_which.side_effect = FileNotFoundError("Testing unhandled errors during install") + mock_exists.side_effect = FileNotFoundError("Testing unhandled errors during install") game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") exp = "Unhandled error." obs = installer.install_game(game, installer="", language="", install_dir="", keep_installers=False, create_desktop_file=True) @@ -67,7 +67,7 @@ def test1_extract_installer(self, mock_subprocess, mock_listdir, mock_is_file): installer_path = "/home/makson/.cache/minigalaxy/download/Beneath a Steel Sky/beneath_a_steel_sky_en_gog_2_20150.sh" temp_dir = "/home/makson/.cache/minigalaxy/extract/1207658695" exp = "" - obs = installer.extract_installer(game, installer_path, temp_dir, "en", use_innoextract=False) + obs, use_temp = installer.extract_installer(game, installer_path, temp_dir, "en") self.assertEqual(exp, obs) @mock.patch('os.path.exists') @@ -83,21 +83,28 @@ def test2_extract_installer(self, mock_subprocess, mock_listdir, mock_is_file): installer_path = "/home/makson/.cache/minigalaxy/download/Beneath a Steel Sky/beneath_a_steel_sky_en_gog_2_20150.sh" temp_dir = "/home/makson/.cache/minigalaxy/extract/1207658695" exp = "The installation of /home/makson/.cache/minigalaxy/download/Beneath a Steel Sky/beneath_a_steel_sky_en_gog_2_20150.sh failed. Please try again." - obs = installer.extract_installer(game, installer_path, temp_dir, "en", use_innoextract=False) + obs, use_temp = installer.extract_installer(game, installer_path, temp_dir, "en") self.assertEqual(exp, obs) + # TODO: Delete - innoextract not used for installation anymore + # test is only made not to fail, but it is pointless, as wine is called + # internally anyway + @mock.patch('minigalaxy.installer.try_wine_command') + @mock.patch('os.path.exists') @mock.patch('subprocess.Popen') @mock.patch('shutil.which') - def test3_extract_installer(self, mock_which, mock_subprocess): + def test3_extract_installer(self, mock_which, mock_subprocess, mock_exists, mock_cmd): """[scenario: innoextract, unpack success]""" mock_which.return_value = True mock_subprocess().poll.return_value = 0 - mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] + mock_subprocess().stdout.readline.return_value = " - en-US" + mock_exists.return_value = True + mock_cmd.side_effect = [True, True] game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") installer_path = "/home/makson/.cache/minigalaxy/download/Absolute Drift/setup_absolute_drift_1.0f_(64bit)_(47863).exe" temp_dir = "/home/makson/.cache/minigalaxy/extract/1136126792" exp = "" - obs = installer.extract_installer(game, installer_path, temp_dir, "en", use_innoextract=True) + obs, use_temp = installer.extract_installer(game, installer_path, temp_dir, "en") self.assertEqual(exp, obs) @mock.patch('os.path.exists') @@ -111,19 +118,23 @@ def test_extract_linux(self, mock_subprocess, mock_listdir, mock_is_file): installer_path = "/home/makson/.cache/minigalaxy/download/Beneath a Steel Sky/beneath_a_steel_sky_en_gog_2_20150.sh" temp_dir = "/home/makson/.cache/minigalaxy/extract/1207658695" exp = "" - obs = installer.extract_linux(installer_path, temp_dir) + obs, temp_used = installer.extract_linux(installer_path, temp_dir) self.assertEqual(exp, obs) + @mock.patch('minigalaxy.installer.try_wine_command') + @mock.patch('os.path.exists') @mock.patch('subprocess.Popen') - def test_extract_windows(self, mock_subprocess): + def test_extract_windows(self, mock_subprocess, mock_exists, mock_cmd): """[scenario: innoextract, unpack success]""" mock_subprocess().poll.return_value = 0 mock_subprocess().stdout.readlines.return_value = ["stdout", "stderr"] + mock_exists.return_value = True + mock_cmd.side_effect = [True, True] game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") installer_path = "/home/makson/.cache/minigalaxy/download/Absolute Drift/setup_absolute_drift_1.0f_(64bit)_(47863).exe" temp_dir = "/home/makson/.cache/minigalaxy/extract/1136126792" exp = "" - obs = installer.extract_windows(game, installer_path, temp_dir, "en", use_innoextract=True) + obs, uses_tmp = installer.extract_windows(game, installer_path, "en") self.assertEqual(exp, obs) @mock.patch('subprocess.Popen') From 66fe52ff9b609b8d9609cdd414055917825bc333 Mon Sep 17 00:00:00 2001 From: GB609 <39741460+GB609@users.noreply.github.com> Date: Sun, 8 Dec 2024 10:22:47 +0000 Subject: [PATCH 10/10] fix review comments --- tests/test_installer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_installer.py b/tests/test_installer.py index 68352eba..eb96ed66 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -132,7 +132,6 @@ def test_extract_windows(self, mock_subprocess, mock_exists, mock_cmd): mock_cmd.side_effect = [True, True] game = Game("Absolute Drift", install_dir="/home/makson/GOG Games/Absolute Drift", platform="windows") installer_path = "/home/makson/.cache/minigalaxy/download/Absolute Drift/setup_absolute_drift_1.0f_(64bit)_(47863).exe" - temp_dir = "/home/makson/.cache/minigalaxy/extract/1136126792" exp = "" obs, uses_tmp = installer.extract_windows(game, installer_path, "en") self.assertEqual(exp, obs)