""" Various utilities for interacting with Visual Studio on Windows. """ import json import os import re import subprocess from typing import Any, Optional, Callable import bpy import lnx.log as log import lnx.make import lnx.make_state as state import lnx.utils if lnx.is_reload(__name__): log = lnx.reload_module(log) lnx.make = lnx.reload_module(lnx.make) state = lnx.reload_module(state) lnx.utils = lnx.reload_module(lnx.utils) else: lnx.enable_reload(__name__) # VS versions supported by khamake. Keep in mind that this list is also # used for the wrd.lnx_project_win_list_vs EnumProperty! supported_versions = [ ('10', '2010', 'Visual Studio 2010 (version 10)'), ('11', '2012', 'Visual Studio 2012 (version 11)'), ('12', '2013', 'Visual Studio 2013 (version 12)'), ('14', '2015', 'Visual Studio 2015 (version 14)'), ('15', '2017', 'Visual Studio 2017 (version 15)'), ('16', '2019', 'Visual Studio 2019 (version 16)'), ('17', '2022', 'Visual Studio 2022 (version 17)') ] # version_major to --visualstudio parameter version_to_khamake_id = { '10': 'vs2010', '11': 'vs2012', '12': 'vs2013', '14': 'vs2015', '15': 'vs2017', '16': 'vs2019', '17': 'vs2022', } # VS versions found with fetch_installed_vs() _installed_versions = [] _REGEX_SLN_MIN_VERSION = re.compile(r'MinimumVisualStudioVersion\s*=\s*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)') def is_version_installed(version_major: str) -> bool: return any(v['version_major'] == version_major for v in _installed_versions) def get_installed_version(version_major: str, re_fetch=False) -> Optional[dict[str, str]]: for installed_version in _installed_versions: if installed_version['version_major'] == version_major: return installed_version # No installation was found. If re_fetch is True, fetch and try again # (the user may not have fetched installations before for example) if re_fetch: if not fetch_installed_vs(): return None return get_installed_version(version_major, False) return None def get_supported_version(version_major: str) -> Optional[dict[str, str]]: for version in supported_versions: if version[0] == version_major: return { 'version_major': version[0], 'year': version[1], 'name': version[2] } return None def fetch_installed_vs(silent=False) -> bool: global _installed_versions data_instances = _vswhere_get_instances(silent) if data_instances is None: return False items = [] for inst in data_instances: name = _vswhere_get_display_name(inst) versions = _vswhere_get_version(inst) path = _vswhere_get_path(inst) if name is None or versions is None or path is None: if not silent: log.warn( f'Found a Visual Studio installation with incomplete information, skipping\n' f' ({name=}, {versions=}, {path=})' ) continue items.append({ 'version_major': versions[0], 'version_full': versions[1], 'version_full_ints': versions[2], 'name': name, 'path': path }) # Store in descending order items.sort(key=lambda x: x['version_major'], reverse=True) _installed_versions = items return True def open_project_in_vs(version_major: str, version_min_full: Optional[str] = None) -> bool: installation = get_installed_version(version_major, re_fetch=True) if installation is None: if version_min_full is not None: # Try whether other installed versions are supported, versions # are already sorted in descending order for installed_version in _installed_versions: if (installed_version['version_full_ints'] >= version_full_to_ints(version_min_full) and int(installed_version['version_major']) < int(version_major)): installation = installed_version break # Still nothing found, warn for version_major if installation is None: vs_info = get_supported_version(version_major) log.warn(f'Could not open project in Visual Studio, {vs_info["name"]} was not found.') return False sln_path = get_sln_path() devenv_path = os.path.join(installation['path'], 'Common7', 'IDE', 'devenv.exe') cmd = ['start', devenv_path, sln_path] try: subprocess.check_call(cmd, shell=True) except subprocess.CalledProcessError as e: log.warn_called_process_error(e) return False return True def enable_vsvars_env(version_major: str, done: Callable[[], None]) -> bool: installation = get_installed_version(version_major, re_fetch=True) if installation is None: vs_info = get_supported_version(version_major) log.error(f'Could not compile project in Visual Studio, {vs_info["name"]} was not found.') return False wrd = bpy.data.worlds['Lnx'] arch_bits = '64' if wrd.lnx_project_win_build_arch == 'x64' else '32' vcvars_path = os.path.join(installation['path'], 'VC', 'Auxiliary', 'Build', 'vcvars' + arch_bits + '.bat') if not os.path.isfile(vcvars_path): log.error( 'Could not compile project in Visual Studio\n' f' File "{vcvars_path}" not found. Please verify that {installation["name"]} was installed correctly.' ) return False state.proc_publish_build = lnx.make.run_proc(vcvars_path, done) return True def compile_in_vs(version_major: str, done: Callable[[], None]) -> bool: installation = get_installed_version(version_major, re_fetch=True) if installation is None: vs_info = get_supported_version(version_major) log.error(f'Could not compile project in Visual Studio, {vs_info["name"]} was not found.') return False wrd = bpy.data.worlds['Lnx'] msbuild_path = os.path.join(installation['path'], 'MSBuild', 'Current', 'Bin', 'MSBuild.exe') if not os.path.isfile(msbuild_path): log.error( 'Could not compile project in Visual Studio\n' f' File "{msbuild_path}" not found. Please verify that {installation["name"]} was installed correctly.' ) return False projectfile_path = get_vcxproj_path() cmd = [msbuild_path, projectfile_path] # Arguments platform = 'x64' if wrd.lnx_project_win_build_arch == 'x64' else 'win32' log_param = wrd.lnx_project_win_build_log if log_param == 'WarningsAndErrorsOnly': log_param = 'WarningsOnly;ErrorsOnly' cmd.extend([ '-m:' + str(wrd.lnx_project_win_build_cpu), '-clp:' + log_param, '/p:Configuration=' + wrd.lnx_project_win_build_mode, '/p:Platform=' + platform ]) print('\nCompiling the project ' + projectfile_path) state.proc_publish_build = lnx.make.run_proc(cmd, done) state.redraw_ui = True return True def _vswhere_get_display_name(instance_data: dict[str, Any]) -> Optional[str]: name_raw = instance_data.get('displayName', None) if name_raw is None: return None return lnx.utils.safestr(name_raw).replace('_', ' ').strip() def _vswhere_get_version(instance_data: dict[str, Any]) -> Optional[tuple[str, str, tuple[int, ...]]]: version_raw = instance_data.get('installationVersion', None) if version_raw is None: return None version_full = version_raw.strip() version_full_ints = version_full_to_ints(version_full) version_major = version_full.split('.')[0] return version_major, version_full, version_full_ints def _vswhere_get_path(instance_data: dict[str, Any]) -> Optional[str]: return instance_data.get('installationPath', None) def _vswhere_get_instances(silent=False) -> Optional[list[dict[str, Any]]]: # vswhere.exe only exists at that location since VS2017 v15.2, for # earlier versions we'd need to package vswhere with Leenkx exe_path = os.path.join(os.environ["ProgramFiles(x86)"], 'Microsoft Visual Studio', 'Installer', 'vswhere.exe') command = [exe_path, '-format', 'json', '-utf8'] try: result = subprocess.check_output(command) except subprocess.CalledProcessError as e: # Do not silence this warning, if this exception is caught there # likely is an issue in the command above log.warn_called_process_error(e) return None except FileNotFoundError as e: if not silent: log.warn(f'Could not open file "{exe_path}", make sure the file exists (errno {e.errno}).') return None result = json.loads(result.decode('utf-8')) return result def version_full_to_ints(version_full: str) -> tuple[int, ...]: return tuple(int(i) for i in version_full.split('.')) def get_project_path() -> str: return os.path.join(lnx.utils.get_fp_build(), 'windows-hl-build') def get_project_name(): wrd = bpy.data.worlds['Lnx'] return lnx.utils.safesrc(wrd.lnx_project_name + '-' + wrd.lnx_project_version) def get_sln_path() -> str: project_path = get_project_path() project_name = get_project_name() return os.path.join(project_path, project_name + '.sln') def get_vcxproj_path() -> str: project_name = get_project_name() project_path = get_project_path() return os.path.join(project_path, project_name + '.vcxproj') def fetch_project_version() -> tuple[Optional[str], Optional[str], Optional[str]]: version_major = None version_min_full = None try: # References: # https://learn.microsoft.com/en-us/visualstudio/extensibility/internals/solution-dot-sln-file?view=vs-2022#file-header # https://github.com/Kode/kmake/blob/a104a89b55218054ceed761d5bc75d6e5cd60573/kmake/src/Exporters/VisualStudioExporter.ts#L188-L225 with open(get_sln_path(), 'r') as file: for linenum, line in enumerate(file): line = line.strip() if linenum == 1: if line == '# Visual Studio Version 17': version_major = 17 elif line == '# Visual Studio Version 16': version_major = 16 elif line == '# Visual Studio 15': version_major = 15 elif line == '# Visual Studio 14': version_major = 14 elif line == '# Visual Studio 2013': version_major = 12 elif line == '# Visual Studio 2012': version_major = 11 elif line == '# Visual Studio 2010': version_major = 10 else: log.warn(f'Could not parse Visual Studio version. Invalid major version, parsed {line}') return None, None, 'err_invalid_version_major' elif linenum == 3 and version_major >= 12: match = _REGEX_SLN_MIN_VERSION.match(line) if match: version_min_full = match.group(1) break log.warn(f'Could not parse Visual Studio version. Invalid full version, parsed {line}') return None, None, 'err_invalid_version_full' except FileNotFoundError: return None, None, 'err_file_not_found' return str(version_major), version_min_full, None