1  """
       2  File generation for APPX/MSIX manifests.
       3  """
       4  
       5  __author__ = "Steve Dower <steve.dower@python.org>"
       6  __version__ = "3.8"
       7  
       8  
       9  import collections
      10  import ctypes
      11  import io
      12  import os
      13  import sys
      14  
      15  from pathlib import Path, PureWindowsPath
      16  from xml.etree import ElementTree as ET
      17  
      18  from .constants import *
      19  
      20  __all__ = ["get_appx_layout"]
      21  
      22  
      23  APPX_DATA = dict(
      24      Name="PythonSoftwareFoundation.Python.{}".format(VER_DOT),
      25      Version="{}.{}.{}.0".format(VER_MAJOR, VER_MINOR, VER_FIELD3),
      26      Publisher=os.getenv(
      27          "APPX_DATA_PUBLISHER", "CN=4975D53F-AA7E-49A5-8B49-EA4FDC1BB66B"
      28      ),
      29      DisplayName="Python {}".format(VER_DOT),
      30      Description="The Python {} runtime and console.".format(VER_DOT),
      31  )
      32  
      33  APPX_PLATFORM_DATA = dict(
      34      _keys=("ProcessorArchitecture",),
      35      win32=("x86",),
      36      amd64=("x64",),
      37      arm32=("arm",),
      38      arm64=("arm64",),
      39  )
      40  
      41  PYTHON_VE_DATA = dict(
      42      DisplayName="Python {}".format(VER_DOT),
      43      Description="Python interactive console",
      44      Square150x150Logo="_resources/pythonx150.png",
      45      Square44x44Logo="_resources/pythonx44.png",
      46      BackgroundColor="transparent",
      47  )
      48  
      49  PYTHONW_VE_DATA = dict(
      50      DisplayName="Python {} (Windowed)".format(VER_DOT),
      51      Description="Python windowed app launcher",
      52      Square150x150Logo="_resources/pythonwx150.png",
      53      Square44x44Logo="_resources/pythonwx44.png",
      54      BackgroundColor="transparent",
      55      AppListEntry="none",
      56  )
      57  
      58  PIP_VE_DATA = dict(
      59      DisplayName="pip (Python {})".format(VER_DOT),
      60      Description="pip package manager for Python {}".format(VER_DOT),
      61      Square150x150Logo="_resources/pythonx150.png",
      62      Square44x44Logo="_resources/pythonx44.png",
      63      BackgroundColor="transparent",
      64      AppListEntry="none",
      65  )
      66  
      67  IDLE_VE_DATA = dict(
      68      DisplayName="IDLE (Python {})".format(VER_DOT),
      69      Description="IDLE editor for Python {}".format(VER_DOT),
      70      Square150x150Logo="_resources/idlex150.png",
      71      Square44x44Logo="_resources/idlex44.png",
      72      BackgroundColor="transparent",
      73  )
      74  
      75  PY_PNG = "_resources/py.png"
      76  
      77  APPXMANIFEST_NS = {
      78      "": "http://schemas.microsoft.com/appx/manifest/foundation/windows10",
      79      "m": "http://schemas.microsoft.com/appx/manifest/foundation/windows10",
      80      "uap": "http://schemas.microsoft.com/appx/manifest/uap/windows10",
      81      "rescap": "http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities",
      82      "rescap4": "http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities/4",
      83      "desktop4": "http://schemas.microsoft.com/appx/manifest/desktop/windows10/4",
      84      "desktop6": "http://schemas.microsoft.com/appx/manifest/desktop/windows10/6",
      85      "uap3": "http://schemas.microsoft.com/appx/manifest/uap/windows10/3",
      86      "uap4": "http://schemas.microsoft.com/appx/manifest/uap/windows10/4",
      87      "uap5": "http://schemas.microsoft.com/appx/manifest/uap/windows10/5",
      88  }
      89  
      90  APPXMANIFEST_TEMPLATE = """<?xml version="1.0" encoding="utf-8"?>
      91  <Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
      92      xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
      93      xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
      94      xmlns:rescap4="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities/4"
      95      xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
      96      xmlns:uap4="http://schemas.microsoft.com/appx/manifest/uap/windows10/4"
      97      xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5">
      98      <Identity Name=""
      99                Version=""
     100                Publisher=""
     101                ProcessorArchitecture="" />
     102      <Properties>
     103          <DisplayName></DisplayName>
     104          <PublisherDisplayName>Python Software Foundation</PublisherDisplayName>
     105          <Description></Description>
     106          <Logo>_resources/pythonx50.png</Logo>
     107      </Properties>
     108      <Resources>
     109          <Resource Language="en-US" />
     110      </Resources>
     111      <Dependencies>
     112          <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="" />
     113      </Dependencies>
     114      <Capabilities>
     115          <rescap:Capability Name="runFullTrust"/>
     116      </Capabilities>
     117      <Applications>
     118      </Applications>
     119      <Extensions>
     120      </Extensions>
     121  </Package>"""
     122  
     123  
     124  RESOURCES_XML_TEMPLATE = r"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
     125  <!--This file is input for makepri.exe. It should be excluded from the final package.-->
     126  <resources targetOsVersion="10.0.0" majorVersion="1">
     127      <packaging>
     128          <autoResourcePackage qualifier="Language"/>
     129          <autoResourcePackage qualifier="Scale"/>
     130          <autoResourcePackage qualifier="DXFeatureLevel"/>
     131      </packaging>
     132      <index root="\" startIndexAt="\">
     133          <default>
     134              <qualifier name="Language" value="en-US"/>
     135              <qualifier name="Contrast" value="standard"/>
     136              <qualifier name="Scale" value="100"/>
     137              <qualifier name="HomeRegion" value="001"/>
     138              <qualifier name="TargetSize" value="256"/>
     139              <qualifier name="LayoutDirection" value="LTR"/>
     140              <qualifier name="Theme" value="dark"/>
     141              <qualifier name="AlternateForm" value=""/>
     142              <qualifier name="DXFeatureLevel" value="DX9"/>
     143              <qualifier name="Configuration" value=""/>
     144              <qualifier name="DeviceFamily" value="Universal"/>
     145              <qualifier name="Custom" value=""/>
     146          </default>
     147          <indexer-config type="folder" foldernameAsQualifier="true" filenameAsQualifier="true" qualifierDelimiter="$"/>
     148          <indexer-config type="resw" convertDotsToSlashes="true" initialPath=""/>
     149          <indexer-config type="resjson" initialPath=""/>
     150          <indexer-config type="PRI"/>
     151      </index>
     152  </resources>"""
     153  
     154  
     155  SCCD_FILENAME = "PC/classicAppCompat.sccd"
     156  
     157  SPECIAL_LOOKUP = object()
     158  
     159  REGISTRY = {
     160      "HKCU\\Software\\Python\\PythonCore": {
     161          VER_DOT: {
     162              "DisplayName": APPX_DATA["DisplayName"],
     163              "SupportUrl": "https://www.python.org/",
     164              "SysArchitecture": SPECIAL_LOOKUP,
     165              "SysVersion": VER_DOT,
     166              "Version": "{}.{}.{}".format(VER_MAJOR, VER_MINOR, VER_MICRO),
     167              "InstallPath": {
     168                  "": "[{AppVPackageRoot}]",
     169                  "ExecutablePath": "[{{AppVPackageRoot}}]\\python{}.exe".format(VER_DOT),
     170                  "WindowedExecutablePath": "[{{AppVPackageRoot}}]\\pythonw{}.exe".format(
     171                      VER_DOT
     172                  ),
     173              },
     174              "Help": {
     175                  "Main Python Documentation": {
     176                      "_condition": lambda ns: ns.include_chm,
     177                      "": "[{{AppVPackageRoot}}]\\Doc\\{}".format(PYTHON_CHM_NAME),
     178                  },
     179                  "Local Python Documentation": {
     180                      "_condition": lambda ns: ns.include_html_doc,
     181                      "": "[{AppVPackageRoot}]\\Doc\\html\\index.html",
     182                  },
     183                  "Online Python Documentation": {
     184                      "": "https://docs.python.org/{}".format(VER_DOT)
     185                  },
     186              },
     187              "Idle": {
     188                  "_condition": lambda ns: ns.include_idle,
     189                  "": "[{AppVPackageRoot}]\\Lib\\idlelib\\idle.pyw",
     190              },
     191          }
     192      }
     193  }
     194  
     195  
     196  def get_packagefamilyname(name, publisher_id):
     197      class ESC[4;38;5;81mPACKAGE_ID(ESC[4;38;5;149mctypesESC[4;38;5;149m.ESC[4;38;5;149mStructure):
     198          _fields_ = [
     199              ("reserved", ctypes.c_uint32),
     200              ("processorArchitecture", ctypes.c_uint32),
     201              ("version", ctypes.c_uint64),
     202              ("name", ctypes.c_wchar_p),
     203              ("publisher", ctypes.c_wchar_p),
     204              ("resourceId", ctypes.c_wchar_p),
     205              ("publisherId", ctypes.c_wchar_p),
     206          ]
     207          _pack_ = 4
     208  
     209      pid = PACKAGE_ID(0, 0, 0, name, publisher_id, None, None)
     210      result = ctypes.create_unicode_buffer(256)
     211      result_len = ctypes.c_uint32(256)
     212      r = ctypes.windll.kernel32.PackageFamilyNameFromId(
     213          pid, ctypes.byref(result_len), result
     214      )
     215      if r:
     216          raise OSError(r, "failed to get package family name")
     217      return result.value[: result_len.value]
     218  
     219  
     220  def _fixup_sccd(ns, sccd, new_hash=None):
     221      if not new_hash:
     222          return sccd
     223  
     224      NS = dict(s="http://schemas.microsoft.com/appx/2016/sccd")
     225      with open(sccd, "rb") as f:
     226          xml = ET.parse(f)
     227  
     228      pfn = get_packagefamilyname(APPX_DATA["Name"], APPX_DATA["Publisher"])
     229  
     230      ae = xml.find("s:AuthorizedEntities", NS)
     231      ae.clear()
     232  
     233      e = ET.SubElement(ae, ET.QName(NS["s"], "AuthorizedEntity"))
     234      e.set("AppPackageFamilyName", pfn)
     235      e.set("CertificateSignatureHash", new_hash)
     236  
     237      for e in xml.findall("s:Catalog", NS):
     238          e.text = "FFFF"
     239  
     240      sccd = ns.temp / sccd.name
     241      sccd.parent.mkdir(parents=True, exist_ok=True)
     242      with open(sccd, "wb") as f:
     243          xml.write(f, encoding="utf-8")
     244  
     245      return sccd
     246  
     247  
     248  def find_or_add(xml, element, attr=None, always_add=False):
     249      if always_add:
     250          e = None
     251      else:
     252          q = element
     253          if attr:
     254              q += "[@{}='{}']".format(*attr)
     255          e = xml.find(q, APPXMANIFEST_NS)
     256      if e is None:
     257          prefix, _, name = element.partition(":")
     258          name = ET.QName(APPXMANIFEST_NS[prefix or ""], name)
     259          e = ET.SubElement(xml, name)
     260          if attr:
     261              e.set(*attr)
     262      return e
     263  
     264  
     265  def _get_app(xml, appid):
     266      if appid:
     267          app = xml.find(
     268              "m:Applications/m:Application[@Id='{}']".format(appid), APPXMANIFEST_NS
     269          )
     270          if app is None:
     271              raise LookupError(appid)
     272      else:
     273          app = xml
     274      return app
     275  
     276  
     277  def add_visual(xml, appid, data):
     278      app = _get_app(xml, appid)
     279      e = find_or_add(app, "uap:VisualElements")
     280      for i in data.items():
     281          e.set(*i)
     282      return e
     283  
     284  
     285  def add_alias(xml, appid, alias, subsystem="windows"):
     286      app = _get_app(xml, appid)
     287      e = find_or_add(app, "m:Extensions")
     288      e = find_or_add(e, "uap5:Extension", ("Category", "windows.appExecutionAlias"))
     289      e = find_or_add(e, "uap5:AppExecutionAlias")
     290      e.set(ET.QName(APPXMANIFEST_NS["desktop4"], "Subsystem"), subsystem)
     291      e = find_or_add(e, "uap5:ExecutionAlias", ("Alias", alias))
     292  
     293  
     294  def add_file_type(xml, appid, name, suffix, parameters='"%1"', info=None, logo=None):
     295      app = _get_app(xml, appid)
     296      e = find_or_add(app, "m:Extensions")
     297      e = find_or_add(e, "uap3:Extension", ("Category", "windows.fileTypeAssociation"))
     298      e = find_or_add(e, "uap3:FileTypeAssociation", ("Name", name))
     299      e.set("Parameters", parameters)
     300      if info:
     301          find_or_add(e, "uap:DisplayName").text = info
     302      if logo:
     303          find_or_add(e, "uap:Logo").text = logo
     304      e = find_or_add(e, "uap:SupportedFileTypes")
     305      if isinstance(suffix, str):
     306          suffix = [suffix]
     307      for s in suffix:
     308          ET.SubElement(e, ET.QName(APPXMANIFEST_NS["uap"], "FileType")).text = s
     309  
     310  
     311  def add_application(
     312      ns, xml, appid, executable, aliases, visual_element, subsystem, file_types
     313  ):
     314      node = xml.find("m:Applications", APPXMANIFEST_NS)
     315      suffix = "_d.exe" if ns.debug else ".exe"
     316      app = ET.SubElement(
     317          node,
     318          ET.QName(APPXMANIFEST_NS[""], "Application"),
     319          {
     320              "Id": appid,
     321              "Executable": executable + suffix,
     322              "EntryPoint": "Windows.FullTrustApplication",
     323              ET.QName(APPXMANIFEST_NS["desktop4"], "SupportsMultipleInstances"): "true",
     324          },
     325      )
     326      if visual_element:
     327          add_visual(app, None, visual_element)
     328      for alias in aliases:
     329          add_alias(app, None, alias + suffix, subsystem)
     330      if file_types:
     331          add_file_type(app, None, *file_types)
     332      return app
     333  
     334  
     335  def _get_registry_entries(ns, root="", d=None):
     336      r = root if root else PureWindowsPath("")
     337      if d is None:
     338          d = REGISTRY
     339      for key, value in d.items():
     340          if key == "_condition":
     341              continue
     342          if value is SPECIAL_LOOKUP:
     343              if key == "SysArchitecture":
     344                  value = {
     345                      "win32": "32bit",
     346                      "amd64": "64bit",
     347                      "arm32": "32bit",
     348                      "arm64": "64bit",
     349                  }[ns.arch]
     350              else:
     351                  raise ValueError(f"Key '{key}' unhandled for special lookup")
     352          if isinstance(value, dict):
     353              cond = value.get("_condition")
     354              if cond and not cond(ns):
     355                  continue
     356              fullkey = r
     357              for part in PureWindowsPath(key).parts:
     358                  fullkey /= part
     359                  if len(fullkey.parts) > 1:
     360                      yield str(fullkey), None, None
     361              yield from _get_registry_entries(ns, fullkey, value)
     362          elif len(r.parts) > 1:
     363              yield str(r), key, value
     364  
     365  
     366  def add_registry_entries(ns, xml):
     367      e = find_or_add(xml, "m:Extensions")
     368      e = find_or_add(e, "rescap4:Extension")
     369      e.set("Category", "windows.classicAppCompatKeys")
     370      e.set("EntryPoint", "Windows.FullTrustApplication")
     371      e = ET.SubElement(e, ET.QName(APPXMANIFEST_NS["rescap4"], "ClassicAppCompatKeys"))
     372      for name, valuename, value in _get_registry_entries(ns):
     373          k = ET.SubElement(
     374              e, ET.QName(APPXMANIFEST_NS["rescap4"], "ClassicAppCompatKey")
     375          )
     376          k.set("Name", name)
     377          if value:
     378              k.set("ValueName", valuename)
     379              k.set("Value", value)
     380              k.set("ValueType", "REG_SZ")
     381  
     382  
     383  def disable_registry_virtualization(xml):
     384      e = find_or_add(xml, "m:Properties")
     385      e = find_or_add(e, "desktop6:RegistryWriteVirtualization")
     386      e.text = "disabled"
     387      e = find_or_add(xml, "m:Capabilities")
     388      e = find_or_add(e, "rescap:Capability", ("Name", "unvirtualizedResources"))
     389  
     390  
     391  def get_appxmanifest(ns):
     392      for k, v in APPXMANIFEST_NS.items():
     393          ET.register_namespace(k, v)
     394      ET.register_namespace("", APPXMANIFEST_NS["m"])
     395  
     396      xml = ET.parse(io.StringIO(APPXMANIFEST_TEMPLATE))
     397      NS = APPXMANIFEST_NS
     398      QN = ET.QName
     399  
     400      data = dict(APPX_DATA)
     401      for k, v in zip(APPX_PLATFORM_DATA["_keys"], APPX_PLATFORM_DATA[ns.arch]):
     402          data[k] = v
     403  
     404      node = xml.find("m:Identity", NS)
     405      for k in node.keys():
     406          value = data.get(k)
     407          if value:
     408              node.set(k, value)
     409  
     410      for node in xml.find("m:Properties", NS):
     411          value = data.get(node.tag.rpartition("}")[2])
     412          if value:
     413              node.text = value
     414  
     415      try:
     416          winver = tuple(int(i) for i in os.getenv("APPX_DATA_WINVER", "").split(".", maxsplit=3))
     417      except (TypeError, ValueError):
     418          winver = ()
     419  
     420      # Default "known good" version is 10.0.22000, first Windows 11 release
     421      winver = winver or (10, 0, 22000)
     422  
     423      if winver < (10, 0, 17763):
     424          winver = 10, 0, 17763
     425      find_or_add(xml, "m:Dependencies/m:TargetDeviceFamily").set(
     426          "MaxVersionTested", "{}.{}.{}.{}".format(*(winver + (0, 0, 0, 0)[:4]))
     427      )
     428  
     429      # Only for Python 3.11 and later. Older versions do not disable virtualization
     430      if (VER_MAJOR, VER_MINOR) >= (3, 11) and winver > (10, 0, 17763):
     431          disable_registry_virtualization(xml)
     432  
     433      app = add_application(
     434          ns,
     435          xml,
     436          "Python",
     437          "python{}".format(VER_DOT),
     438          ["python", "python{}".format(VER_MAJOR), "python{}".format(VER_DOT)],
     439          PYTHON_VE_DATA,
     440          "console",
     441          ("python.file", [".py"], '"%1" %*', "Python File", PY_PNG),
     442      )
     443  
     444      add_application(
     445          ns,
     446          xml,
     447          "PythonW",
     448          "pythonw{}".format(VER_DOT),
     449          ["pythonw", "pythonw{}".format(VER_MAJOR), "pythonw{}".format(VER_DOT)],
     450          PYTHONW_VE_DATA,
     451          "windows",
     452          ("python.windowedfile", [".pyw"], '"%1" %*', "Python File (no console)", PY_PNG),
     453      )
     454  
     455      if ns.include_pip and ns.include_launchers:
     456          add_application(
     457              ns,
     458              xml,
     459              "Pip",
     460              "pip{}".format(VER_DOT),
     461              ["pip", "pip{}".format(VER_MAJOR), "pip{}".format(VER_DOT)],
     462              PIP_VE_DATA,
     463              "console",
     464              ("python.wheel", [".whl"], 'install "%1"', "Python Wheel"),
     465          )
     466  
     467      if ns.include_idle and ns.include_launchers:
     468          add_application(
     469              ns,
     470              xml,
     471              "Idle",
     472              "idle{}".format(VER_DOT),
     473              ["idle", "idle{}".format(VER_MAJOR), "idle{}".format(VER_DOT)],
     474              IDLE_VE_DATA,
     475              "windows",
     476              None,
     477          )
     478  
     479      if (ns.source / SCCD_FILENAME).is_file():
     480          add_registry_entries(ns, xml)
     481          node = xml.find("m:Capabilities", NS)
     482          node = ET.SubElement(node, QN(NS["uap4"], "CustomCapability"))
     483          node.set("Name", "Microsoft.classicAppCompat_8wekyb3d8bbwe")
     484  
     485      buffer = io.BytesIO()
     486      xml.write(buffer, encoding="utf-8", xml_declaration=True)
     487      return buffer.getbuffer()
     488  
     489  
     490  def get_resources_xml(ns):
     491      return RESOURCES_XML_TEMPLATE.encode("utf-8")
     492  
     493  
     494  def get_appx_layout(ns):
     495      if not ns.include_appxmanifest:
     496          return
     497  
     498      yield "AppxManifest.xml", ("AppxManifest.xml", get_appxmanifest(ns))
     499      yield "_resources.xml", ("_resources.xml", get_resources_xml(ns))
     500      icons = ns.source / "PC" / "icons"
     501      for px in [44, 50, 150]:
     502          src = icons / "pythonx{}.png".format(px)
     503          yield f"_resources/pythonx{px}.png", src
     504          yield f"_resources/pythonx{px}$targetsize-{px}_altform-unplated.png", src
     505      for px in [44, 150]:
     506          src = icons / "pythonwx{}.png".format(px)
     507          yield f"_resources/pythonwx{px}.png", src
     508          yield f"_resources/pythonwx{px}$targetsize-{px}_altform-unplated.png", src
     509      if ns.include_idle and ns.include_launchers:
     510          for px in [44, 150]:
     511              src = icons / "idlex{}.png".format(px)
     512              yield f"_resources/idlex{px}.png", src
     513              yield f"_resources/idlex{px}$targetsize-{px}_altform-unplated.png", src
     514      yield f"_resources/py.png", icons / "py.png"
     515      sccd = ns.source / SCCD_FILENAME
     516      if sccd.is_file():
     517          # This should only be set for side-loading purposes.
     518          sccd = _fixup_sccd(ns, sccd, os.getenv("APPX_DATA_SHA256"))
     519          yield sccd.name, sccd