diff --git a/tools/packaging/README.md b/tools/packaging/README.md new file mode 100644 index 0000000..34e518b --- /dev/null +++ b/tools/packaging/README.md @@ -0,0 +1,68 @@ +# Command line tools to package and isolate applications + +## Package + +You can convert your `msi` or `exe` installers to `msix` using `package.py`. + +### Requirement + +* Python3 +* MSIX Packaging Tool +* A [template](https://learn.microsoft.com/en-us/windows/msix/packaging-tool/generate-template-file) file. + You can also use the [example](./template.xml) we give as a start + +### Usage + +#### Prepare your template + +The best way to generate a template is to use MSIX Packaging Tool to [package](../../docs/packaging/msix-packaging-tool.md#win32---msix) +your application once and save the template. + +However, you can also fill in the template manually. The most important sections are `` and `` + +*Note: you have to fill the `PublisherName` field of `` accurately (matching your cert) in order to sign the package* + +#### Run the script + +``` +python package.py --template template.xml -o app.msix installer.msi +``` + +Under the hood, this is very similar to using +[MSIX Packaging Command Line Tool](https://learn.microsoft.com/en-us/windows/msix/packaging-tool/package-conversion-command-line) + +The script helps you fill the `` and `` according to your input, but feel free to use `MsixPackagingTool.exe` +directly. + +*Note: this step requires an elevation because `MsixPackagingTool.exe` needs it* + +#### Finish the installation + +You need to go though the installation of the application. To make sure this works properly, the app should be uninstalled first +if it's already installed. + +## Isolate + +You can isolate your `msix` package using `isolate.py`. + +### Requirement + +* Python3 +* [MSIX Packaging Tool](https://github.com/microsoft/win32-app-isolation/releases) +* `.pfx` certification to sign your package + +### Usage + +``` +python isolate.py --cert your_cert.pfx -o isolated.msix app.msix +``` + +The command will try to use the `makeappx.exe` and `signtool.exe` from your MSIX Packaging Tool. +If you want to use your own SDK version, use `--sdk_dir` to pass the directory that has the +binaries. + +In order to add capabilities, use `--capability` or `--cap` like + +``` +python isolate.py --cert your_cert.pfx -o isolated.msix --cap runFullTrust --cap isolatedWin32-promptForAccess app.msix +``` diff --git a/tools/packaging/isolate.py b/tools/packaging/isolate.py new file mode 100644 index 0000000..c4e6c27 --- /dev/null +++ b/tools/packaging/isolate.py @@ -0,0 +1,196 @@ +import argparse +import os +import platform +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass + + +@dataclass +class IsolateConfig: + output: str + cert: str + signtool: str + makeappx: str + working_dir: str + msix: str + capabilities: list[str] + + +class Manifest: + def __init__(self, path: str, config: IsolateConfig): + self.path = path + self.config = config + with open(path, "r") as f: + self.str = f.read() + + def modify_package(self, s: str): + m = re.search(r"", s, re.MULTILINE | re.DOTALL) + if m: + package = m.group(0) + if "IgnorableNamespaces" not in package: + package = package.replace(">", ' IgnorableNamespaces="">') + + if "xmlns:previewsecurity2=" not in package: + package = package.replace(">", ' xmlns:previewsecurity2="http://schemas.microsoft.com/appx/manifest/preview/windows10/security/2">') + package = package.replace('IgnorableNamespaces="', 'IgnorableNamespaces="previewsecurity2 ') + + if "xmlns:uap10=" not in package: + package = package.replace(">", ' xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10">') + package = package.replace('IgnorableNamespaces="', 'IgnorableNamespaces="uap10 ') + + if "xmlns:rescap=" not in package: + package = package.replace(">", ' xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities">') + package = package.replace('IgnorableNamespaces="', 'IgnorableNamespaces="rescap ') + + s = s.replace(m.group(0), package) + return s + else: + raise ValueError("No package found in manifest") + + def modify_target_device_family(self, s: str): + m = re.search(r"", s, re.MULTILINE | re.DOTALL) + if m: + target_device_family = m.group(0) + s = s.replace(target_device_family, '') + return s + + def modify_application(self, s: str): + for m in re.finditer(r"", s, re.MULTILINE | re.DOTALL): + application = m.group(0) + application = re.sub('EntryPoint=".*?"', "", application) + application = re.sub(' .*?TrustLevel=".*?"', "", application) + application = re.sub(' .*?RuntimeBehavior=".*?"', "", application) + application = application.replace(">", ' uap10:TrustLevel="appContainer" previewsecurity2:RuntimeBehavior="appSilo">') + s = s.replace(m.group(0), application) + return s + + def modify_capabilities(self, s: str): + m = re.search(r".*?", s, re.MULTILINE | re.DOTALL) + if m: + capabilities = m.group(0) + capabilities = re.sub(r'', '', capabilities) + for capability in self.config.capabilities: + capabilities = capabilities.replace("", f'\n') + s = s.replace(m.group(0), capabilities) + elif self.config.capabilities: + capabilities = '\n' + for capability in self.config.capabilities: + capabilities = capabilities.replace("", f'\n') + s = s.replace("", capabilities + "") + return s + + def process(self): + self.str = self.modify_package(self.str) + self.str = self.modify_target_device_family(self.str) + self.str = self.modify_application(self.str) + self.str = self.modify_capabilities(self.str) + + def save(self, path=None): + if not path: + path = self.path + with open(path, "w") as f: + f.write(self.str) + + +def unpack(config: IsolateConfig): + print("Unpacking...") + # Create working directory + os.makedirs(config.working_dir, exist_ok=True) + + # Extract the package + subprocess.check_call([config.makeappx, "unpack", "/p", config.msix, "/d", os.path.join(config.working_dir, "unpack")], shell=True) + + +def pack_and_sign(config: IsolateConfig): + print("Repacking...") + # Repack the package + subprocess.check_call([config.makeappx, "pack", "/nv", "/d", os.path.join(config.working_dir, 'unpack'), "/p" ,config.output], shell=True) + + # Sign the package + subprocess.check_call([config.signtool, "sign", "/fd", "SHA256", "/f", config.cert, config.output], shell=True) + + +def modify_manifest(config: IsolateConfig): + manifest = Manifest(os.path.join(config.working_dir, "unpack", "AppxManifest.xml"), config) + manifest.process() + manifest.save() + + +def find_sdk_dir(tmpdir): + """ + Find the SDK dir associated with MSIX Packaging Tool and copy it to tmpdir + We need to copy it because we don't have access to execute it in the original location + """ + stdout = subprocess.check_output(["powershell.exe", "Get-AppxPackage", "-name", "Microsoft.MSIXPackagingTool"]) + + match = re.search(r"InstallLocation\s+:\s+(.*)", stdout.decode("utf-8")) + if match is None: + return None + original_sdk_dir = os.path.join(match.group(1).strip(), "SDK") + sdk_dir = os.path.join(tmpdir, "SDK") + shutil.copytree(original_sdk_dir, sdk_dir) + return sdk_dir + + +def check_requirements(args): + if platform.system() != "Windows": + print("This script only works on Windows") + exit(1) + + if args.sdk_dir is None: + print("MSIX Packaging Tool is not installed, please either install it or pass your SDK directory with --sdk_dir") + exit(1) + + try: + subprocess.call([os.path.join(args.sdk_dir, "signtool.exe"), "/?"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, shell=True) + except FileNotFoundError: + print(f"signtool.exe is not found in {args.sdk_dir}") + exit(1) + + try: + subprocess.call([os.path.join(args.sdk_dir, "makeappx.exe"), "/?"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, shell=True) + except FileNotFoundError: + print(f"makeappx.exe is not found in {args.sdk_dir}") + exit(1) + + for capability in args.capability: + if capability != "runFullTrust" and not capability.startswith("isolatedWin32"): + print(f"Invalid capability: {capability}. Only runFullTrust and isolatedWin32-* are supported") + exit(1) + + +def main(): + with tempfile.TemporaryDirectory() as tmpdir: + parser = argparse.ArgumentParser() + parser.add_argument("--output", "-o", default=None, required=True) + parser.add_argument("--cert", "-c", required=True) + parser.add_argument("--capability", "--cap", action="append", default=[]) + parser.add_argument("--sdk_dir", default=find_sdk_dir(tmpdir)) + parser.add_argument("msix") + + args = parser.parse_args() + + check_requirements(args) + + config = IsolateConfig( + output=os.path.abspath(args.output), + cert=args.cert, + signtool=os.path.join(args.sdk_dir, "signtool.exe"), + makeappx=os.path.join(args.sdk_dir, "makeappx.exe"), + working_dir=os.path.abspath(tmpdir), + msix=os.path.abspath(args.msix) if args.msix else None, + capabilities=args.capability, + ) + + unpack(config) + modify_manifest(config) + pack_and_sign(config) + + +if __name__ == "__main__": + main() diff --git a/tools/packaging/package.py b/tools/packaging/package.py new file mode 100644 index 0000000..a2591b4 --- /dev/null +++ b/tools/packaging/package.py @@ -0,0 +1,74 @@ +import argparse +import os +import subprocess +import tempfile +import xml.etree.ElementTree as XMLET +from dataclasses import dataclass + + +@dataclass +class PackageConfig: + template: str + output: str + working_dir: str + installer: str + + +def make_msix(config: PackageConfig): + ns = { + "V1": "http://schemas.microsoft.com/appx/msixpackagingtool/template/2018", + "V2": "http://schemas.microsoft.com/appx/msixpackagingtool/template/1904", + "V3": "http://schemas.microsoft.com/appx/msixpackagingtool/template/1907", + "V4": "http://schemas.microsoft.com/appx/msixpackagingtool/template/1910", + "V5": "http://schemas.microsoft.com/appx/msixpackagingtool/template/2001", + } + tree = XMLET.parse(config.template) + root = tree.getroot() + + # Set the save location for template + save_location = root.find("V1:SaveLocation", ns) + if not save_location: + save_location = XMLET.Element(f"{{{ns['V1']}}}SaveLocation") + save_location.set("PackagePath", config.output) + root.append(save_location) + + # Set the installer to use + installer = root.find("V1:Installer", ns) + if not installer: + if not config.installer: + print("No installer is passed, please pass your installer or add it to the template") + exit(1) + installer = XMLET.Element(f"{{{ns['V1']}}}Installer") + installer.set(f"Path", config.installer) + if config.installer.endswith(".exe"): + installer.set(f"Arguments", "/qn /norestart INSTALLSTARTMENUSHORTCUTS=1 DISABLEADVTSHORTCUTS=1") + root.append(installer) + + template_path = os.path.join(config.working_dir, "template.xml") + tree.write(template_path) + + # Create the package + subprocess.call(f"MsixPackagingTool.exe create-package --template {template_path}", shell=True) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--template", "-t", required=True) + parser.add_argument("--output", "-o", default="app.msix") + parser.add_argument("installer", nargs="?") + + args = parser.parse_args() + + with tempfile.TemporaryDirectory() as tmpdir: + config = PackageConfig( + template=args.template, + output=os.path.abspath(args.output), + working_dir=os.path.abspath(tmpdir), + installer=os.path.abspath(args.installer) if args.installer else None, + ) + + make_msix(config) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/packaging/template.xml b/tools/packaging/template.xml new file mode 100644 index 0000000..107cc53 --- /dev/null +++ b/tools/packaging/template.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +