diff --git a/Core/ModuleManager.py b/Core/ModuleManager.py index a9c1758..9ca45a2 100644 --- a/Core/ModuleManager.py +++ b/Core/ModuleManager.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 import contextlib +import platform import os import re import shutil import site import sys -import venv import subprocess import yaml import pygit2 @@ -24,18 +24,18 @@ class ModulesManager: def __init__(self, mainWindow): self.mainWindow = mainWindow + self.system = platform.system() self.baseAppStoragePath = Path( QtCore.QStandardPaths.standardLocations( QtCore.QStandardPaths.StandardLocation.AppDataLocation)[0]) self.baseAppStoragePath.mkdir(exist_ok=True, parents=True) self.modulesBaseDirectoryPath = self.baseAppStoragePath / "User Module Packs Storage" - self.modulesBaseDirectoryPath.mkdir(exist_ok=True) self.browsersBaseDirectoryPath = self.baseAppStoragePath / "Browsers" self.browsersBaseDirectoryPath.mkdir(exist_ok=True) self.modulesRequirementsPath = self.modulesBaseDirectoryPath / "requirements.txt" - self.modulesRequirementsPath.touch(mode=0o700, exist_ok=True) self.modulesRequirementsTempPath = self.modulesBaseDirectoryPath / "requirements.txt.tmp" - self.modulesPythonPath = self.modulesBaseDirectoryPath / 'bin' / 'python3' + self.modulesPythonPath = self.modulesBaseDirectoryPath / 'bin' / 'python3.11' if self.system == "Linux" \ + else self.modulesBaseDirectoryPath / 'Scripts' / 'python.exe' self.upgradeThread = None self.moduleReqsThread = None self.uninstallThread = None @@ -50,7 +50,8 @@ def __init__(self, mainWindow): self.venvThread.start() def afterUpgrade(self, upgradeStatus: bool): - self.mainWindow.MESSAGEHANDLER.info(f"Upgrade status: {'Success' if upgradeStatus else 'Failed'}") + self.mainWindow.MESSAGEHANDLER.info(f"Module environment upgrade status: " + f"{'Success' if upgradeStatus else 'Failed'}") self.mainWindow.MESSAGEHANDLER.info('Modules Loaded.') self.mainWindow.setStatus('Environment Updated, Modules Loaded.') @@ -70,26 +71,42 @@ def loadYamlFile(self, filePath: Path): # {} == False return fileContents if fileContents is not None else {} - def configureVenv(self, venvPath): - binDir = os.path.dirname(venvPath / 'bin') - base = binDir[: -len("bin") - 1] # strip away the bin part from the __file__, plus the path separator + def configureVenvLinux(self): + binDir = self.modulesBaseDirectoryPath / 'bin' - # prepend bin to PATH (this file is inside the bin directory) - os.environ["PATH"] = os.pathsep.join([binDir] + os.environ.get("PATH", "").split(os.pathsep)) - os.environ["VIRTUAL_ENV"] = base # virtual env is right above bin directory + # prepend bin to PATH + os.environ["PATH"] = f'{binDir}{os.pathsep}{os.environ.get("PATH", "")}' + os.environ["VIRTUAL_ENV"] = str(self.modulesBaseDirectoryPath) + os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(self.browsersBaseDirectoryPath) + + packagesPath = str(list((self.modulesBaseDirectoryPath / 'lib').glob('python*'))[0] / 'site-packages') + + sys.path.append(packagesPath) + site.addsitedir(packagesPath) + + sys.prefix = str(self.modulesBaseDirectoryPath) + + def configureVenvWindows(self): + binDir = self.modulesBaseDirectoryPath / 'Scripts' + + # prepend Scripts to PATH + os.environ["PATH"] = f'{binDir}{os.pathsep}{os.environ.get("PATH", "")}' + os.environ["VIRTUAL_ENV"] = str(self.modulesBaseDirectoryPath) os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(self.browsersBaseDirectoryPath) # add the virtual environments libraries to the host python import mechanism - prevLength = len(sys.path) - packagesPath = str(list((venvPath / 'lib').glob('python*'))[0] / 'site-packages') + packagesPath = str(self.modulesBaseDirectoryPath / 'Lib' / 'site-packages') + + sys.path.append(packagesPath) + site.addsitedir(packagesPath) - for lib in packagesPath.split(os.pathsep): - path = os.path.realpath(os.path.join(binDir, lib)) - site.addsitedir(path) - sys.path[:] = sys.path[prevLength:] + sys.path[:prevLength] + sys.prefix = str(self.modulesBaseDirectoryPath) - sys.real_prefix = sys.prefix - sys.prefix = base + def configureVenv(self): + if self.system == "Linux": + self.configureVenvLinux() + else: + self.configureVenvWindows() self.loadAllModules() @@ -776,18 +793,30 @@ def accept(self) -> None: class InitialiseVenvThread(QtCore.QThread): - configureVenvOfMainThreadSignal = QtCore.Signal(Path) + configureVenvOfMainThreadSignal = QtCore.Signal() def __init__(self, modulesManager: ModulesManager) -> None: super().__init__() self.modulesManager = modulesManager def run(self) -> None: - venvPath = self.modulesManager.modulesBaseDirectoryPath - if not (venvPath / 'bin').exists(): - venv.create(venvPath, symlinks=True, with_pip=True, upgrade_deps=True) + if self.modulesManager.system == "Linux": + localBinPath = str(Path.home() / '.local' / 'bin') + currentPath = os.environ.get("PATH", "") + if localBinPath not in currentPath.split(os.pathsep): + os.environ["PATH"] = currentPath + os.pathsep + localBinPath + + binPath = self.modulesManager.modulesBaseDirectoryPath / 'bin' \ + if self.modulesManager.system == "Linux" \ + else self.modulesManager.modulesBaseDirectoryPath / 'Scripts' + if not binPath.exists(): + pythonExecutable = "python3.11" if self.modulesManager.system == "Linux" else "python.exe" + cmdStr = (f'{pythonExecutable} -m venv --symlinks --clear --upgrade-deps ' + f'"{self.modulesManager.modulesBaseDirectoryPath}"') + subprocess.run(cmdStr, shell=True) + self.modulesManager.modulesRequirementsPath.touch(mode=0o700, exist_ok=True) - self.configureVenvOfMainThreadSignal.emit(venvPath) + self.configureVenvOfMainThreadSignal.emit() class UpgradeVenvThread(QtCore.QThread): @@ -808,14 +837,14 @@ def run(self) -> None: else: reqsFile = self.modulesManager.modulesRequirementsPath try: - cmdStr = f"'{self.modulesManager.modulesPythonPath}' --version" + cmdStr = f'"{self.modulesManager.modulesPythonPath}" --version' subprocess.check_output(cmdStr, shell=True) - cmdStr = f"'{self.modulesManager.modulesPythonPath}' -m pip install --upgrade -r '{reqsFile}'" + cmdStr = f'"{self.modulesManager.modulesPythonPath}" -m pip install --upgrade -r "{reqsFile}"' subprocess.check_output(cmdStr, shell=True) # Install / upgrade playwright & misc if not already installed, since they need special treatment. - cmdStr = f"'{self.modulesManager.modulesPythonPath}' -m pip install --upgrade pip wheel setuptools playwright" + cmdStr = f'"{self.modulesManager.modulesPythonPath}" -m pip install --upgrade pip wheel setuptools playwright' subprocess.run(cmdStr, shell=True) - cmdStr = f"'{self.modulesManager.modulesPythonPath}' -m playwright install" + cmdStr = f'"{self.modulesManager.modulesPythonPath}" -m playwright install' subprocess.run(cmdStr, shell=True) collapse_browser_folders(self.modulesManager.browsersBaseDirectoryPath) self.upgradeVenvThreadSignal.emit(True) @@ -859,8 +888,8 @@ def run(self) -> None: self.progressSignal.emit(1) success = False - cmdStr = f"'{self.modulesManager.modulesPythonPath}' -m pip install --upgrade -r " \ - f"'{self.modulesManager.modulesRequirementsTempPath}'" + cmdStr = f'"{self.modulesManager.modulesPythonPath}" -m pip install --upgrade -r ' \ + f'"{self.modulesManager.modulesRequirementsTempPath}"' try: subprocess.check_output(cmdStr, shell=True) @@ -920,7 +949,7 @@ def run(self) -> None: [file.write(line + '\n') for line in newRequirementsSet] if reqsDiff := allRequirementsSet.difference(newRequirementsSet): - cmdStr = f"'{self.modulesManager.modulesPythonPath}' -m pip uninstall {' '.join(reqsDiff)} -y" + cmdStr = f'"{self.modulesManager.modulesPythonPath}" -m pip uninstall {" ".join(reqsDiff)} -y' try: subprocess.check_output(cmdStr, shell=True) shutil.move(self.modulesManager.modulesRequirementsTempPath, diff --git a/Core/UpdateManager.py b/Core/UpdateManager.py index e1dfb07..cb0301f 100644 --- a/Core/UpdateManager.py +++ b/Core/UpdateManager.py @@ -4,7 +4,10 @@ import platform import subprocess import requests +import tempfile +import shutil import os +import ctypes from pathlib import Path from semver import compare @@ -40,8 +43,9 @@ def getLatestVersion(self): def isUpdateAvailable(self): latest_version = self.getLatestVersion() return ( - latest_version is not None - and compare(self.mainWindow.SETTINGS.value("Program/Version").lstrip('v'), latest_version.lstrip('v')) < 0 + latest_version is not None + and compare(self.mainWindow.SETTINGS.value("Program/Version").lstrip('v'), + latest_version.lstrip('v')) < 0 ) def doUpdate(self) -> None: @@ -54,11 +58,22 @@ def doUpdate(self) -> None: self.updateThread.start() self.mainWindow.MESSAGEHANDLER.info('Update in progress') - def finalizeUpdate(self, success: bool) -> None: - if success: + def finalizeUpdate(self, updateTempPath: str) -> None: + if updateTempPath != '': latest_version = self.getLatestVersion() self.mainWindow.SETTINGS.setValue("Program/Version", latest_version) self.mainWindow.MESSAGEHANDLER.info('Updating done, please restart for the changes to take effect.') + self.mainWindow.MESSAGEHANDLER.info("The application will now save and close to apply the updates. " + "Please wait for a few minutes before reopening LinkScope.", + popUp=True) + + uncompressNewVersion( + self.system, + self.mainWindow.SETTINGS.value("Program/BaseDir"), + str(self.baseSoftwarePath), + updateTempPath) + self.mainWindow.close() + else: self.mainWindow.MESSAGEHANDLER.info('Updating failed.') @@ -99,7 +114,7 @@ def initiateUpdate(self) -> None: class UpdaterThread(QtCore.QThread): - updateDoneSignal = QtCore.Signal(bool) + updateDoneSignal = QtCore.Signal(str) def __init__(self, updateManager, mainWindow): super().__init__() @@ -107,19 +122,72 @@ def __init__(self, updateManager, mainWindow): self.updateManager = updateManager def run(self) -> None: - downloadUrl = self.updateManager.getDownloadURL() - if self.updateManager.system == 'Windows': - subprocess.run( - ['runas', - '/user:Administrator', - Path(self.mainWindow.SETTINGS.value("Program/BaseDir")) / "UpdaterUtil.exe", - downloadUrl, - self.updateManager.baseSoftwarePath.parent] - ) - elif self.updateManager.system == 'Linux': - subprocess.run( - ['pkexec', - Path(self.mainWindow.SETTINGS.value("Program/BaseDir")) / "UpdaterUtil", - downloadUrl, - self.updateManager.baseSoftwarePath.parent] - ) + try: + downloadUrl = self.updateManager.getDownloadURL() + clientTempCompressedArchive = tempfile.mkstemp(suffix='.7z') + tempPath = clientTempCompressedArchive[1] + + with os.fdopen(clientTempCompressedArchive[0], 'wb') as tempArchive: + with requests.get(downloadUrl, stream=True) as fileStream: + for chunk in fileStream.iter_content(chunk_size=5 * 1024 * 1024): + tempArchive.write(chunk) + self.updateDoneSignal.emit(tempPath) + except Exception: + self.updateDoneSignal.emit('') + + +def uncompressNewVersion(system: str, baseDir: str, baseSoftwarePath: str, updateTempPath: str): + tempDir = tempfile.mkdtemp(prefix='LinkScope_Updater_TMP_') + + if system == 'Windows': + updaterPath = Path(baseDir) / "UpdaterUtil.exe" + + tempUpdaterPath = Path(tempDir) / updaterPath.name + shutil.copy(updaterPath, tempUpdaterPath) + + # This is done so that Windows spawns the updater as a detached process. + ShellExecuteEx = ctypes.windll.shell32.ShellExecuteEx + SEE_MASK_NO_CONSOLE = 0x00008000 + + class SHELLEXECUTEINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_ulong), + ("fMask", ctypes.c_ulong), + ("hwnd", ctypes.c_void_p), + ("lpVerb", ctypes.c_char_p), + ("lpFile", ctypes.c_char_p), + ("lpParameters", ctypes.c_char_p), + ("lpDirectory", ctypes.c_char_p), + ("nShow", ctypes.c_int), + ("hInstApp", ctypes.c_void_p), + ("lpIDList", ctypes.c_void_p), + ("lpClass", ctypes.c_char_p), + ("hkeyClass", ctypes.c_void_p), + ("dwHotKey", ctypes.c_ulong), + ("hIconOrMonitor", ctypes.c_void_p), + ("hProcess", ctypes.c_void_p), + ] + + sei = SHELLEXECUTEINFO() + sei.cbSize = ctypes.sizeof(sei) + sei.fMask = SEE_MASK_NO_CONSOLE + sei.lpVerb = b"runas" + sei.lpFile = bytes(tempUpdaterPath) + sei.lpParameters = f'"{updateTempPath}" "{baseSoftwarePath}"'.encode('utf-8') + sei.nShow = 1 + + if not ShellExecuteEx(ctypes.byref(sei)): + raise ctypes.WinError() + + elif system == 'Linux': + updaterPath = Path(baseDir) / "UpdaterUtil" + + tempUpdaterPath = Path(tempDir) / updaterPath.name + shutil.copy(updaterPath, tempUpdaterPath) + + subprocess.Popen( + f'pkexec "{tempUpdaterPath}" "{updateTempPath}" "{baseSoftwarePath}"', + start_new_session=True, + close_fds=True, + shell=True, + ) diff --git a/LinkScope.py b/LinkScope.py index c575c9b..d921b38 100644 --- a/LinkScope.py +++ b/LinkScope.py @@ -2,6 +2,7 @@ # Load modules import contextlib +import platform import re import sys import time @@ -1085,9 +1086,9 @@ def getPlaywrightBrowserPath(self, browser: str = None) -> Path: if browserDir is not None: browserPath = [subdirectory for subdirectory in browserDir.iterdir() if subdirectory.is_dir()][0] if browser == 'firefox': - browserPath /= 'firefox' + browserPath /= 'firefox.exe' if platform.system() == 'Windows' else 'firefox' elif browser == 'chromium': - browserPath /= 'chrome' + browserPath /= 'chrome.exe' if platform.system() == 'Windows' else 'chrome' else: self.MESSAGEHANDLER.critical('Cannot find installed playwright browsers.', exc_info=False) return browserPath diff --git a/UpdaterUtil.py b/UpdaterUtil.py index ffd9808..9436a6c 100644 --- a/UpdaterUtil.py +++ b/UpdaterUtil.py @@ -1,27 +1,34 @@ #!/usr/bin/env python3 - import sys -import tempfile -import os -import requests -import py7zr +import time from pathlib import Path -if len(sys.argv) != 3: - sys.exit(3) +import py7zr + + +def main(): + if len(sys.argv) != 3: + sys.exit(3) + + tempPath = Path(sys.argv[1]) + softwarePath = Path(sys.argv[2]) + + time.sleep(10) # Wait for a few seconds for the main application to close. + + input("\nPlease make sure that all LinkScope Client processes are closed, " + "and then press Enter to begin the update.\n") + print("Updating...\n") -downloadUrl = sys.argv[1] -softwarePath = sys.argv[2] + with py7zr.SevenZipFile(tempPath, 'r') as archive: + archive.extractall(path=softwarePath.parent) -clientTempCompressedArchive = tempfile.mkstemp(suffix='.7z') -tempPath = Path(clientTempCompressedArchive[1]) + tempPath.unlink(missing_ok=True) -with os.fdopen(clientTempCompressedArchive[0], 'wb') as tempArchive: - with requests.get(downloadUrl, stream=True) as fileStream: - for chunk in fileStream.iter_content(chunk_size=5 * 1024 * 1024): - tempArchive.write(chunk) + input("Update complete. Press Enter to exit.") -with py7zr.SevenZipFile(tempPath, 'r') as archive: - archive.extractall(path=softwarePath) -tempPath.unlink(missing_ok=True) +if __name__ == '__main__': + try: + main() + except Exception as e: + input(f"Update failed. Press Enter to exit. Reason: {repr(e)}")