From b76ee99b7cdfe08819d00231846b9cbe66fe053f Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Fri, 10 Jan 2025 06:03:00 +1000 Subject: [PATCH] Handle dependencies Allow applications to bundle dependencies which are installed in reverse order. --- README.md | 13 +++ build_exe.ps1 | 20 +++- extract_msix_data.py | 13 ++- src/msix_global_installer/app.py | 24 +++- src/msix_global_installer/gui.py | 54 ++++++--- src/msix_global_installer/msix.py | 163 ++++++++++++++++++++------- src/msix_global_installer/pickler.py | 6 +- tests/test_msix.py | 2 +- 8 files changed, 225 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index f72d44b..bc5dfbd 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,16 @@ uv run powershell ./build_exe.ps1 ``` Your executable will be in dist/. + +### How to add dependencies + +Copy your MSIX file and dependencies into root of this repo, then run the preparation step + +```ps +uv run python extract_msix_data.py path_to_your_msix_file path_to_dependency path_to_second_dependency ... +``` + +The packages will be installed in reverse order with the last one specified installed first, until the +main package (first argument) is installed last. + +There is no tested limit on the number of dependencies. diff --git a/build_exe.ps1 b/build_exe.ps1 index 89d811f..b3d03c4 100644 --- a/build_exe.ps1 +++ b/build_exe.ps1 @@ -1,6 +1,16 @@ -$path = python -c "import pickle; print(pickle.load(open('extracted/data.pkl', 'rb')).package_path)" -$basename = [System.IO.Path]::GetFileNameWithoutExtension($path) +# 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 `'$($_):.`'" } +$addDataString = $addDataArgs -replace "`r?`n", "" + +$basename = [System.IO.Path]::GetFileNameWithoutExtension($main_app_path) $pathexe = $basename + ".exe" -$icon = python -c "import pickle; print(pickle.load(open('extracted/data.pkl', 'rb')).icon_path)" -$add_data_msix_path = $path + ":." -pyinstaller .\src\msix_global_installer\app.py --add-data 'extracted:extracted' --add-data "$add_data_msix_path" --onefile --name $pathexe --icon $icon --noconsole \ No newline at end of file +$icon = python -c "import pickle; print(pickle.load(open('extracted/data.pkl', 'rb'))[0].icon_path)" + +# 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" +Invoke-Expression $command_str \ No newline at end of file diff --git a/extract_msix_data.py b/extract_msix_data.py index c4432f3..931e73c 100644 --- a/extract_msix_data.py +++ b/extract_msix_data.py @@ -9,6 +9,12 @@ import sys import pathlib + +def get_metadata(paths: list[str]) -> list[msix.MsixMetadata]: + """Get the metadata from a list of items.""" + return [msix.get_msix_metadata(path) for path in paths] + + path = sys.argv[1] print("Extracting data from %s" % path) @@ -18,6 +24,11 @@ data_file = data_output_path / "data.pkl" metadata = msix.get_msix_metadata(path, data_output_path) +dependency_paths = sys.argv[2:] +dependency_metadata = [] +if dependency_paths: + dependency_metadata = get_metadata(paths=dependency_paths) +all_metadata = [metadata] + dependency_metadata # Scale the image, save and add to metadata scaled_image = image.scale_image(metadata.icon_path, 100, 100) @@ -27,4 +38,4 @@ image.save_image(scaled_image, scaled_image_path) metadata.scaled_icon_path = scaled_image_path -pickler.save_metadata(data_file_path=data_file, metadata=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 074ef5a..4597b60 100644 --- a/src/msix_global_installer/app.py +++ b/src/msix_global_installer/app.py @@ -19,9 +19,27 @@ def process_event(event: events.Event): elif event.name == events.EventType.INSTALL_MSIX: install_globally = event.data["global"] meta = pickler.load_metadata(config.EXTRACTED_DATA_PATH) - path = pyinstaller_helper.resource_path(meta.package_path) - logger.info("Installing app: %s", path) - meta = msix.install_msix(path=path, global_install=install_globally) + paths = [ + ( + metadata.package_name, + pyinstaller_helper.resource_path(metadata.package_path), + ) + for metadata in meta + ] + # TODO: Break this into a function in MSIX + paths.reverse() + number_of_packages = len(paths) + for i, path in enumerate(paths): + logger.info("Installing app: %s", path) + success = msix.install_msix( + path=path[1], + title=path[0], + global_install=install_globally, + packages_to_install=number_of_packages, + package_number=i + 1, + ) + if not success: + break logger.info("Installing app: %s... DONE", path) diff --git a/src/msix_global_installer/gui.py b/src/msix_global_installer/gui.py index 1a558a7..7c04fed 100644 --- a/src/msix_global_installer/gui.py +++ b/src/msix_global_installer/gui.py @@ -33,7 +33,7 @@ def __init__(self, parent, *args, **kwargs): def set_icon(self): """Set the window icon.""" - meta = pickler.load_metadata(config.EXTRACTED_DATA_PATH) + meta = pickler.load_metadata(config.EXTRACTED_DATA_PATH)[0] image_to_iconify = Image.open(pyinstaller_helper.resource_path(meta.icon_path)) icon_in_correct_format = ImageTk.PhotoImage(image_to_iconify) self.parent.wm_iconphoto(False, icon_in_correct_format) @@ -87,7 +87,8 @@ def __init__(self, parent: tkinter.Tk, *args, **kwargs): def handle_event(self, event: events.Event): """Handle events on the queue.""" if event.name == events.EventType.MSIX_METADATA_RECEIVED: - metadata: msix.MsixMetadata = event.data + # Only need the image from the first metadata entry + metadata: msix.MsixMetadata = event.data[0] image_path = pyinstaller_helper.resource_path(metadata.scaled_icon_path) scaled_image = Image.open(image_path) self.img = ImageTk.PhotoImage(scaled_image) @@ -122,9 +123,14 @@ def __init__(self, parent: tkinter.Tk, *args, **kwargs): self.version_content = ttk.Label(self, text="v0.0.0.0") self.version_content.grid(row=2, column=1, sticky="W") - # Not sure what's causing these to get cut off - install_type_label = ttk.Label(self, text="Installnstall Globally") - install_type_label.grid(row=3, column=0, sticky="W") + dependency_count_title = ttk.Label(self, text="Dependencies:") + dependency_count_title.grid(row=3, column=0, sticky="W") + + self.dependency_count = ttk.Label(self, text="0") + self.dependency_count.grid(row=3, column=1, sticky="W") + + install_type_label = ttk.Label(self, text="Install for all users") + install_type_label.grid(row=4, column=0, sticky="W") self.global_install_checkbox_state = tkinter.BooleanVar(self) is_admin = pyuac.isUserAdmin() @@ -134,14 +140,14 @@ def __init__(self, parent: tkinter.Tk, *args, **kwargs): variable=self.global_install_checkbox_state, command=self.on_checkbox_change, ) - global_install_checkbox.grid(row=3, column=0, sticky="W") + global_install_checkbox.grid(row=4, column=1, sticky="W") button = ttk.Button( self, text="Install", command=self.install, ) - button.grid(row=4, column=1) + button.grid(row=5, column=1) def on_checkbox_change(self): """On change to checkbox.""" @@ -158,11 +164,14 @@ def on_checkbox_change(self): def handle_event(self, event: events.Event): """Handle events on the queue.""" if event.name == events.EventType.MSIX_METADATA_RECEIVED: - data: msix.MsixMetadata = event.data + all_data: list[msix.MsixMetadata] = event.data + data = all_data[0] title_text = f"Install {data.package_name}" self.title.configure(text=title_text) self.version_content.configure(text=data.version) self.author_content.configure(text=data.publisher) + dep_count = len(all_data) - 1 + self.dependency_count.configure(text=dep_count) def install(self): """Install the MSIX.""" @@ -178,24 +187,37 @@ def __init__(self, parent, *args, **kwargs): ttk.Frame.__init__(self, parent, *args, **kwargs) self.parent: tkinter.Tk = parent - self.status = ttk.Label(self, text="Starting...") - self.status.grid(row=0, column=0) + self.title = ttk.Label(self, text="Starting...") + self.title.grid(row=0, column=0) + + self.subtitle = ttk.Label(self, text="") + self.subtitle.grid(row=1, column=0) self.progress = ttk.Progressbar(self, length=200, mode="indeterminate") - self.progress.grid(row=1, column=0) - self.progress.start(interval=200) + self.progress.grid(row=2, column=0) + self.progress.start(interval=2000) done_button = ttk.Button( self, text="Done", command=lambda: self.parent.switch_frame(InfoScreenContainer), ) - done_button.grid(row=2, column=0) + done_button.grid(row=3, column=0) - def handle_event(self, event): + def handle_event(self, event: events.Event): if event.name == events.EventType.INSTALL_PROGRESS_TEXT: - text = event.data["text"] - self.status.configure(text=text) + try: + text = event.data["title"] + self.title.configure(text=text) + except KeyError: + # Main text wasn't included in the data + pass + try: + text = event.data["subtitle"] + self.subtitle.configure(text=text) + except KeyError: + # Sub text wasn't included in the data + pass try: progress_percentage = int(event.data["progress"]) logger.info("Updating progress bar to: %s", progress_percentage) diff --git a/src/msix_global_installer/msix.py b/src/msix_global_installer/msix.py index 4140713..3e21ec4 100644 --- a/src/msix_global_installer/msix.py +++ b/src/msix_global_installer/msix.py @@ -41,13 +41,17 @@ class ReturnCodeResult: return_code: int -def get_msix_metadata(msix_path: str, output_path: pathlib.Path) -> MsixMetadata: +def get_msix_metadata( + msix_path: str, output_icon_path: pathlib.Path | None = None +) -> MsixMetadata: """ Extract Metadata from MSIX package. + Output path is used for the icon. + Note this does not support APPXBUNDLE. """ - if not output_path.exists(): + if output_icon_path and not output_icon_path.exists(): raise Exception("Path doesn't exist") try: @@ -94,16 +98,18 @@ def get_msix_metadata(msix_path: str, output_path: pathlib.Path) -> MsixMetadata ) extracted_icon_path = 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_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) + 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 @@ -142,10 +148,17 @@ def count_progress(line: str, max_count: int) -> int | None: percentage = count / max_count * 100 except ZeroDivisionError: return 0 + return ceil(percentage) -def install_msix(path: pathlib.Path, global_install: bool = False): +def install_msix( + path: pathlib.Path, + title: str, + global_install: bool = False, + packages_to_install: int = 1, + package_number: int = 1, +): """Install an MSIX package.""" # TODO: If global install ensure we are running as admin global_install_command = ( @@ -176,52 +189,109 @@ def install_msix(path: pathlib.Path, global_install: bool = False): + os.linesep ) - error = None - retcode = None + error: str | None = None + retcode: int | None = None while proc.isalive(): line = proc.readline() logger.debug("%r\n\r", line) result = process_line(line) - if isinstance(result, ProgressResult): - event = events.Event( - name=events.EventType.INSTALL_PROGRESS_TEXT, - data={"text": "Installing", "progress": result.progress}, - ) - events.post_event_sync(event, event_queue=events.gui_event_queue) - elif isinstance(result, ErrorResult): - error = result.error - event = events.Event( - name=events.EventType.INSTALL_PROGRESS_TEXT, - data={"text": result.error.args[0], "progress": 100}, - ) - events.post_event_sync(event, event_queue=events.gui_event_queue) - elif isinstance(result, ReturnCodeResult): - retcode = result.return_code - if retcode > 1 and error is None: - event = events.Event( - name=events.EventType.INSTALL_PROGRESS_TEXT, - data={"text": "Error Occurred", "progress": 100}, - ) - events.post_event_sync(event, event_queue=events.gui_event_queue) + # 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) + if isinstance(result, ErrorResult): + error = result.error if not error else error + if not should_continue: break logger.debug("Process is closed") - if retcode == 0 and not error: + # Set progress to 100 + progress = progress_mincer(100, packages_to_install, package_number) + logger.error("Progress: " + str(progress)) + event = events.Event( + name=events.EventType.INSTALL_PROGRESS_TEXT, + data={"progress": progress}, + ) + events.post_event_sync(event, event_queue=events.gui_event_queue) + + return check_has_succeeded(return_code=retcode, error=error, package_title=title) + + +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: logger.info("Should have installed successfully!") - install_complete_text = "Install Complete" + install_complete_text = f"Install of {package_title} complete" event = events.Event( name=events.EventType.INSTALL_PROGRESS_TEXT, - data={"text": install_complete_text, "progress": 100}, + data={"title": install_complete_text}, ) events.post_event_sync(event, event_queue=events.gui_event_queue) + return True else: logger.error("Install failed") - logger.error("Retcode is: %s", retcode) + logger.error("Retcode is: %s", return_code) logger.error("Error is: %s", error) + if error is None and return_code is None: + # Terminal must have force quit - won't have an error message + install_complete_text = f"Install of {package_title} failed" + event = events.Event( + name=events.EventType.INSTALL_PROGRESS_TEXT, + data={"title": install_complete_text}, + ) + events.post_event_sync(event, event_queue=events.gui_event_queue) + return False -def process_line(line) -> ProgressResult | ErrorResult | ReturnCodeResult: +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): + event = events.Event( + name=events.EventType.INSTALL_PROGRESS_TEXT, + data={ + "title": f"Installing {package_title}", + "progress": progress_mincer( + result.progress, packages_to_install, package_number + ), + }, + ) + events.post_event_sync(event, event_queue=events.gui_event_queue) + return (True, None) + elif isinstance(result, ErrorResult): + # Only need to push the first error, otherwise it will keep finding errors + # in the output and switch quickly + if not current_error: + event = events.Event( + name=events.EventType.INSTALL_PROGRESS_TEXT, + data={ + "title": f"Failed to install {package_title}", + "subtitle": result.error.args[0], + "progress": 100, + }, + ) + events.post_event_sync(event, event_queue=events.gui_event_queue) + return (True, None) + elif isinstance(result, ReturnCodeResult): + retcode = result.return_code + if retcode > 1 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) + # Not a matching line - continue + return (True, None) + + +def process_line(line) -> ProgressResult | ErrorResult | ReturnCodeResult | None: progress = count_progress(line=line, max_count=68) if progress: return ProgressResult(progress=progress) @@ -270,3 +340,14 @@ def parse_retcode(line: str) -> int: except ValueError: return None return int_retcode + + +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) + stage_progress = total_for_stage * (package_progress / 100) + overall_progress = zero_point + stage_progress + return ceil(overall_progress) diff --git a/src/msix_global_installer/pickler.py b/src/msix_global_installer/pickler.py index ccf4dad..43a21cd 100644 --- a/src/msix_global_installer/pickler.py +++ b/src/msix_global_installer/pickler.py @@ -3,13 +3,13 @@ from msix_global_installer import msix -def save_metadata(data_file_path: pathlib.Path, metadata: msix.MsixMetadata): +def save_metadata(data_file_path: pathlib.Path, metadata_list: list[msix.MsixMetadata]): """Save MSIX metadata to a pickle file.""" with open(data_file_path, "wb") as file: - pickle.dump(metadata, file) + pickle.dump(metadata_list, file) -def load_metadata(data_file_path: pathlib.Path) -> msix.MsixMetadata: +def load_metadata(data_file_path: pathlib.Path) -> list[msix.MsixMetadata]: """Load MSIX metadata from a pickle file.""" with open(data_file_path, "rb") as file: return pickle.load(file) diff --git a/tests/test_msix.py b/tests/test_msix.py index 60c17e8..d630f5e 100644 --- a/tests/test_msix.py +++ b/tests/test_msix.py @@ -10,7 +10,7 @@ def test_get_msix_metadata(self, tmpdir): """Test we get the required metadata from a given test file.""" path = pathlib.Path("tests/TestMsixPackage.msix") dir = tmpdir - data = msix.get_msix_metadata(path, output_path=dir) + data = msix.get_msix_metadata(path, output_icon_path=dir) assert data.package_name == "MyEmployees" assert data.version == "9.0.0.0" assert data.publisher == "Contoso Corporation"