diff --git a/build_exe.ps1 b/build_exe.ps1 index b3d03c4..2f01c5b 100644 --- a/build_exe.ps1 +++ b/build_exe.ps1 @@ -1,9 +1,24 @@ +function Get-ParentPath { + param([string]$path) + + # Check if the path is just a file (no backslashes or forward slashes) + if ($path -notmatch '[\\/]') { + return '.' # Return '.' for just a file without a directory + } + + # Use Split-Path to get the parent directory + return (Split-Path $path -Parent) +} + # Get picked data to get name and icon $paths_py = python -c "import pickle; m = pickle.load(open('extracted/data.pkl', 'rb')); print([a.package_path for a in m])" $paths_py_doublequotes = $paths_py -replace "'", '"' $paths_json = $paths_py_doublequotes | ConvertFrom-Json $main_app_path = $paths_json[0] -$addDataArgs = $paths_json | ForEach-Object { "--add-data `'$($_):.`'" } +$addDataArgs = $paths_json | ForEach-Object { + $parent_path = Get-ParentPath $_ + "--add-data `'$($_):$parent_path`'" +} $addDataString = $addDataArgs -replace "`r?`n", "" $basename = [System.IO.Path]::GetFileNameWithoutExtension($main_app_path) @@ -13,4 +28,5 @@ $icon = python -c "import pickle; print(pickle.load(open('extracted/data.pkl', ' # Build command # This only works when first stored as a string - some powershell string issue for the add-data commands $command_str = "pyinstaller .\src\msix_global_installer\app.py --add-data 'extracted:extracted' $addDataString --onefile --name $pathexe --icon $icon --noconsole" +Write-Host "Running command: $command_str" Invoke-Expression $command_str \ No newline at end of file diff --git a/extract_msix_data.py b/extract_msix_data.py index 931e73c..480d064 100644 --- a/extract_msix_data.py +++ b/extract_msix_data.py @@ -38,4 +38,6 @@ def get_metadata(paths: list[str]) -> list[msix.MsixMetadata]: image.save_image(scaled_image, scaled_image_path) metadata.scaled_icon_path = scaled_image_path +print(f"\nExtracted: {all_metadata}") + pickler.save_metadata(data_file_path=data_file, metadata_list=all_metadata) diff --git a/src/msix_global_installer/app.py b/src/msix_global_installer/app.py index 4597b60..1e5a5df 100644 --- a/src/msix_global_installer/app.py +++ b/src/msix_global_installer/app.py @@ -12,9 +12,7 @@ def process_event(event: events.Event): if event.name == events.EventType.REQUEST_MSIX_METADATA: meta = pickler.load_metadata(config.EXTRACTED_DATA_PATH) logger.info("Got metadata %s", meta) - metadata_event = events.Event( - name=events.EventType.MSIX_METADATA_RECEIVED, data=meta - ) + metadata_event = events.Event(name=events.EventType.MSIX_METADATA_RECEIVED, data=meta) events.post_event_sync(event=metadata_event, event_queue=events.gui_event_queue) elif event.name == events.EventType.INSTALL_MSIX: install_globally = event.data["global"] diff --git a/src/msix_global_installer/config.py b/src/msix_global_installer/config.py index c0a8502..9d1252f 100644 --- a/src/msix_global_installer/config.py +++ b/src/msix_global_installer/config.py @@ -1,6 +1,5 @@ import pathlib from msix_global_installer import pyinstaller_helper -EXTRACTED_DATA_PATH: pathlib.Path = pyinstaller_helper.resource_path( - "extracted/data.pkl" -) +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 diff --git a/src/msix_global_installer/events.py b/src/msix_global_installer/events.py index 9b91315..4186937 100644 --- a/src/msix_global_installer/events.py +++ b/src/msix_global_installer/events.py @@ -76,9 +76,7 @@ async def wait_for_queue(timeout_s: int, event_queue: asyncio.Queue) -> None: current_time = time.monotonic_ns() await asyncio.sleep(0) if current_time - initial_time > timeout_ns: - raise TimeoutError( - f"Timed out waiting for event queue to clear. Events: {str(event_queue)}" - ) + raise TimeoutError(f"Timed out waiting for event queue to clear. Events: {str(event_queue)}") elif event_queue.empty(): break diff --git a/src/msix_global_installer/gui.py b/src/msix_global_installer/gui.py index 7c04fed..0f5943c 100644 --- a/src/msix_global_installer/gui.py +++ b/src/msix_global_installer/gui.py @@ -102,11 +102,7 @@ class InfoScreen(ttk.Frame, events.EventHandler): def __init__(self, parent: tkinter.Tk, *args, **kwargs): ttk.Frame.__init__(self, parent, *args, **kwargs) self.parent: tkinter.Tk = parent - post_backend_event( - events.Event( - events.EventType.REQUEST_MSIX_METADATA, data=events.EventData() - ) - ) + post_backend_event(events.Event(events.EventType.REQUEST_MSIX_METADATA, data=events.EventData())) self.title = ttk.Label(self, text="Install MSIX Application") self.title.grid(row=0, column=0, sticky="W") diff --git a/src/msix_global_installer/msix.py b/src/msix_global_installer/msix.py index 3e21ec4..b75052a 100644 --- a/src/msix_global_installer/msix.py +++ b/src/msix_global_installer/msix.py @@ -1,13 +1,14 @@ from dataclasses import dataclass from math import ceil -from msix_global_installer import events +from msix_global_installer import events, config +import io import logging import os import pathlib import re +import sys import xml.etree.ElementTree as ET import zipfile -import sys if sys.platform == "win32": from winpty import PtyProcess @@ -41,9 +42,7 @@ class ReturnCodeResult: return_code: int -def get_msix_metadata( - msix_path: str, output_icon_path: pathlib.Path | None = None -) -> MsixMetadata: +def get_msix_metadata(msix_path: str, output_icon_path: pathlib.Path | None = None) -> MsixMetadata: """ Extract Metadata from MSIX package. @@ -55,67 +54,108 @@ def get_msix_metadata( raise Exception("Path doesn't exist") try: + is_bundle = msix_path.endswith(".msixbundle") or msix_path.endswith(".appxbundle") with zipfile.ZipFile(msix_path, "r") as msix: - with msix.open("AppxManifest.xml") as manifest: - tree = ET.parse(manifest) - root = tree.getroot() - - # Define namespace for querying XML - namespace = { - "default": "http://schemas.microsoft.com/appx/manifest/foundation/windows10" - } - - # Extract DisplayName - display_name = root.find( - "default:Properties/default:DisplayName", namespace - ) - package_name = ( - display_name.text - if display_name is not None - else "DisplayName not found" - ) - - # Extract Version (Attribute of the Identity element) - identity = root.find("default:Identity", namespace) - version = ( - identity.attrib.get("Version", "Version not found") - if identity is not None - else "Version not found" - ) - - # Extract Publisher (Attribute of the Identity element) - publisher_full = ( - identity.attrib.get("Publisher", "Publisher not found") - if identity is not None - else "Publisher not found" - ) - publisher = get_name_from_publisher(publisher_full) - - # Extract Icon Path - icon_element = root.find("default:Properties/default:Logo", namespace) - icon_path_in_msix = ( - icon_element.text if icon_element is not None else None - ) - - extracted_icon_path = None - if output_icon_path is not None: - if icon_path_in_msix: - # Extract the icon from the MSIX package - icon_path_in_msix = icon_path_in_msix.replace("\\", "/") - output_icon_path = ( - pathlib.Path(output_icon_path) - / pathlib.Path(icon_path_in_msix).name - ) - with msix.open(icon_path_in_msix) as icon_file: - with open(output_icon_path, "wb") as out_file: - out_file.write(icon_file.read()) - extracted_icon_path = pathlib.Path(output_icon_path) - - return MsixMetadata( - msix_path, package_name, version, publisher, extracted_icon_path - ) + if is_bundle: + for file in msix.namelist(): + # Get the first msix file in the bundle and use that as the reference + # TODO: Support localisation + if file.endswith(".msix") or file.endswith(".appx"): + with msix.open(file) as inner_msix: + # This opens a ZipFileObject so we need to open it as bytes like object in memory + # If the MSIX file is huge this will cause trouble opening on very low memory computers I expect + inner_msix_filedata = io.BytesIO(inner_msix.read()) + with zipfile.ZipFile(inner_msix_filedata, "r") as working_msix: + return extract_metadata_from_manifest( + working_msix, pathlib.Path(msix_path), output_icon_path + ) + raise FileNotFoundError("No APPX or MSIX in bundle!") + else: + return extract_metadata_from_manifest(msix, pathlib.Path(msix_path), output_icon_path) except Exception as e: - return MsixMetadata("Error", "Error", str(e), "Error") + raise e + + +def extract_metadata_from_manifest( + open_msix_path_object, msix_path: pathlib.Path, output_icon_path: pathlib.Path | None +): + """Extract details from a given manifest.""" + with open_msix_path_object.open("AppxManifest.xml") as manifest: + tree = ET.parse(manifest) + root = tree.getroot() + + # Define namespace for querying XML + namespace = {"default": "http://schemas.microsoft.com/appx/manifest/foundation/windows10"} + + # Extract DisplayName + display_name = root.find("default:Properties/default:DisplayName", namespace) + package_name = str(display_name.text) if display_name is not None else "DisplayName not found" + + # Extract Version (Attribute of the Identity element) + identity = root.find("default:Identity", namespace) + version = identity.attrib.get("Version", "Version not found") if identity is not None else "Version not found" + + # Extract Publisher (Attribute of the Identity element) + publisher_full = ( + identity.attrib.get("Publisher", "Publisher not found") if identity is not None else "Publisher not found" + ) + publisher = get_name_from_publisher(publisher_full) + + # Extract Icon Path + icon_element = root.find("default:Properties/default:Logo", namespace) + icon_path_in_msix = icon_element.text if icon_element is not None else None + + extracted_icon_path = None + if output_icon_path is not None: + if icon_path_in_msix: + # Get the correct name + icon_path_in_msix = icon_path_in_msix.replace("\\", "/") + # Extract the icon from the MSIX package + try: + qualified_icon_path = find_qualified_logo_file(open_msix_path_object, icon_path_in_msix) + except FileNotFoundError: + print("No logo found...") + extracted_icon_path = None + else: + # Build the name to extract to + output_icon_path = pathlib.Path(output_icon_path) / pathlib.Path(icon_path_in_msix).name + # Extract + with open_msix_path_object.open(qualified_icon_path) as icon_file: + with open(output_icon_path, "wb") as out_file: + out_file.write(icon_file.read()) + extracted_icon_path = pathlib.Path(output_icon_path) + + return MsixMetadata(str(msix_path), package_name, version, publisher, extracted_icon_path) + + +def find_qualified_logo_file(manifest: zipfile.ZipFile, resource_path: str) -> str: + """ + Searches for the best match for a resource file with qualifiers in the ZIP archive. + + Eg would be assets/AppPackageLogo.png would find assets/AppPackageLogo.scale200.png + """ + # Strip .png extension if it exists + base_name = resource_path.rsplit(".png", 1)[0] + + # Generate potential qualified file names + candidates = [ + f"{resource_path}", # Ensure we check if it's available with the given name first + # Then try different sizes + f"{base_name}.scale-100.png", + f"{base_name}.scale-200.png", + f"{base_name}.scale-400.png", + ] + + # List all files in the archive + all_files = manifest.namelist() + + # Check for matches + for candidate in candidates: + if candidate in all_files: + return candidate + + # If no matches found, raise an error + raise FileNotFoundError(f"No qualified file found for {resource_path} in archive.") def get_name_from_publisher(publisher: str) -> str: @@ -161,14 +201,9 @@ 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 - ) + global_install_command = "Add-AppxProvisionedPackage -PackagePath %s -Online -SkipLicense | Out-String" % path local_install_command = "Add-AppxPackage -Path %s | Out-String" % path - command_string = ( - local_install_command if not global_install else global_install_command - ) + command_string = local_install_command if not global_install else global_install_command save_returncode_string = "; $installRetcode = $LastExitCode" print_return_code = "; echo RETCODE=$installRetcode" wait_string = "; Start-Sleep -Milliseconds 1500" @@ -181,12 +216,7 @@ def install_msix( # Here we incorperate a wait which allows us to capture the lines before the terminal closes # As we need the last lines which are the return code proc.write( - command_string - + save_returncode_string - + print_return_code * 10 - + wait_string - + exit_string - + os.linesep + command_string + save_returncode_string + print_return_code * 10 + wait_string + exit_string + os.linesep ) error: str | None = None @@ -194,10 +224,17 @@ def install_msix( while proc.isalive(): line = proc.readline() logger.debug("%r\n\r", line) - result = process_line(line) + is_dependency = packages_to_install > 1 and package_number != packages_to_install + 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(result=result, package_title=title, current_error=error, packages_to_install=packages_to_install, package_number=package_number) + should_continue, retcode = process_result( + result=result, + package_title=title, + current_error=error, + packages_to_install=packages_to_install, + package_number=package_number, + ) if isinstance(result, ErrorResult): error = result.error if not error else error if not should_continue: @@ -220,7 +257,7 @@ def install_msix( def check_has_succeeded(return_code: int, error: str, package_title: str): """ Return success. - + Post update to GUI on result. """ if return_code == 0 and not error: @@ -247,9 +284,15 @@ def check_has_succeeded(return_code: int, error: str, package_title: str): return False -def process_result(result: ProgressResult | ErrorResult | ReturnCodeResult, current_error: str, package_title, packages_to_install, package_number) -> tuple[bool, int | None]: +def process_result( + result: ProgressResult | ErrorResult | ReturnCodeResult, + current_error: str, + package_title, + packages_to_install, + package_number, +) -> tuple[bool, int | None]: """Process a Result and return data to the GUI. - + ::returns:: Should Continue. Break on False return. """ if isinstance(result, ProgressResult): @@ -257,9 +300,7 @@ def process_result(result: ProgressResult | ErrorResult | ReturnCodeResult, curr name=events.EventType.INSTALL_PROGRESS_TEXT, data={ "title": f"Installing {package_title}", - "progress": progress_mincer( - result.progress, packages_to_install, package_number - ), + "progress": progress_mincer(result.progress, packages_to_install, package_number), }, ) events.post_event_sync(event, event_queue=events.gui_event_queue) @@ -291,13 +332,23 @@ def process_result(result: ProgressResult | ErrorResult | ReturnCodeResult, curr return (True, None) -def process_line(line) -> ProgressResult | ErrorResult | ReturnCodeResult | None: +def process_line(line, is_dependency: bool) -> ProgressResult | ErrorResult | ReturnCodeResult | None: progress = count_progress(line=line, max_count=68) if progress: return ProgressResult(progress=progress) - elif "error" in line or "CategoryInfo" in line: + elif "error" in line or "CategoryInfo" in line or "HRESULT" in line: try: parse_error(line) + # Must parse this first as it's derived from RuntimeError + 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: + # 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) + else: + return ErrorResult(e) except RuntimeError as e: return ErrorResult(e) elif "RETCODE=" in line: @@ -307,18 +358,20 @@ def process_line(line) -> ProgressResult | ErrorResult | ReturnCodeResult | None return ReturnCodeResult(return_code) +class RecovorableRuntimeError(RuntimeError): + """Used when an error is raised but it needs to be parsed differently.""" + pass + + def parse_error(error_string: str): logger.warning("Error string: %s" % error_string) if "0x80074CF0" in error_string: raise RuntimeError("Certificate error") elif "0x800B0109" in error_string: - raise RuntimeError( - "The root certificate of the signature in the app package or bundle must be trusted." - ) - elif ( - "Add-AppxProvisionedPackage : The requested operation requires elevation" - in error_string - ): + 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 "Add-AppxProvisionedPackage : The requested operation requires elevation" in error_string: raise RuntimeError("The requested operation requires elevation") elif "ObjectNotFound" in error_string: raise RuntimeError("Installer file not found!") @@ -342,9 +395,7 @@ def parse_retcode(line: str) -> int: return int_retcode -def progress_mincer( - package_progress: int, packages_to_install: int, package_number: int -) -> int: +def progress_mincer(package_progress: int, packages_to_install: int, package_number: int) -> int: """Get progress as part of the total packages to install.""" total_for_stage = 1 / packages_to_install * 100 zero_point = 100 * ((package_number - 1) / packages_to_install) diff --git a/tests/test_msix.py b/tests/test_msix.py index d630f5e..9eae206 100644 --- a/tests/test_msix.py +++ b/tests/test_msix.py @@ -8,7 +8,7 @@ class TestMsix: def test_get_msix_metadata(self, tmpdir): """Test we get the required metadata from a given test file.""" - path = pathlib.Path("tests/TestMsixPackage.msix") + path = str(pathlib.Path("tests/TestMsixPackage.msix")) dir = tmpdir data = msix.get_msix_metadata(path, output_icon_path=dir) assert data.package_name == "MyEmployees"