From db2f155e42f64ef0b17afc5003a7d48c1f337530 Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Fri, 17 Jan 2025 17:19:25 +1100 Subject: [PATCH] Support more Powershell versions Output is not always written to the terminal the same way. I think this is about the Powershell version. Make changes to line process for greater support. Other changes: - Add file logging --- README.md | 6 ++ pyproject.toml | 1 + src/msix_global_installer/app.py | 22 ++++++- src/msix_global_installer/config.py | 3 +- src/msix_global_installer/msix.py | 97 ++++++++++++++++++++--------- uv.lock | 11 ++++ 6 files changed, 107 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index bc5dfbd..4eb7c5b 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,9 @@ The packages will be installed in reverse order with the last one specified inst main package (first argument) is installed last. There is no tested limit on the number of dependencies. + +## Logs + +Logs are enabled by default. +You can disable logging by changing ENABLE_LOG to 'False' in config.py. +Logs are stored in 'C:\\Users\\USER\\AppData\\Local\\msix_global_installer\\msix_global_installer\\Logs'. diff --git a/pyproject.toml b/pyproject.toml index fdef273..75b3c18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.10.6" dependencies = [ "attrs>=24.3.0", "pillow>=11.1.0", + "platformdirs>=4.3.6", "pyuac>=0.0.3", "pywin32>=308 ; sys_platform == 'win32'", "pywinpty>=2.0.14 ; sys_platform == 'win32'", diff --git a/src/msix_global_installer/app.py b/src/msix_global_installer/app.py index 1e5a5df..41805f0 100644 --- a/src/msix_global_installer/app.py +++ b/src/msix_global_installer/app.py @@ -2,9 +2,29 @@ import asyncio import logging import threading +import platformdirs +import pathlib -logging.basicConfig(level=logging.NOTSET) +if config.ENABLE_LOGS: + log_dir_path = pathlib.Path( + platformdirs.user_log_dir(appname="msix_global_installer", appauthor="msix_global_installer") + ) + log_dir_path.mkdir(parents=True) + log_path = log_dir_path / "installer.log" + logging.basicConfig( + level=logging.NOTSET, + filename=log_path, + filemode="a", + format="%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s", + datefmt="%H:%M:%S", + ) +else: + logging.basicConfig( + level=logging.NOTSET, + format="%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s", + datefmt="%H:%M:%S", + ) logger = logging.getLogger(__name__) diff --git a/src/msix_global_installer/config.py b/src/msix_global_installer/config.py index 9d1252f..d51a71f 100644 --- a/src/msix_global_installer/config.py +++ b/src/msix_global_installer/config.py @@ -2,4 +2,5 @@ from msix_global_installer import pyinstaller_helper EXTRACTED_DATA_PATH: pathlib.Path = pyinstaller_helper.resource_path("extracted/data.pkl") -ALLOW_DEPENDENCIES_TO_FAIL_DUE_TO_NEWER_VERSION_INSTALLED = True \ No newline at end of file +ALLOW_DEPENDENCIES_TO_FAIL_DUE_TO_NEWER_VERSION_INSTALLED = True +ENABLE_LOGS = True diff --git a/src/msix_global_installer/msix.py b/src/msix_global_installer/msix.py index b75052a..d475ed4 100644 --- a/src/msix_global_installer/msix.py +++ b/src/msix_global_installer/msix.py @@ -39,7 +39,7 @@ class ErrorResult: @dataclass class ReturnCodeResult: - return_code: int + install_success: bool def get_msix_metadata(msix_path: str, output_icon_path: pathlib.Path | None = None) -> MsixMetadata: @@ -201,13 +201,16 @@ def install_msix( ): """Install an MSIX package.""" # TODO: If global install ensure we are running as admin - global_install_command = "Add-AppxProvisionedPackage -PackagePath %s -Online -SkipLicense | Out-String" % path - local_install_command = "Add-AppxPackage -Path %s | Out-String" % path + global_install_command = ( + "Add-AppxProvisionedPackage -PackagePath %s -Online -SkipLicense -ErrorAction Continue | Out-String" % path + ) + local_install_command = "Add-AppxPackage -Path %s -ErrorAction Continue | Out-String" % path command_string = local_install_command if not global_install else global_install_command - save_returncode_string = "; $installRetcode = $LastExitCode" - print_return_code = "; echo RETCODE=$installRetcode" + # Use a q after the success int to confirm that we have read the right thing + save_returncode_string = "; $success_tail='q' ; $success=[int][bool]::Parse($?)" + print_return_code = "; echo INSTALL_SUCCESS===$success$success_tail" wait_string = "; Start-Sleep -Milliseconds 1500" - exit_string = "; Exit" + exit_string = "; echo Exiting with code $LASTEXITCODE; Exit" # We must use a psudo terminal as otherwise # the written lines are not going to stdout, just appearing on the terminal for the progress @@ -220,7 +223,7 @@ def install_msix( ) error: str | None = None - retcode: int | None = None + install_succeeded: bool | None = None while proc.isalive(): line = proc.readline() logger.debug("%r\n\r", line) @@ -228,18 +231,27 @@ def install_msix( result = process_line(line, is_dependency) # Return code will also come with a False for should continue so it doesn't # matter that we are overwriting this - should_continue, retcode = process_result( + should_continue, returned_install_result = process_result( result=result, package_title=title, current_error=error, packages_to_install=packages_to_install, package_number=package_number, ) + install_succeeded = returned_install_result if isinstance(result, ErrorResult): error = result.error if not error else error if not should_continue: + logger.info("Received request to not continue!") + # proc.write(exit_string + os.linesep) break + logger.info("Continuing") + # TODO Work out if this actually returns the exit status of the terminal + # It appears to always return 0 + logger.info("EXIT STATUS : %s", proc.exitstatus) + if not install_succeeded: + install_succeeded = True if proc.exitstatus == 0 else None logger.debug("Process is closed") # Set progress to 100 @@ -251,16 +263,16 @@ def install_msix( ) events.post_event_sync(event, event_queue=events.gui_event_queue) - return check_has_succeeded(return_code=retcode, error=error, package_title=title) + return check_has_succeeded(install_succeeded=install_succeeded, error=error, package_title=title) -def check_has_succeeded(return_code: int, error: str, package_title: str): +def check_has_succeeded(install_succeeded: bool | None, error: str, package_title: str): """ Return success. Post update to GUI on result. """ - if return_code == 0 and not error: + if install_succeeded is not None and install_succeeded and not error: logger.info("Should have installed successfully!") install_complete_text = f"Install of {package_title} complete" event = events.Event( @@ -271,10 +283,11 @@ def check_has_succeeded(return_code: int, error: str, package_title: str): return True else: logger.error("Install failed") - logger.error("Retcode is: %s", return_code) + logger.error("App reported install succeeded: %s", install_succeeded) logger.error("Error is: %s", error) - if error is None and return_code is None: + if error is None and install_succeeded is None: # Terminal must have force quit - won't have an error message + logger.warning("Stopping install - terminal must have force quit.") install_complete_text = f"Install of {package_title} failed" event = events.Event( name=events.EventType.INSTALL_PROGRESS_TEXT, @@ -285,15 +298,17 @@ def check_has_succeeded(return_code: int, error: str, package_title: str): def process_result( - result: ProgressResult | ErrorResult | ReturnCodeResult, - current_error: str, + result: ProgressResult | ErrorResult | ReturnCodeResult | None, + current_error: str | None, package_title, packages_to_install, package_number, -) -> tuple[bool, int | None]: +) -> tuple[bool, bool | None]: """Process a Result and return data to the GUI. - ::returns:: Should Continue. Break on False return. + ::returns:: (should_continue, install_success) + Should Continue: Break on False return. + Install Success: Reported success of the script """ if isinstance(result, ProgressResult): event = events.Event( @@ -318,16 +333,25 @@ def process_result( }, ) events.post_event_sync(event, event_queue=events.gui_event_queue) + logger.warning("Stoppping install due to new error: %s", result.error) + return (False, None) return (True, None) elif isinstance(result, ReturnCodeResult): - retcode = result.return_code - if retcode > 1 and current_error is None: + install_succeeded = result.install_success + if install_succeeded is not None and not install_succeeded and current_error is None: event = events.Event( name=events.EventType.INSTALL_PROGRESS_TEXT, data={"title": f"Failed to install {package_title}", "progress": 100}, ) events.post_event_sync(event, event_queue=events.gui_event_queue) - return (False, retcode) + logger.warning( + "Stopping install - script reported success-(%s) and current error (%s)", + install_succeeded, + current_error, + ) + return (False, install_succeeded) + # Success return code recieved + return (False, install_succeeded) # Not a matching line - continue return (True, None) @@ -343,23 +367,26 @@ def process_line(line, is_dependency: bool) -> ProgressResult | ErrorResult | Re except RecovorableRuntimeError as e: logger.info("Got a recoverable error: %s", e) if is_dependency and config.ALLOW_DEPENDENCIES_TO_FAIL_DUE_TO_NEWER_VERSION_INSTALLED: + logger.info("Settings allow for success to be returned") # Fudge progress to say it's installed successfully if # we are happy to ignore the error as it's a dependency # and the error says that it's already installed. - return ReturnCodeResult(0) + return ReturnCodeResult(True) else: + logger.warning("Settings insist this is a true failure") return ErrorResult(e) except RuntimeError as e: return ErrorResult(e) - elif "RETCODE=" in line: - return_code = parse_retcode(line) - logger.info("Retcode found: %s", return_code) - if return_code is not None: - return ReturnCodeResult(return_code) + elif "INSTALL_SUCCESS===" in line: + install_succeeded = parse_retcode(line) + if install_succeeded is not None: + logger.info("Success state %s found from line", install_succeeded) + return ReturnCodeResult(install_succeeded) class RecovorableRuntimeError(RuntimeError): """Used when an error is raised but it needs to be parsed differently.""" + pass @@ -371,6 +398,8 @@ def parse_error(error_string: str): raise RuntimeError("The root certificate of the signature in the app package or bundle must be trusted.") elif "0x80073D06" in error_string: raise RecovorableRuntimeError("A newer version of this package is already installed!") + elif "0x80073D02" in error_string: + raise RecovorableRuntimeError("A conflicting application is open!") elif "Add-AppxProvisionedPackage : The requested operation requires elevation" in error_string: raise RuntimeError("The requested operation requires elevation") elif "ObjectNotFound" in error_string: @@ -378,21 +407,27 @@ def parse_error(error_string: str): raise RuntimeError("Unknown error!") -def parse_retcode(line: str) -> int: +def parse_retcode(line: str) -> bool | None: """Get the retcode out of a string. Expects RETCODE=x where x is the retcode and any amount of values either side. """ - split = line.split("RETCODE=") - returncode = split[1][0] + split = line.split("INSTALL_SUCCESS===") + install_result = split[1][0] + install_result_confirmation_tail = split[1][1] try: + logger.info("Parsing return value %s from %s", install_result, split) # Line can sometimes be the command which gives an incorrect value # Such as ...ho\x1b[m RETCODE=\x1b[9... - int_retcode = int(returncode) + bool_success = bool(int(install_result)) + if install_result_confirmation_tail == "q": + logger.debug("Line rejected, don't have expected tail.") + return None except ValueError: + logger.debug("Value is not a bool") return None - return int_retcode + return bool(bool_success) def progress_mincer(package_progress: int, packages_to_install: int, package_number: int) -> int: diff --git a/uv.lock b/uv.lock index b392bf7..c9905c7 100644 --- a/uv.lock +++ b/uv.lock @@ -78,6 +78,7 @@ source = { editable = "." } dependencies = [ { name = "attrs" }, { name = "pillow" }, + { name = "platformdirs" }, { name = "pyuac" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, @@ -96,6 +97,7 @@ dev = [ requires-dist = [ { name = "attrs", specifier = ">=24.3.0" }, { name = "pillow", specifier = ">=11.1.0" }, + { name = "platformdirs", specifier = ">=4.3.6" }, { name = "pyuac", specifier = ">=0.0.3" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=308" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.14" }, @@ -195,6 +197,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/67/936f9814bdd74b2dfd4822f1f7725ab5d8ff4103919a1664eb4874c58b2f/pillow-11.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4637b88343166249fe8aa94e7c4a62a180c4b3898283bb5d3d2fd5fe10d8e4e0", size = 2626353 }, ] +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + [[package]] name = "pluggy" version = "1.5.0"