#!/usr/bin/env python # License: GPLv3 Copyright: 2023, Kovid Goyal # See https://github.com/mstorsjo/msvc-wine/blob/master/vsdownload.py and # https://github.com/Jake-Shadle/xwin/blob/main/src/lib.rs for the basic logic # used to download and install the needed VisualStudio packages import argparse import concurrent.futures import glob import hashlib import json import os import re import shutil import subprocess import sys from collections import defaultdict from dataclasses import dataclass from functools import partial from pprint import pprint from tempfile import TemporaryDirectory from urllib.parse import unquote from urllib.request import urlopen from zipfile import ZipFile @dataclass class File: filename: str url: str size: int sha256: str def __init__(self, pf, filename=''): self.filename=filename or pf['fileName'] self.url=pf['url'] self.size=pf['size'] self.sha256=pf['sha256'].lower() def package_sort_key(p): chip = 0 if p.get('chip', '').lower() == 'x64' else 1 language = 0 if p.get('language', '').lower().startswith('en-') else 1 return chip, language def llvm_arch_to_ms_arch(arch): return {'x86_64': 'x64', 'aarch64': 'arm64', 'x64': 'x64', 'arm64': 'arm64'}[arch] class Packages: def __init__(self, manifest_raw, crt_variant, arch): arch = llvm_arch_to_ms_arch(arch) self.manifest = json.loads(manifest_raw) self.packages = defaultdict(list) self.cabinet_entries = {} for p in self.manifest['packages']: pid = p['id'].lower() self.packages[pid].append(p) for v in self.packages.values(): v.sort(key=package_sort_key) build_tools = self.packages[ 'Microsoft.VisualStudio.Product.BuildTools'.lower()][0] pat = re.compile(r'Microsoft\.VisualStudio\.Component\.VC\.(.+)\.x86\.x64') latest = (0, 0, 0, 0) self.crt_version = '' for dep in build_tools['dependencies']: m = pat.match(dep) if m is not None: parts = m.group(1).split('.') if len(parts) > 1: q = tuple(map(int, parts)) if q > latest: self.crt_version = m.group(1) latest = q if not self.crt_version: raise KeyError('Failed to find CRT version from build tools deps') self.files_to_download = [] def add_package(key): p = self.packages.get(key.lower()) if not p: raise KeyError(f'No package named {key} found') for pf in p[0]['payloads']: self.files_to_download.append(File(pf)) # CRT headers add_package(f'Microsoft.VC.{self.crt_version}.CRT.Headers.base') # CRT libs prefix = f'Microsoft.VC.{self.crt_version}.CRT.{arch}.'.lower() variants = {} for pid in self.packages: if pid.startswith(prefix): parts = pid[len(prefix):].split('.') if parts[-1] == 'base': variant = parts[0] if variant not in variants or 'spectre' in parts: # spectre variant contains both spectre and regular libs variants[variant] = pid add_package(variants[crt_variant]) # ATL headers add_package(f'Microsoft.VC.{self.crt_version}.ATL.Headers.base') # ATL libs add_package(f'Microsoft.VC.{self.crt_version}.ATL.{arch}.Spectre.base') add_package(f'Microsoft.VC.{self.crt_version}.ATL.{arch}.base') # WinSDK pat = re.compile(r'Win(\d+)SDK_(\d.+)', re.IGNORECASE) latest_sdk = (0, 0, 0) self.sdk_version = '' sdk_pid = '' for pid in self.packages: m = pat.match(pid) if m is not None: ver = tuple(map(int, m.group(2).split('.'))) if ver > latest_sdk: self.sdk_version = m.group(2) latest_sdk = ver sdk_pid = pid if not self.sdk_version: raise KeyError('Failed to find SDK package') # headers are in x86 package and arch specific package for pf in self.packages[sdk_pid][0]['payloads']: fname = pf['fileName'].split('\\')[-1] if fname.startswith('Windows SDK Desktop Headers '): q = fname[len('Windows SDK Desktop Headers '):] if q.lower() == 'x86-x86_en-us.msi': self.files_to_download.append(File( pf, filename=f'{sdk_pid}_headers.msi')) elif q.lower() == f'{arch}-x86_en-us.msi': self.files_to_download.append(File( pf, filename=f'{sdk_pid}_{arch}_headers.msi')) elif fname == 'Windows SDK for Windows Store Apps Headers-x86_en-us.msi': self.files_to_download.append(File( pf, filename=f'{sdk_pid}_store_headers.msi')) elif fname.startswith('Windows SDK Desktop Libs '): q = fname[len('Windows SDK Desktop Libs '):] if q == f'{arch}-x86_en-us.msi': self.files_to_download.append(File( pf, filename=f'{sdk_pid}_libs_x64.msi')) elif fname == 'Windows SDK for Windows Store Apps Libs-x86_en-us.msi': self.files_to_download.append(File( pf, filename=f'{sdk_pid}_store_libs.msi')) elif (fl := fname.lower()).endswith('.cab'): self.cabinet_entries[fl] = File(pf, filename=fl) # UCRT for pf in self.packages[ 'Microsoft.Windows.UniversalCRT.HeadersLibsSources.Msi'.lower()][0]['payloads']: fname = pf['fileName'].split('\\')[-1] if fname == 'Universal CRT Headers Libraries and Sources-x86_en-us.msi': self.files_to_download.append(File(pf)) self.files_to_download[-1].filename = 'ucrt.msi' elif (fl := fname.lower()).endswith('.cab'): self.cabinet_entries[fl] = File(pf, filename=fl) def download_item(dest_dir: str, file: File): dest = os.path.join(dest_dir, file.filename) m = hashlib.sha256() with urlopen(file.url) as src, open(dest, 'wb') as d: with memoryview(bytearray(shutil.COPY_BUFSIZE)) as buf: while True: n = src.readinto(buf) if not n: break elif n < shutil.COPY_BUFSIZE: with buf[:n] as smv: d.write(smv) m.update(smv) else: d.write(buf) m.update(buf) if m.hexdigest() != file.sha256: raise SystemExit(f'The hash for {file.filename} does not match.' f' {m.hexdigest()} != {file.sha256}') def cabinets_in_msi(path): raw = subprocess.check_output(['msiinfo', 'export', path, 'Media']).decode('utf-8') return re.findall(r'\S+\.cab', raw) def download(dest_dir, manifest_version=17, manifest_type='release', manifest_path='', crt_variant='desktop', arch='x86_64'): if manifest_path: manifest = open(manifest_path, 'rb').read() else: url = f'https://aka.ms/vs/{manifest_version}/{manifest_type}/channel' print('Downloading top-level manifest from', url) tm = json.loads(urlopen(url).read()) print('Got toplevel manifest for', (tm['info']['productDisplayVersion'])) for item in tm['channelItems']: if item.get('type') == 'Manifest': url = item['payloads'][0]['url'] print('Downloading actual manifest...') manifest = urlopen(url).read() pkgs = Packages(manifest, crt_variant, arch) os.makedirs(dest_dir, exist_ok=True) total = sum(x.size for x in pkgs.files_to_download) print('Downloading', int(total/(1024*1024)), 'MB in', len(pkgs.files_to_download), 'files...') with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: for _ in executor.map(partial(download_item, dest_dir), pkgs.files_to_download): pass cabs = [] for x in os.listdir(dest_dir): if x.lower().endswith('.msi'): for cab in cabinets_in_msi(os.path.join(dest_dir, x)): cabs.append(pkgs.cabinet_entries[cab]) total = sum(x.size for x in cabs) print('Downloading', int(total/(1024*1024)), 'MB in', len(cabs), 'files...') with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: for _ in executor.map(partial(download_item, dest_dir), cabs): pass def merge_trees(src, dest): if not os.path.isdir(src): return if not os.path.isdir(dest): shutil.move(src, dest) return destnames = {n.lower():n for n in os.listdir(dest)} for d in os.scandir(src): n = d.name srcname = os.path.join(src, n) destname = os.path.join(dest, n) if d.is_dir(): if os.path.isdir(destname): merge_trees(srcname, destname) elif n.lower() in destnames: merge_trees(srcname, os.path.join(dest, destnames[n.lower()])) else: shutil.move(srcname, destname) else: shutil.move(srcname, destname) def extract_msi(path, dest_dir): print('Extracting', os.path.basename(path), '...') with open(os.path.join(dest_dir, os.path.basename(path) + '.listing'), 'w') as log: subprocess.check_call(['msiextract', '-C', dest_dir, path], stdout=log) def extract_zipfile(zf, dest_dir): tmp = os.path.join(dest_dir, 'extract') os.mkdir(tmp) for f in zf.infolist(): name = unquote(f.filename) dest = os.path.join(dest_dir, name) extracted = zf.extract(f, tmp) os.makedirs(os.path.dirname(dest), exist_ok=True) shutil.move(extracted, dest) shutil.rmtree(tmp) def extract_vsix(path, dest_dir): print('Extracting', os.path.basename(path), '...') with TemporaryDirectory(dir=dest_dir) as tdir, ZipFile(path, 'r') as zf: extract_zipfile(zf, tdir) contents = os.path.join(tdir, 'Contents') merge_trees(contents, dest_dir) names = zf.namelist() with open(os.path.join(dest_dir, os.path.basename(path) + '.listing'), 'w') as ls: ls.write('\n'.join(names)) def move_unpacked_trees(src_dir, dest_dir): # CRT crt_src = os.path.dirname(glob.glob( os.path.join(src_dir, 'VC/Tools/MSVC/*/include'))[0]) crt_dest = os.path.join(dest_dir, 'crt') os.makedirs(crt_dest) merge_trees(os.path.join(crt_src, 'include'), os.path.join(crt_dest, 'include')) merge_trees(os.path.join(crt_src, 'lib'), os.path.join(crt_dest, 'lib')) merge_trees(os.path.join(crt_src, 'atlmfc', 'include'), os.path.join(crt_dest, 'include')) merge_trees(os.path.join(crt_src, 'atlmfc', 'lib'), os.path.join(crt_dest, 'lib')) # SDK sdk_ver = glob.glob(os.path.join(src_dir, 'win11sdk_*_headers.msi.listing'))[0] sdk_ver = sdk_ver.split('_')[1] for x in glob.glob(os.path.join(src_dir, 'Program Files/Windows Kits/*/Include/*')): if os.path.basename(x).startswith(sdk_ver): sdk_ver = os.path.basename(x) break else: raise SystemExit(f'Failed to find sdk_ver: {sdk_ver}') sdk_include_src = glob.glob(os.path.join( src_dir, f'Program Files/Windows Kits/*/Include/{sdk_ver}'))[0] sdk_dest = os.path.join(dest_dir, 'sdk') os.makedirs(sdk_dest) merge_trees(sdk_include_src, os.path.join(sdk_dest, 'include')) sdk_lib_src = glob.glob(os.path.join( src_dir, f'Program Files/Windows Kits/*/Lib/{sdk_ver}'))[0] merge_trees(sdk_lib_src, os.path.join(sdk_dest, 'lib')) # UCRT if os.path.exists(os.path.join(sdk_include_src, 'ucrt')): return ucrt_include_src = glob.glob(os.path.join( src_dir, 'Program Files/Windows Kits/*/Include/*/ucrt'))[0] merge_trees(ucrt_include_src, os.path.join(sdk_dest, 'include', 'ucrt')) ucrt_lib_src = glob.glob(os.path.join( src_dir, 'Program Files/Windows Kits/*/Lib/*/ucrt'))[0] merge_trees(ucrt_lib_src, os.path.join(sdk_dest, 'lib', 'ucrt')) def unpack(src_dir, dest_dir): if os.path.exists(dest_dir): shutil.rmtree(dest_dir) extract_dir = os.path.join(dest_dir, 'extract') os.makedirs(extract_dir) for x in os.listdir(src_dir): path = os.path.join(src_dir, x) ext = os.path.splitext(x)[1].lower() if ext =='.msi': extract_msi(path, extract_dir) elif ext == '.vsix': extract_vsix(path, extract_dir) elif ext == '.cab': continue else: raise SystemExit(f'Unknown downloaded file type: {x}') move_unpacked_trees(extract_dir, dest_dir) shutil.rmtree(extract_dir) def symlink_transformed(path, transform=str.lower): base, name = os.path.split(path) lname = transform(name) if lname != name: npath = os.path.join(base, lname) if not os.path.lexists(npath): os.symlink(name, npath) def clone_tree(src_dir, dest_dir): os.makedirs(dest_dir) for dirpath, dirnames, filenames in os.walk(src_dir): for d in dirnames: path = os.path.join(dirpath, d) rpath = os.path.relpath(path, src_dir) dpath = os.path.join(dest_dir, rpath) os.makedirs(dpath) symlink_transformed(dpath) for f in filenames: if f.lower().endswith('.pdb'): continue path = os.path.join(dirpath, f) rpath = os.path.relpath(path, src_dir) dpath = os.path.join(dest_dir, rpath) os.link(path, dpath) symlink_transformed(dpath) def files_in(path): for dirpath, _, filenames in os.walk(path): for f in filenames: yield os.path.relpath(os.path.join(dirpath, f), path) def create_include_symlinks(path, include_root, include_files): ' Create symlinks for include entries in header files whose case does not match ' with open(path, 'rb') as f: src = f.read() for m in re.finditer(rb'^#include\s+([<"])(.+?)[>"]', src, flags=re.M): spec = m.group(2).decode().replace('\\', '/') lspec = spec.lower() if spec == lspec: continue is_local = m.group(1).decode() == '"' found = '' lmatches = [] for ir, specs in include_files.items(): if spec in specs: found = ir break if lspec in specs: lmatches.append(ir) if found and (not is_local or found == include_root): continue if lmatches: if is_local and include_root in lmatches: fr = include_root else: fr = lmatches[0] symlink_transformed(os.path.join(fr, lspec), lambda n: os.path.basename(spec)) def setup(splat_dir, root_dir, arch): print('Creating symlinks...') msarch = llvm_arch_to_ms_arch(arch) if os.path.exists(root_dir): shutil.rmtree(root_dir) os.makedirs(root_dir) # CRT clone_tree(os.path.join(splat_dir, 'crt', 'include'), os.path.join(root_dir, 'crt', 'include')) clone_tree(os.path.join(splat_dir, 'crt', 'lib', 'spectre', msarch), os.path.join(root_dir, 'crt', 'lib')) # SDK clone_tree(os.path.join(splat_dir, 'sdk', 'include'), os.path.join(root_dir, 'sdk', 'include')) for x in glob.glob(os.path.join(splat_dir, 'sdk', 'lib', '*', msarch)): clone_tree(x, os.path.join(root_dir, 'sdk', 'lib', os.path.basename(os.path.dirname(x)))) include_roots = [x for x in glob.glob(os.path.join(root_dir, 'sdk', 'include', '*')) if os.path.isdir(x)] include_roots.append(os.path.join(root_dir, 'crt', 'include')) include_files = {x:set(files_in(x)) for x in include_roots} for ir, files in include_files.items(): files_to_check = [] for relpath in files: path = os.path.join(ir, relpath) if not os.path.islink(path): files_to_check.append(path) for path in files_to_check: create_include_symlinks(path, ir, include_files) def main(args=sys.argv[1:]): stages = ('download', 'unpack', 'setup') p = argparse.ArgumentParser( description='Setup the headers and libraries for cross-compilation of windows binaries') p.add_argument( 'stages', metavar='STAGES', nargs='*', help=( f'The stages to run by default all stages are run. Stages are: {" ".join(stages)}')) p.add_argument( '--manifest-version', default=17, type=int, help='The manifest version to use to find the packages to install') p.add_argument( '--manifest-path', default='', help='Path to a local manifest file to use. Causes --manifest-version to be ignored.') p.add_argument( '--crt-variant', default='desktop', choices=('desktop', 'store', 'onecore'), help='The type of CRT to download') p.add_argument( '--arch', default='x86_64', choices=('x86_64', 'aarch64'), help='The architecture to install') p.add_argument('--dest', default='.', help='The directory to install into') args = p.parse_args(args) if args.dest == '.': args.dest = os.getcwd() stages = args.stages or stages dl_dir = os.path.join(args.dest, 'dl') splat_dir = os.path.join(args.dest, 'splat') root_dir = os.path.join(args.dest, 'root') for stage in stages: if stage == 'download': download(dl_dir, manifest_version=args.manifest_version, manifest_path=args.manifest_path, crt_variant=args.crt_variant, arch=args.arch) elif stage == 'unpack': unpack(dl_dir, splat_dir) elif stage == 'setup': setup(splat_dir, root_dir, args.arch) else: raise SystemExit(f'Unknown stage: {stage}') if __name__ == '__main__': pprint main()