commit 6464da78475c88d2eb50d22dfae53e5b2ebd754d Author: Cheviiot <153805936+Cheviiot@users.noreply.github.com> Date: Sat Jul 25 00:57:41 2026 +1000 Initial implementation of msvc-go-wine A single-binary Go tool for cross compiling with the real MSVC toolchain on Linux via Wine. Behaves as cl/link/lib/rc/midl/mt/dumpbin/msbuild/ nmake/ml/ml64/armasm/armasm64/cmd/findstr depending on the name it's invoked as, plus download/install/env/version management subcommands. - download: fetches the MSVC/WinSDK installer manifest, resolves package selection and dependencies, downloads and verifies payloads, unpacks VSIX/MSI packages, and applies a handful of compatibility patches so VsDevCmd.bat and MSBuild's SDK detection work without a Windows Registry (which doesn't exist under Wine). - install: locates the installed toolchain/SDK versions, normalizes header/library name casing, lays out per-architecture tool symlinks with an env.json config each, and compiles a small native launcher (toolrelay.exe) that lets mt.exe's CMake-compatibility exit code survive Wine's own exit-code truncation. - The wrapper runtime rewrites absolute unix paths in tool arguments into Wine's z:\... form, runs the real .exe under wine, and rewrites the tool's output back to plain unix paths. Verified end-to-end against a real MSVC/WinSDK download: cl, link, mt and the resulting hello.exe all work under Wine, including through the toolrelay.exe relay path and with paths containing non-ASCII characters. Offline unit tests cover the wrapper's path-rewrite/output-filter logic, install-time header lowercasing, and download package-selection/ dependency-resolution. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18451b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/msvc-go-wine +*.test +.claude/ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d8ad75f --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,25 @@ +MIT License + +Copyright (c) 2026 Cheviiot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +This license covers msvc-go-wine's own source code. It does not cover the +Microsoft Visual C++ Build Tools / Windows SDK that `msvc-go-wine download` +fetches and unpacks, which remain governed by Microsoft's own license terms. diff --git a/README.md b/README.md new file mode 100644 index 0000000..28ac894 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# msvc-go-wine + +Cross compile with MSVC on Linux, using Wine — a single-binary Go tool +inspired by [mstorsjo/msvc-wine](https://github.com/mstorsjo/msvc-wine)'s +approach (download the real MSVC/WinSDK, wrap the compiler under Wine), +implemented independently. + +Once installed, you invoke the real Microsoft toolchain exactly like on +Windows: `cl`, `link`, `lib`, `rc`, `midl`, `mt`, `dumpbin`, `msbuild`, +`nmake`, `ml`, `ml64`, `armasm`, `armasm64` all just work from your `PATH`. + +## How it works + +`msvc-go-wine` is one Go binary that behaves differently depending on the +name it's invoked as (a "multi-call binary", like busybox): + +- Invoked as `cl`, `link`, `lib`, ... → it loads a small per-architecture + `env.json`, builds the `INCLUDE`/`LIB`/`WINEPATH` environment Wine needs, + rewrites absolute unix paths in the arguments into Wine's `z:\...` form + (working around [a Wine/cl.exe include-path bug](https://bugs.winehq.org/show_bug.cgi?id=55200)), + runs the real `.exe` under `wine`/`wine64`, and rewrites the tool's output + back from `z:\...` paths to plain unix paths so your build system's error + parsing keeps working. +- Invoked as `msvc-go-wine` → it exposes the `download`, `install`, `env` and + `version` management subcommands described below. + +## Quick start + +```bash +# 1. Download and unpack MSVC + Windows SDK (requires accepting Microsoft's +# Visual Studio Build Tools license, and msitools for unpacking .msi payloads) +msvc-go-wine download --accept-license --dest ~/my_msvc + +# 2. Wire up the tool wrappers +msvc-go-wine install ~/my_msvc + +# 3. Add the toolchain to PATH and build +export PATH=~/my_msvc/bin/x64:$PATH +cl /nologo /EHsc hello.cpp +``` + +### Prerequisites + +- `wine` (or `wine64`) — runs the real `cl.exe`/`link.exe`/etc. +- `msitools` (`msiextract`) — unpacks the `.msi` payloads MSVC/WinSDK ship as. +- `git` — used to apply the small compatibility patches bundled with + `download` (see Compatibility patches below). + +On ALT Linux: + +```bash +pkcon install wine msitools +``` + +## Commands + +``` +msvc-go-wine download --dest [options] fetch and unpack MSVC/WinSDK +msvc-go-wine install wire up wrappers for a downloaded MSVC +msvc-go-wine env --bin /bin/ print INCLUDE/LIB for native clang-cl/lld-link use +msvc-go-wine version print the version +``` + +`download` supports `--msvc-version`, `--sdk-version`, `--architecture`, +`--host-arch`, `--with-*` component toggles, `--ignore`, `--only-download`, +`--only-unpack`, `--keep-unpack`, `--cache`, `--language`, +`--include-optional`, `--skip-recommended`, `--major`, `--preview`, +`--manifest`. Run `msvc-go-wine download -h` for the full list. + +### Using clang-cl/lld-link instead of Wine + +You don't need Wine at all if you drive the (nonredistributable) MSVC/WinSDK +headers and libraries with Clang/LLD in MSVC-compatible mode: + +```bash +eval "$(msvc-go-wine env --bin ~/my_msvc/bin/x64)" +clang-cl -c hello.c +lld-link hello.obj -out:hello.exe +``` + +## Building from source + +```bash +go build -o msvc-go-wine ./cmd/msvc-go-wine +``` + +Go 1.23+ is all you need to build it; `wine`/`msitools` are only needed at +run time (`install`/tool invocation and `download` respectively). + +## toolrelay.exe + +`install` compiles `assets/vendor/toolrelay.cpp` (a small native Windows +launcher, original to this project) with the freshly-installed host-arch +`cl.exe` (best-effort: if `wine` isn't present yet, or the compile fails, +install still succeeds and the wrapper runtime just falls back to invoking +tools directly through wine). When present, every non-MSBuild tool +invocation is routed through it via two named FIFOs. This is what lets +`mt.exe`'s CMake-compatibility exit-code translation (`0x41020001` → `0xbb`) +survive Wine's own exit-code truncation: only a native Windows process +observing the untranslated code via `GetExitCodeProcess()` can catch it +before Wine marshals the process exit back to Unix and drops everything but +the low byte. + +## Compatibility patches + +`download` applies a handful of small patches (`assets/patches`) to the +downloaded MSVC/WinSDK tree - independently written for this project - that +make `VsDevCmd.bat` and MSBuild's SDK-detection props work without a +Windows Registry (which doesn't exist under Wine): they check the SDK +directly under the VS install root instead of querying the registry, skip +telemetry, and don't hard-fail devcmd setup when an optional component +(ConnectionManagerExe, bundled CMake/Ninja) wasn't downloaded. + +## Known gaps + +- `download` doesn't yet support printing the dependency/reverse-dependency + tree, listing available workloads/components/packages, or installing the + Windows Driver Kit via `--with-wdk-installers`; the core selection/ + download/unpack/install pipeline is fully implemented. + +## License + +MIT, see [LICENSE.txt](LICENSE.txt) - covers msvc-go-wine's own source only. +The MSVC Build Tools / Windows SDK that `download` fetches remain governed +by Microsoft's own license (accepted via `--accept-license`), same as with +any other way of obtaining them. diff --git a/assets/embed.go b/assets/embed.go new file mode 100644 index 0000000..f7c5252 --- /dev/null +++ b/assets/embed.go @@ -0,0 +1,13 @@ +// Package assets embeds vendored files that don't belong in Go source: the +// Wine compatibility patches applied after unpacking MSVC/WinSDK, and the +// toolrelay.cpp native launcher helper (see internal/install and +// internal/wrapper). +package assets + +import "embed" + +//go:embed patches +var Patches embed.FS + +//go:embed vendor/toolrelay.cpp +var ToolRelaySource []byte diff --git a/assets/patches/Common7/Tools/VsDevCmd.bat.patch b/assets/patches/Common7/Tools/VsDevCmd.bat.patch new file mode 100644 index 0000000..8ac84a9 --- /dev/null +++ b/assets/patches/Common7/Tools/VsDevCmd.bat.patch @@ -0,0 +1,18 @@ +msvc-go-wine: skip VsDevCmd's telemetry upload. + +There's no telemetry endpoint reachable (or wanted) under Wine. Rather than +touching the conditional logic below, just set the opt-out switch the +script already checks for on its own. + +diff --git a/Common7/Tools/VsDevCmd.bat b/Common7/Tools/VsDevCmd.bat +index f925e95..f3710ad 100644 +--- a/Common7/Tools/VsDevCmd.bat ++++ b/Common7/Tools/VsDevCmd.bat +@@ -247,6 +247,7 @@ exit /B 0 + :end + + @REM Send Telemetry if user's VS is opted-in ++set "VSCMD_SKIP_SENDTELEMETRY=1" + if "%VSCMD_SKIP_SENDTELEMETRY%"=="" ( + if "%VSCMD_DEBUG%" NEQ "" ( + @echo [DEBUG:%~nx0] Sending telemetry diff --git a/assets/patches/Common7/Tools/vsdevcmd/core/vsdevcmd_start.bat.patch b/assets/patches/Common7/Tools/vsdevcmd/core/vsdevcmd_start.bat.patch new file mode 100644 index 0000000..43d0e29 --- /dev/null +++ b/assets/patches/Common7/Tools/vsdevcmd/core/vsdevcmd_start.bat.patch @@ -0,0 +1,20 @@ +msvc-go-wine: always record the starting directory. + +vsdevcmd_start.bat only saved __VSCMD_CURRENT_DIR when the caller passed +-startdir=none, which doesn't hold up under Wine's cmd.exe (see +https://bugs.winehq.org/show_bug.cgi?id=57137). Just save it +unconditionally instead of gating on the flag. + +diff --git a/Common7/Tools/vsdevcmd/core/vsdevcmd_start.bat b/Common7/Tools/vsdevcmd/core/vsdevcmd_start.bat +index 21caae9..fc18228 100644 +--- a/Common7/Tools/vsdevcmd/core/vsdevcmd_start.bat ++++ b/Common7/Tools/vsdevcmd/core/vsdevcmd_start.bat +@@ -8,7 +8,7 @@ call "%~dp0parse_cmd.bat" %* + if "%ERRORLEVEL%" NEQ "0" set /A __vscmd_vsdevcmd_errcount=__vscmd_vsdevcmd_errcount+1 + + @REM Save the current directory, if -startdir=none was specified by the user. +-if /I "%VSCMD_ARG_STARTDIR%" == "none" set "__VSCMD_CURRENT_DIR=%CD%" ++set "__VSCMD_CURRENT_DIR=%CD%" + + if "%VSCMD_DEBUG%" GEQ "2" ( + @echo [DEBUG:%~n0] Parsing results... diff --git a/assets/patches/Common7/Tools/vsdevcmd/core/winsdk.bat.patch b/assets/patches/Common7/Tools/vsdevcmd/core/winsdk.bat.patch new file mode 100644 index 0000000..3fc976a --- /dev/null +++ b/assets/patches/Common7/Tools/vsdevcmd/core/winsdk.bat.patch @@ -0,0 +1,66 @@ +msvc-go-wine: find the Windows SDK/UCRT dirs on disk before trying the registry. + +winsdk.bat locates the Windows 10/11 SDK and Universal CRT SDK by querying +the Windows Registry, which doesn't have real SDK installation entries +under Wine. msvc-wine's own install layout always puts the SDK directly +under "%VSINSTALLDIR%Windows Kits\10", so check that location first and +only fall back to the registry-based lookups when it's not there. + +diff --git a/Common7/Tools/vsdevcmd/core/winsdk.bat b/Common7/Tools/vsdevcmd/core/winsdk.bat +index af09494..c303775 100644 +--- a/Common7/Tools/vsdevcmd/core/winsdk.bat ++++ b/Common7/Tools/vsdevcmd/core/winsdk.bat +@@ -64,6 +64,11 @@ exit /B 0 + + if "%VSCMD_DEBUG%" GEQ "3" goto :GetWin10SdkDirVerbose + ++if exist "%VSINSTALLDIR%Windows Kits\10\include\" ( ++ SET "WindowsSdkDir=%VSINSTALLDIR%Windows Kits\10\" ++ exit /B 0 ++) ++ + call :GetWin10SdkDirHelper HKLM\SOFTWARE\Wow6432Node > nul 2>&1 + if errorlevel 1 call :GetWin10SdkDirHelper HKCU\SOFTWARE\Wow6432Node > nul 2>&1 + if errorlevel 1 call :GetWin10SdkDirHelper HKLM\SOFTWARE > nul 2>&1 +@@ -73,6 +78,11 @@ exit /B 0 + + :GetWin10SdkDirVerbose + ++if exist "%VSINSTALLDIR%Windows Kits\10\include\" ( ++ SET "WindowsSdkDir=%VSINSTALLDIR%Windows Kits\10\" ++ exit /B 0 ++) ++ + call :GetWin10SdkDirHelper HKLM\SOFTWARE\Wow6432Node + if errorlevel 1 call :GetWin10SdkDirHelper HKCU\SOFTWARE\Wow6432Node + if errorlevel 1 call :GetWin10SdkDirHelper HKLM\SOFTWARE +@@ -231,6 +241,14 @@ set UniversalCRTSdkDir= + + if "%VSCMD_DEBUG%" GEQ "3" goto :GetUniversalCRTSdkDirVerbose + ++if exist "%WindowsSdkDir%Lib\%WindowsSDKVersion%ucrt\" ( ++ SET "UniversalCRTSdkDir=%WindowsSdkDir%" ++ exit /B 0 ++) else if exist "%VSINSTALLDIR%Windows Kits\10\Lib\" ( ++ SET "UniversalCRTSdkDir=%VSINSTALLDIR%Windows Kits\10\" ++ exit /B 0 ++) ++ + call :GetUniversalCRTSdkDirHelper HKLM\SOFTWARE\Wow6432Node > nul 2>&1 + if errorlevel 1 call :GetUniversalCRTSdkDirHelper HKCU\SOFTWARE\Wow6432Node > nul 2>&1 + if errorlevel 1 call :GetUniversalCRTSdkDirHelper HKLM\SOFTWARE > nul 2>&1 +@@ -240,6 +258,14 @@ exit /B 0 + + :GetUniversalCRTSdkDirVerbose + ++if exist "%WindowsSdkDir%Lib\%WindowsSDKVersion%ucrt\" ( ++ SET "UniversalCRTSdkDir=%WindowsSdkDir%" ++ exit /B 0 ++) else if exist "%VSINSTALLDIR%Windows Kits\10\Lib\" ( ++ SET "UniversalCRTSdkDir=%VSINSTALLDIR%Windows Kits\10\" ++ exit /B 0 ++) ++ + call :GetUniversalCRTSdkDirHelper HKLM\SOFTWARE\Wow6432Node + if errorlevel 1 call :GetUniversalCRTSdkDirHelper HKCU\SOFTWARE\Wow6432Node + if errorlevel 1 call :GetUniversalCRTSdkDirHelper HKLM\SOFTWARE diff --git a/assets/patches/Common7/Tools/vsdevcmd/ext/ConnectionManagerExe.bat.patch b/assets/patches/Common7/Tools/vsdevcmd/ext/ConnectionManagerExe.bat.patch new file mode 100644 index 0000000..7eb7d49 --- /dev/null +++ b/assets/patches/Common7/Tools/vsdevcmd/ext/ConnectionManagerExe.bat.patch @@ -0,0 +1,19 @@ +msvc-go-wine: don't hard-fail devcmd setup when ConnectionManagerExe is missing. + +Not every MSVC/WinSDK download selection includes the Linux +ConnectionManagerExe component; a missing optional component shouldn't +abort the whole Developer Command Prompt setup. + +diff --git a/Common7/Tools/vsdevcmd/ext/ConnectionManagerExe.bat b/Common7/Tools/vsdevcmd/ext/ConnectionManagerExe.bat +index 871b9af..f069a25 100644 +--- a/Common7/Tools/vsdevcmd/ext/ConnectionManagerExe.bat ++++ b/Common7/Tools/vsdevcmd/ext/ConnectionManagerExe.bat +@@ -10,7 +10,7 @@ set "PATH=%PATH%;%VSINSTALLDIR%Common7\IDE\VC\Linux\bin\ConnectionManagerExe" + goto :end + + :error_setting_path +-exit /B 1 ++exit /B 0 + + goto :end + diff --git a/assets/patches/Common7/Tools/vsdevcmd/ext/cmake.bat.patch b/assets/patches/Common7/Tools/vsdevcmd/ext/cmake.bat.patch new file mode 100644 index 0000000..11e53a3 --- /dev/null +++ b/assets/patches/Common7/Tools/vsdevcmd/ext/cmake.bat.patch @@ -0,0 +1,18 @@ +msvc-go-wine: don't hard-fail devcmd setup when the bundled CMake/Ninja is missing. + +Same reasoning as the ConnectionManagerExe fix: a missing optional +component shouldn't abort the whole Developer Command Prompt setup. + +diff --git a/Common7/Tools/vsdevcmd/ext/cmake.bat b/Common7/Tools/vsdevcmd/ext/cmake.bat +index d9a1088..584f141 100644 +--- a/Common7/Tools/vsdevcmd/ext/cmake.bat ++++ b/Common7/Tools/vsdevcmd/ext/cmake.bat +@@ -10,7 +10,7 @@ set "PATH=%PATH%;%VSINSTALLDIR%Common7\IDE\CommonExtensions\Microsoft\CMake\CMak + goto :end + + :error_setting_path +-exit /B 1 ++exit /B 0 + + goto :end + diff --git a/assets/patches/MSBuild/Microsoft/VC/v180/ImportBefore/Default/msvc-go-wine.props b/assets/patches/MSBuild/Microsoft/VC/v180/ImportBefore/Default/msvc-go-wine.props new file mode 100644 index 0000000..e01cfa7 --- /dev/null +++ b/assets/patches/MSBuild/Microsoft/VC/v180/ImportBefore/Default/msvc-go-wine.props @@ -0,0 +1,24 @@ + + + + $(VSInstallRoot) + $(VSInstallRoot)\Windows Kits\10\ + $(WindowsSdkDir_10) + $(WindowsSdkDir_10) + + + + x64 + + diff --git a/assets/patches/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props.patch b/assets/patches/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props.patch new file mode 100644 index 0000000..0f3c4a1 --- /dev/null +++ b/assets/patches/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props.patch @@ -0,0 +1,20 @@ +msvc-go-wine: let MSBuild find the Windows SDK without the registry. + +GetLatestSDKTargetPlatformVersion normally resolves the SDK via the +registry, which has nothing to find under Wine. Pass it an extra list of +roots (populated by msvc-go-wine.props, see the ImportBefore directory next +to this file) so it can also look directly under the VS install root. + +diff --git a/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props b/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props +index 5003a04..2a785a4 100644 +--- a/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props ++++ b/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props +@@ -110,7 +110,7 @@ Copyright (C) Microsoft Corporation. All rights reserved. + 10.0 + Windows 10 + +- <_LatestWindowsTargetPlatformVersion>$([Microsoft.Build.Utilities.ToolLocationHelper]::GetLatestSDKTargetPlatformVersion($(SDKIdentifier), $(SDKVersion))) ++ <_LatestWindowsTargetPlatformVersion>$([Microsoft.Build.Utilities.ToolLocationHelper]::GetLatestSDKTargetPlatformVersion($(SDKIdentifier), $(SDKVersion), $(MsvcGoWine_ExtraSdkRoots.Split(';')))) + 10.0 + + diff --git a/assets/vendor/toolrelay.cpp b/assets/vendor/toolrelay.cpp new file mode 100644 index 0000000..ac451d6 --- /dev/null +++ b/assets/vendor/toolrelay.cpp @@ -0,0 +1,126 @@ +// toolrelay.exe - a thin native launcher for a Wine-hosted MSVC tool +// invocation, built and used by msvc-go-wine (see internal/install's +// buildToolRelay and internal/wrapper's runViaToolRelay). +// +// It exists to solve two problems that are only solvable from inside a +// real Windows process, not from the Unix side of Wine: +// +// 1. Wine truncates a Windows process's exit code down to a single byte +// when reporting it back to the host OS. mt.exe (the manifest tool) +// can legitimately exit with 0x41020001 ("manifest unchanged"), which +// CMake's own build driver specifically checks for and translates to +// 0xbb before treating it as success - see cmcmd.cxx upstream. If +// Wine has already truncated 0x41020001 down to 0x01 by the time our +// Go wrapper observes it, we can no longer make that translation +// ourselves. Only a native Windows process calling +// GetExitCodeProcess() can see the real, untruncated value - so this +// helper does the translation itself, then exits with the already- +// small 0xbb, which fits in a byte and survives Wine's truncation +// intact. +// +// 2. It gives the launched process its own Job Object (with +// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE), so an aborted build doesn't +// leave orphaned child processes running under Wine. +// +// Usage: toolrelay.exe [args...] +// +// Optional environment variables MSVCGOWINE_STDIN / MSVCGOWINE_STDOUT / +// MSVCGOWINE_STDERR name files to redirect the child's standard handles to +// (used by runViaToolRelay to relay output through named pipes back to the +// Unix side); any that aren't set fall back to this process's own +// inherited handles. + +#include +#include + +namespace { + +constexpr DWORD kMtManifestUnchanged = 0x41020001; +constexpr DWORD kMtManifestUnchangedForCMake = 0xbb; + +HANDLE OpenRedirectOrFallback(const wchar_t *envVar, DWORD access, + DWORD disposition, HANDLE fallback) { + wchar_t path[32768]; + DWORD n = GetEnvironmentVariableW(envVar, path, ARRAYSIZE(path)); + if (n == 0 || n >= ARRAYSIZE(path)) { + return fallback; + } + SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; + HANDLE h = CreateFileW(path, access, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + &sa, disposition, FILE_ATTRIBUTE_NORMAL, nullptr); + return h == INVALID_HANDLE_VALUE ? fallback : h; +} + +// CreateProcessW wants a single mutable command line string, quoting each +// argument and escaping embedded quotes. +std::wstring BuildCommandLine(int argc, wchar_t **argv) { + std::wstring cmd; + for (int i = 0; i < argc; i++) { + if (i > 0) cmd += L' '; + cmd += L'"'; + for (const wchar_t *c = argv[i]; *c; c++) { + if (*c == L'"') cmd += L'\\'; + cmd += *c; + } + cmd += L'"'; + } + return cmd; +} + +HANDLE MakeKillOnCloseJob() { + HANDLE job = CreateJobObjectW(nullptr, nullptr); + if (!job) return nullptr; + JOBOBJECT_EXTENDED_LIMIT_INFORMATION info{}; + info.BasicLimitInformation.LimitFlags = + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK; + SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info)); + return job; +} + +bool IsMtExe(const wchar_t *path) { + const wchar_t *name = wcsrchr(path, L'\\'); + name = name ? name + 1 : path; + return _wcsicmp(name, L"mt.exe") == 0; +} + +} // namespace + +int wmain(int argc, wchar_t **argv) { + if (argc < 2) { + return 1; + } + const wchar_t *targetExe = argv[1]; + + STARTUPINFOW si{sizeof(si)}; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = OpenRedirectOrFallback(L"MSVCGOWINE_STDIN", GENERIC_READ, + OPEN_EXISTING, GetStdHandle(STD_INPUT_HANDLE)); + si.hStdOutput = OpenRedirectOrFallback(L"MSVCGOWINE_STDOUT", GENERIC_WRITE, + CREATE_ALWAYS, GetStdHandle(STD_OUTPUT_HANDLE)); + si.hStdError = OpenRedirectOrFallback(L"MSVCGOWINE_STDERR", GENERIC_WRITE, + CREATE_ALWAYS, GetStdHandle(STD_ERROR_HANDLE)); + + HANDLE job = MakeKillOnCloseJob(); + std::wstring cmdLine = BuildCommandLine(argc - 1, argv + 1); + + PROCESS_INFORMATION pi{}; + DWORD exitCode; + if (CreateProcessW(targetExe, cmdLine.empty() ? nullptr : &cmdLine[0], nullptr, + nullptr, TRUE, 0, nullptr, nullptr, &si, &pi)) { + if (job) AssignProcessToJobObject(job, pi.hProcess); + WaitForSingleObject(pi.hProcess, INFINITE); + if (!GetExitCodeProcess(pi.hProcess, &exitCode)) { + exitCode = GetLastError(); + } + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } else { + exitCode = GetLastError(); + } + + if (IsMtExe(targetExe) && exitCode == kMtManifestUnchanged) { + exitCode = kMtManifestUnchangedForCMake; + } + return static_cast(exitCode); +} diff --git a/cmd/msvc-go-wine/download.go b/cmd/msvc-go-wine/download.go new file mode 100644 index 0000000..1597981 --- /dev/null +++ b/cmd/msvc-go-wine/download.go @@ -0,0 +1,211 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/Cheviiot/msvc-go-wine/internal/download" +) + +func runDownload(args []string) int { + fs := flag.NewFlagSet("download", flag.ContinueOnError) + dest := fs.String("dest", "", "directory to install into (required unless --only-download)") + cacheDir := fs.String("cache", "", "directory to use as a persistent download cache (default: a temp dir, removed afterwards)") + major := fs.Int("major", 18, "the major VS version to download") + preview := fs.Bool("preview", false, "download the preview/insiders channel instead of release/stable") + manifestFile := fs.String("manifest", "", "use a predownloaded installer manifest file instead of fetching one") + acceptLicense := fs.Bool("accept-license", false, "don't prompt for accepting the license") + msvcVersion := fs.String("msvc-version", "", "install a specific MSVC toolchain version, e.g. 17.10") + sdkVersion := fs.String("sdk-version", "", "install a specific Windows SDK version") + hostArch := fs.String("host-arch", "", "host architecture of packages to install (x86, x64, arm64; auto-detected)") + onlyHost := fs.Bool("only-host", true, "only download packages matching the host architecture") + language := fs.String("language", "en", "preferred language code for packages available in multiple languages") + includeOptional := fs.Bool("include-optional", false, "include all optional dependencies") + skipRecommended := fs.Bool("skip-recommended", false, "don't include recommended dependencies") + onlyDownload := fs.Bool("only-download", false, "stop after downloading package files") + onlyUnpack := fs.Bool("only-unpack", false, "unpack selected packages and keep everything, without pruning to just the CLI tools") + keepUnpack := fs.Bool("keep-unpack", false, "keep the scratch unpack dir instead of removing it after moving files into place") + skipPatch := fs.Bool("skip-patch", false, "don't apply the Wine compatibility patches") + var archsFlag stringList + fs.Var(&archsFlag, "architecture", "target architecture to include (x86, x64, arm, arm64, host); repeatable") + var ignoreFlag stringList + fs.Var(&ignoreFlag, "ignore", "package id to skip; repeatable") + + if err := fs.Parse(args); err != nil { + return 2 + } + packages := fs.Args() + + opts := &download.Options{ + Package: packages, + Ignore: []string(ignoreFlag), + Architecture: []string(archsFlag), + HostArch: *hostArch, + OnlyHost: *onlyHost, + MSVCVersion: *msvcVersion, + SDKVersion: *sdkVersion, + IncludeOptional: *includeOptional, + SkipRecommended: *skipRecommended, + Language: *language, + } + + manifestURL := *manifestFile + if manifestURL == "" { + url, err := download.FetchChannelManifest(*major, *preview) + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) + return 1 + } + manifestURL = url + } else { + manifestURL = "file:" + manifestURL + } + + manifest, err := download.FetchInstallerManifest(manifestURL) + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) + return 1 + } + + if opts.HostArch == "" { + opts.HostArch = detectHostArch() + } + fmt.Println("Install packages for", opts.HostArch, "host architecture") + + idx := download.BuildIndex(manifest, opts.HostArch, opts.Language) + + if !*acceptLicense { + license := "the Visual Studio Build Tools license" + if p := idx.Find("Microsoft.VisualStudio.Product.BuildTools", nil); p != nil && len(p.LocalizedResources) > 0 { + license = p.LocalizedResources[0].License + } + if !promptAcceptLicense(license) { + return 0 + } + } + + if err := download.ResolveSelection(opts, idx); err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) + return 1 + } + + selected, err := download.ExpandSelection(idx, opts) + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) + return 1 + } + var downloadSize, installSize int64 + for _, p := range selected { + downloadSize += p.DownloadSize() + installSize += p.InstalledSize() + } + fmt.Printf("Selected %d packages, for a total download size of %s, install size of %s\n", + len(selected), download.HumanizeBytes(downloadSize), download.HumanizeBytes(installSize)) + + cache := *cacheDir + removeCache := false + if cache == "" { + tmp, err := os.MkdirTemp("", "msvc-go-wine-cache-") + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) + return 1 + } + cache = tmp + removeCache = true + } + if removeCache { + defer os.RemoveAll(cache) + } + + if !*onlyDownload && *dest == "" { + fmt.Fprintln(os.Stderr, "msvc-go-wine download: --dest is required unless --only-download is set") + return 1 + } + + if err := download.FetchPayloads(selected, cache, *onlyDownload); err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) + return 1 + } + if *onlyDownload { + return 0 + } + + destAbs, err := filepath.Abs(*dest) + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) + return 1 + } + + unpack := destAbs + if !*onlyUnpack { + unpack = filepath.Join(destAbs, "unpack") + } + if err := download.UnpackSelectedPackages(selected, cache, unpack); err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) + return 1 + } + + // Wine doesn't honor .exe.config redirects, so copy + // MSBuild's redirected assemblies next to it directly. + for _, hostArch := range []string{"amd64", "arm64"} { + msbuildExe := filepath.Join(unpack, "MSBuild", "Current", "Bin", hostArch, "MSBuild.exe") + if err := download.CopyRedirectedAssemblies(msbuildExe); err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) + return 1 + } + } + + if !*onlyUnpack { + if err := download.RelocateBuildTools(unpack, destAbs); err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) + return 1 + } + if !*keepUnpack { + os.RemoveAll(unpack) + } + if !*skipPatch && *major == 18 { + if err := download.ApplyCompatibilityFixes(destAbs); err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) + return 1 + } + } + } + + fmt.Println("Done. Next: msvc-go-wine install", destAbs) + return 0 +} + +func detectHostArch() string { + if runtime.GOARCH == "arm64" { + return "arm64" + } + return "x64" +} + +func promptAcceptLicense(license string) bool { + fmt.Printf("Do you accept the license at %s (yes/no)? ", license) + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + switch scanner.Text() { + case "yes": + return true + case "no": + return false + } + fmt.Print("Do you accept the license? Answer \"yes\" or \"no\": ") + } + return false +} + +// stringList implements flag.Value to collect a repeatable string flag. +type stringList []string + +func (s *stringList) String() string { return fmt.Sprint([]string(*s)) } +func (s *stringList) Set(v string) error { + *s = append(*s, v) + return nil +} diff --git a/cmd/msvc-go-wine/env.go b/cmd/msvc-go-wine/env.go new file mode 100644 index 0000000..535ab8c --- /dev/null +++ b/cmd/msvc-go-wine/env.go @@ -0,0 +1,64 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/Cheviiot/msvc-go-wine/internal/wineenv" +) + +// runEnv prints shell `export` statements for INCLUDE/LIB (converted from +// wine's "z:\..." notation to plain unix paths) and TARGET_TRIPLE, for +// driving clang-cl/lld-link directly without Wine. +// Usage: eval "$(msvc-go-wine env --bin /bin/)" +func runEnv(args []string) int { + fs := flag.NewFlagSet("env", flag.ContinueOnError) + bin := fs.String("bin", "", "the /bin/ directory produced by `msvc-go-wine install`") + if err := fs.Parse(args); err != nil { + return 2 + } + if *bin == "" { + fmt.Fprintln(os.Stderr, "usage: msvc-go-wine env --bin /bin/") + return 1 + } + + cfg, err := wineenv.Load(*bin) + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine env:", err) + return 1 + } + baseUnix, err := wineenv.FindBaseUnix(*bin) + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine env:", err) + return 1 + } + paths := wineenv.NewPaths(cfg, baseUnix) + + triple, ok := targetTriples[cfg.Arch] + if !ok { + fmt.Fprintf(os.Stderr, "msvc-go-wine env: unknown arch %q\n", cfg.Arch) + return 1 + } + + fmt.Printf("export INCLUDE=%q\n", toUnixPathList(paths.Include)) + fmt.Printf("export LIB=%q\n", toUnixPathList(paths.Lib)) + fmt.Printf("export TARGET_TRIPLE=%q\n", triple) + return 0 +} + +var targetTriples = map[string]string{ + "x86": "i686-windows-msvc", + "x64": "x86_64-windows-msvc", + "arm": "armv7-windows-msvc", + "arm64": "aarch64-windows-msvc", +} + +// toUnixPathList does a blanket removal of the "z:" drive prefix and +// backslash->slash conversion across the whole semicolon-joined path list. +func toUnixPathList(s string) string { + s = strings.ReplaceAll(s, "z:", "") + s = strings.ReplaceAll(s, `\`, "/") + return s +} diff --git a/cmd/msvc-go-wine/install.go b/cmd/msvc-go-wine/install.go new file mode 100644 index 0000000..6d864ba --- /dev/null +++ b/cmd/msvc-go-wine/install.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "os" + + "github.com/Cheviiot/msvc-go-wine/internal/install" +) + +func runInstall(args []string) int { + if len(args) != 1 || args[0] == "-h" || args[0] == "--help" { + fmt.Fprintln(os.Stderr, "usage: msvc-go-wine install ") + return 1 + } + dest := args[0] + + self, err := os.Executable() + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine install:", err) + return 1 + } + + if err := install.Install(dest, self); err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine install:", err) + return 1 + } + fmt.Println("Done. Add", dest+"/bin/ to PATH to use cl, link, lib, ...") + return 0 +} diff --git a/cmd/msvc-go-wine/main.go b/cmd/msvc-go-wine/main.go new file mode 100644 index 0000000..737eddd --- /dev/null +++ b/cmd/msvc-go-wine/main.go @@ -0,0 +1,70 @@ +// Command msvc-go-wine cross compiles with the real MSVC toolchain on Linux +// via Wine. It's a multi-call binary that behaves as `cl`, `link`, `lib`, +// `rc`, `midl`, `mt`, `dumpbin`, `msbuild`, etc. when invoked under one of +// those names (via symlinks set up by `msvc-go-wine install`), and +// otherwise exposes the `download`/`install`/`env`/`version` management +// subcommands. +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Cheviiot/msvc-go-wine/internal/wrapper" +) + +func main() { + base := filepath.Base(os.Args[0]) + name := strings.TrimSuffix(strings.ToLower(base), ".exe") + + if _, ok := wrapper.Tools[name]; ok { + os.Exit(wrapper.Run(name, os.Args[1:])) + } + if name == "cmd" || name == "findstr" { + os.Exit(wrapper.Run(name, os.Args[1:])) + } + + os.Exit(runCLI(os.Args[1:])) +} + +func runCLI(args []string) int { + if len(args) == 0 { + printUsage() + return 1 + } + + switch args[0] { + case "download": + return runDownload(args[1:]) + case "install": + return runInstall(args[1:]) + case "env": + return runEnv(args[1:]) + case "version": + fmt.Println("msvc-go-wine dev") + return 0 + case "-h", "--help", "help": + printUsage() + return 0 + default: + fmt.Fprintf(os.Stderr, "msvc-go-wine: unknown subcommand %q\n\n", args[0]) + printUsage() + return 1 + } +} + +func printUsage() { + fmt.Fprint(os.Stderr, `msvc-go-wine - cross compile with MSVC on Linux via Wine + +Usage: + msvc-go-wine download --dest [options] fetch and unpack MSVC/WinSDK + msvc-go-wine install wire up wrappers for a downloaded MSVC + msvc-go-wine env --bin print INCLUDE/LIB for native clang-cl/lld-link use + msvc-go-wine version print the version + +Once installed, add /bin/ to PATH and invoke the tools directly: + cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr +`) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4303eb8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Cheviiot/msvc-go-wine + +go 1.26.5 diff --git a/internal/download/extract.go b/internal/download/extract.go new file mode 100644 index 0000000..c49c078 --- /dev/null +++ b/internal/download/extract.go @@ -0,0 +1,309 @@ +package download + +import ( + "archive/zip" + "encoding/xml" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// UnpackSelectedPackages unpacks every selected package's cached payloads +// into unpack (VSIX packages as plain zips, Win10SDK/Win11SDK via the +// external msiextract). +func UnpackSelectedPackages(selected []*Package, cacheDir, unpack string) error { + if err := os.MkdirAll(unpack, 0o755); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(unpack, "MSBuild"), 0o755); err != nil { + return err + } + for _, p := range selected { + dir := filepath.Join(cacheDir, p.Key()) + switch p.Type { + case "Component", "Workload", "Group": + continue + case "Vsix": + fmt.Println("Unpacking", p.ID) + for _, pl := range p.Payloads { + listing := filepath.Join(unpack, p.Key()+"-listing.txt") + if err := extractVSIXPackage(filepath.Join(dir, pl.Name()), unpack, listing); err != nil { + return fmt.Errorf("unpacking %s: %w", p.ID, err) + } + } + default: + if strings.HasPrefix(p.ID, "Win10SDK") || strings.HasPrefix(p.ID, "Win11SDK") { + fmt.Println("Unpacking", p.ID) + if err := extractWindowsSDKPackage(dir, p.Payloads, unpack); err != nil { + return fmt.Errorf("unpacking %s: %w", p.ID, err) + } + } else { + fmt.Println("Skipping unpacking of", p.ID, "of type", p.Type) + } + } + } + return nil +} + +// extractVSIXPackage extracts a VSIX (plain zip) into a scratch dir under +// dest, then merges its "Contents" (and WDK's "$MSBuild") subtree into +// dest. +func extractVSIXPackage(file, dest, listingPath string) error { + r, err := zip.OpenReader(file) + if err != nil { + return err + } + defer r.Close() + + tmp := filepath.Join(dest, "vsix") + if err := os.RemoveAll(tmp); err != nil { + return err + } + + var names []string + for _, f := range r.File { + names = append(names, f.Name) + name, err := url.PathUnescape(f.Name) + if err != nil { + name = f.Name + } + target := filepath.Join(tmp, name) + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := extractZipEntry(f, target); err != nil { + return err + } + } + if err := os.WriteFile(listingPath, []byte(strings.Join(names, "\n")+"\n"), 0o644); err != nil { + return err + } + + if contents := filepath.Join(tmp, "Contents"); isDir(contents) { + if err := combineDirTrees(contents, dest); err != nil { + return err + } + } + // This archive directory structure is used by the WDK.vsix. + if msbuild := filepath.Join(tmp, "$MSBuild"); isDir(msbuild) { + if err := combineDirTrees(msbuild, filepath.Join(dest, "MSBuild")); err != nil { + return err + } + } + return os.RemoveAll(tmp) +} + +func extractZipEntry(f *zip.File, dest string) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + mode := f.Mode() + if mode == 0 { + mode = 0o644 + } + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, rc) + return err +} + +// extractWindowsSDKPackage extracts every .msi payload of a WinSDK package +// via msiextract, and symlinks "Program Files" to "." so files msiextract +// unpacks there land at the unpack root (matching msiexec's own behavior on +// Windows). +func extractWindowsSDKPackage(src string, payloads []Payload, dest string) error { + pf := filepath.Join(dest, "Program Files") + if !exists(pf) { + if err := os.Symlink(".", pf); err != nil { + return err + } + } + for _, pl := range payloads { + name := pl.Name() + if !strings.HasSuffix(strings.ToLower(name), ".msi") { + continue + } + fmt.Println("Extracting", name) + srcFile := filepath.Join(src, name) + logPath := filepath.Join(dest, "WinSDK-"+name+"-listing.txt") + if err := runMsiExtract(srcFile, dest, logPath); err != nil { + return fmt.Errorf("msiextract %s: %w", name, err) + } + } + return nil +} + +func runMsiExtract(srcFile, dest, logPath string) error { + if _, err := exec.LookPath("msiextract"); err != nil { + return fmt.Errorf("msiextract not found in PATH (install the msitools package): %w", err) + } + logFile, err := os.Create(logPath) + if err != nil { + return err + } + defer logFile.Close() + cmd := exec.Command("msiextract", "-C", dest, srcFile) + cmd.Stdout = logFile + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// RelocateBuildTools relocates the components CLI tools actually need (VC, +// Windows Kits, DIA SDK, MSBuild, Common7/Tools) from the scratch unpack +// dir into dest, so the rest of unpack can be discarded. +func RelocateBuildTools(unpack, dest string) error { + components := []string{"VC", "Windows Kits", "DIA SDK", "MSBuild", filepath.Join("Common7", "Tools")} + for _, c := range components { + if err := combineDirTrees(filepath.Join(unpack, c), filepath.Join(dest, c)); err != nil { + return fmt.Errorf("moving %s: %w", c, err) + } + } + return nil +} + +// combineDirTrees moves src into dest, recursively merging where a +// same-named directory already exists in dest (matching case-insensitively) +// rather than clobbering it - MSVC/WinSDK packages aren't casing-consistent +// about where they put things. +func combineDirTrees(src, dest string) error { + if !isDir(src) { + return nil + } + if !isDir(dest) { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + return os.Rename(src, dest) + } + + entries, err := os.ReadDir(src) + if err != nil { + return err + } + destEntries, err := os.ReadDir(dest) + if err != nil { + return err + } + destNames := map[string]string{} + for _, e := range destEntries { + destNames[strings.ToLower(e.Name())] = e.Name() + } + + for _, e := range entries { + n := e.Name() + srcName := filepath.Join(src, n) + destName := filepath.Join(dest, n) + if e.IsDir() { + if isDir(destName) { + if err := combineDirTrees(srcName, destName); err != nil { + return err + } + continue + } + if actual, ok := destNames[strings.ToLower(n)]; ok { + if err := combineDirTrees(srcName, filepath.Join(dest, actual)); err != nil { + return err + } + continue + } + if err := os.Rename(srcName, destName); err != nil { + return err + } + continue + } + if err := os.Rename(srcName, destName); err != nil { + return err + } + } + return nil +} + +// CopyRedirectedAssemblies works around Wine not honoring +// codeBase redirects in an .exe.config file, by copying the referenced DLL +// next to the executable directly. +func CopyRedirectedAssemblies(app string) error { + cfgPath := app + ".config" + if !isFile(cfgPath) { + return nil + } + data, err := os.ReadFile(cfgPath) + if err != nil { + return err + } + + var cfg struct { + Runtime struct { + AssemblyBinding struct { + DependentAssembly []struct { + CodeBase struct { + Href string `xml:"href,attr"` + } `xml:"codeBase"` + } `xml:"dependentAssembly"` + } `xml:"assemblyBinding"` + } `xml:"runtime"` + } + if err := xml.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("parsing %s: %w", cfgPath, err) + } + + dest := filepath.Dir(app) + for _, da := range cfg.Runtime.AssemblyBinding.DependentAssembly { + href := strings.ReplaceAll(da.CodeBase.Href, "\\", "/") + if href == "" { + continue + } + src := filepath.Join(dest, href) + if isFile(src) { + if err := copyFile(src, filepath.Join(dest, filepath.Base(src)), 0o644); err != nil { + return err + } + } + } + return nil +} + +func isDir(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.IsDir() +} + +func isFile(path string) bool { + fi, err := os.Stat(path) + return err == nil && !fi.IsDir() +} + +func exists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + _ = os.Remove(dst) + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} diff --git a/internal/download/fetch.go b/internal/download/fetch.go new file mode 100644 index 0000000..34c072f --- /dev/null +++ b/internal/download/fetch.go @@ -0,0 +1,194 @@ +package download + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sync" + "sync/atomic" + "time" +) + +const maxConcurrentDownloads = 5 +const maxDownloadAttempts = 5 + +// FetchPayloads fetches every payload of every selected package into +// cacheDir//, verifying sha256 and skipping files +// already present and correct. allowHashMismatch (used for --only-download) +// warns instead of failing on a hash mismatch. +func FetchPayloads(selected []*Package, cacheDir string, allowHashMismatch bool) error { + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return err + } + + type task struct { + payload Payload + dest string + fileID string + } + var tasks []task + for _, p := range selected { + if len(p.Payloads) == 0 { + continue + } + dir := filepath.Join(cacheDir, p.Key()) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + for _, pl := range p.Payloads { + name := pl.Name() + tasks = append(tasks, task{ + payload: pl, + dest: filepath.Join(dir, name), + fileID: filepath.Join(p.Key(), name), + }) + } + } + + sem := make(chan struct{}, maxConcurrentDownloads) + var wg sync.WaitGroup + var totalDownloaded int64 + errCh := make(chan error, len(tasks)) + + for _, t := range tasks { + t := t + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + n, err := fetchOnePayloadWithRetries(t.payload, t.dest, t.fileID, allowHashMismatch) + if err != nil { + errCh <- err + return + } + atomic.AddInt64(&totalDownloaded, n) + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + return err + } + } + + fmt.Printf("Downloaded %s in total\n", HumanizeBytes(totalDownloaded)) + return nil +} + +func fetchOnePayloadWithRetries(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) { + var lastErr error + for attempt := 0; attempt < maxDownloadAttempts; attempt++ { + n, err := tryDownloadPayload(payload, dest, fileID, allowHashMismatch) + if err == nil { + return n, nil + } + lastErr = err + fmt.Printf("%v\n", err) + } + return 0, fmt.Errorf("giving up on %s after %d attempts: %w", fileID, maxDownloadAttempts, lastErr) +} + +func tryDownloadPayload(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) { + if fi, err := os.Stat(dest); err == nil && fi.Mode().IsRegular() { + if payload.SHA256 != "" { + sum, err := sha256File(dest) + if err != nil { + return 0, err + } + if !equalFoldHex(sum, payload.SHA256) { + fmt.Printf("Incorrect existing file %s, removing\n", fileID) + os.Remove(dest) + } else { + fmt.Printf("Using existing file %s\n", fileID) + return 0, nil + } + } else { + return 0, nil + } + } + + fmt.Printf("Downloading %s (%s)\n", fileID, HumanizeBytes(payload.Size)) + if err := httpDownloadFile(payload.URL, dest); err != nil { + return 0, err + } + if payload.SHA256 != "" { + sum, err := sha256File(dest) + if err != nil { + return 0, err + } + if !equalFoldHex(sum, payload.SHA256) { + if allowHashMismatch { + fmt.Printf("WARNING: incorrect hash for downloaded file %s\n", fileID) + } else { + return 0, fmt.Errorf("incorrect hash for downloaded file %s, aborting", fileID) + } + } + } + return payload.Size, nil +} + +var downloadHTTPClient = &http.Client{Timeout: 30 * time.Minute} + +func httpDownloadFile(url, dest string) error { + resp, err := downloadHTTPClient.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s: %s", url, resp.Status) + } + tmp := dest + ".part" + out, err := os.Create(tmp) + if err != nil { + return err + } + if _, err := io.Copy(out, resp.Body); err != nil { + out.Close() + os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, dest) +} + +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func equalFoldHex(a, b string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ca, cb := a[i], b[i] + if ca >= 'A' && ca <= 'Z' { + ca += 'a' - 'A' + } + if cb >= 'A' && cb <= 'Z' { + cb += 'a' - 'A' + } + if ca != cb { + return false + } + } + return true +} diff --git a/internal/download/index.go b/internal/download/index.go new file mode 100644 index 0000000..8450230 --- /dev/null +++ b/internal/download/index.go @@ -0,0 +1,170 @@ +package download + +import ( + "regexp" + "sort" + "strings" +) + +// Index maps a lowercased package id to every variant sharing that id, +// sorted by arch/language priority (best match first). +type Index map[string][]*Package + +// BuildIndex indexes every package in m, prioritizing variants matching +// hostArch and language ("" language means no language preference beyond +// the default "en"). +func BuildIndex(m *Manifest, hostArch, language string) Index { + if language == "" { + language = "en" + } + idx := Index{} + for i := range m.Packages { + p := &m.Packages[i] + key := strings.ToLower(p.ID) + idx[key] = append(idx[key], p) + } + for key, list := range idx { + list := list + sort.SliceStable(list, func(i, j int) bool { + return comparePackages(hostArch, language, list[i], list[j]) < 0 + }) + idx[key] = list + } + return idx +} + +// comparePackages ranks arch match first, then language match. +func comparePackages(arch, language string, a, b *Package) int { + archOrd := func(field string, x *Package) int { + if arch == "" { + return 0 + } + var v string + switch field { + case "chip": + v = x.Chip + case "machineArch": + v = x.MachineArch + case "productArch": + v = x.ProductArch + } + v = strings.ToLower(v) + if v == "" || v == "neutral" { + return 0 + } + if v == arch { + return -1 + } + return 1 + } + for _, field := range []string{"chip", "machineArch", "productArch"} { + if r := archOrd(field, a) - archOrd(field, b); r != 0 { + return r + } + } + + lang := strings.ToLower(language) + countryLang := strings.Contains(lang, "-") + langPriority := func(x *Package) int { + xl := strings.ToLower(x.Language) + if xl == "" { + return 0 + } + if (countryLang && xl == lang) || (!countryLang && strings.HasPrefix(xl, lang+"-")) { + return 2 + } + if strings.HasPrefix(xl, "en-") { + return 1 + } + return 0 + } + ap, bp := langPriority(a), langPriority(b) + if ap > bp { + return -1 + } + if ap < bp { + return 1 + } + return 0 +} + +// Find looks up id (case-insensitive), preferring a candidate matching +// constraints' chip/machineArch, falling back to the highest-priority +// variant. Returns nil if id isn't in the index at all. +func (idx Index) Find(id string, constraints map[string]string) *Package { + candidates, ok := idx[strings.ToLower(id)] + if !ok || len(candidates) == 0 { + return nil + } + for _, p := range candidates { + matched := true + for _, k := range []string{"chip", "machineArch"} { + want, has := constraints[k] + if !has { + continue + } + var got string + if k == "chip" { + got = p.Chip + } else { + got = p.MachineArch + } + if !strings.EqualFold(got, want) { + matched = false + break + } + } + if matched { + return p + } + } + return candidates[0] +} + +var knownHostArchs = []string{"x86", "x64", "arm64"} + +// HostArchCompatible reports whether p can run on host: some packages +// encode their host arch in the id itself (e.g. +// Microsoft.VisualCpp.Tools.HostARM64.*). +func HostArchCompatible(p *Package, host string) bool { + if host == "" { + return true + } + id := strings.ToLower(p.ID) + for _, a := range knownHostArchs { + if strings.Contains(id, "host"+a) { + return a == host + } + } + for _, v := range []string{p.Chip, p.MachineArch, p.ProductArch} { + v = strings.ToLower(v) + if v == "" || v == "neutral" { + continue + } + if v != host { + return false + } + } + return true +} + +var reTargetArch = regexp.MustCompile(`\.target(x86|x64|arm64|arm)(\W|$)`) + +// TargetArchCompatible reports whether p is wanted for archs: packages +// naming a target arch in their id (e.g. ...HostX64.TargetX64) are only +// wanted when that arch was requested. +func TargetArchCompatible(p *Package, archs []string) bool { + if archs == nil { + return true + } + m := reTargetArch.FindStringSubmatch(strings.ToLower(p.ID)) + if m == nil { + return true + } + for _, a := range archs { + if a == m[1] { + return true + } + } + return false +} diff --git a/internal/download/manifest.go b/internal/download/manifest.go new file mode 100644 index 0000000..f9ada82 --- /dev/null +++ b/internal/download/manifest.go @@ -0,0 +1,237 @@ +// Package download fetches and unpacks MSVC/WinSDK using the same installer +// manifests Visual Studio's own installer uses. +package download + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Payload is one downloadable file belonging to a Package. +type Payload struct { + FileName string `json:"fileName"` + URL string `json:"url"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +// Name returns the payload's bare file name, stripping any directory +// components the manifest's fileName might carry. +func (p Payload) Name() string { + name := p.FileName + if i := strings.LastIndexByte(name, '\\'); i >= 0 { + name = name[i+1:] + } + if i := strings.LastIndexByte(name, '/'); i >= 0 { + name = name[i+1:] + } + return name +} + +// Dependency is a normalized package dependency: manifests encode these +// either as a bare version string or as an object with version/type/id. +type Dependency struct { + TargetID string // the id to depend on (overrides the map key if set) + Version string + Type string // "", "Optional" or "Recommended" +} + +// LocalizedResource carries the license URL shown before accepting a +// package's terms. +type LocalizedResource struct { + Language string `json:"language"` + License string `json:"license"` +} + +// Package is one entry from the installer manifest's "packages" array. +type Package struct { + ID string `json:"id"` + Type string `json:"type"` + Version string `json:"version"` + Chip string `json:"chip"` + MachineArch string `json:"machineArch"` + ProductArch string `json:"productArch"` + Language string `json:"language"` + Payloads []Payload `json:"payloads"` + InstallSizes map[string]int64 `json:"installSizes"` + LocalizedResources []LocalizedResource `json:"localizedResources"` + + DependenciesRaw map[string]json.RawMessage `json:"dependencies"` + dependencies map[string]Dependency +} + +// Dependencies lazily normalizes DependenciesRaw into Dependency values. +func (p *Package) Dependencies() map[string]Dependency { + if p.dependencies != nil { + return p.dependencies + } + p.dependencies = map[string]Dependency{} + for key, raw := range p.DependenciesRaw { + var version string + if err := json.Unmarshal(raw, &version); err == nil { + p.dependencies[key] = Dependency{Version: version} + continue + } + var d struct { + Version string `json:"version"` + Type string `json:"type"` + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &d); err == nil { + p.dependencies[key] = Dependency{TargetID: d.ID, Version: d.Version, Type: d.Type} + } + } + return p.dependencies +} + +// Key uniquely identifies a specific package variant (id + version + arch), +// used to dedupe an already-included package during dependency resolution +// and to name its cache directory. Mirrors getPackageKey. +func (p *Package) Key() string { + key := p.ID + if p.Version != "" { + key += "-" + p.Version + } + if p.Chip != "" { + key += "-chip." + p.Chip + } + if p.MachineArch != "" { + key += "-machineArch." + p.MachineArch + } + if p.ProductArch != "" { + key += "-productArch." + p.ProductArch + } + return key +} + +func (p *Package) InstalledSize() int64 { + var sum int64 + for _, v := range p.InstallSizes { + sum += v + } + return sum +} + +func (p *Package) DownloadSize() int64 { + var sum int64 + for _, pl := range p.Payloads { + sum += pl.Size + } + return sum +} + +// ChannelItem is one entry of a channel manifest's "channelItems", used only +// to locate the installer manifest URL. +type ChannelItem struct { + Type string `json:"type"` + Payloads []Payload `json:"payloads"` +} + +// Manifest is the top-level installer manifest (or channel manifest, which +// shares the "info" field used for logging). +type Manifest struct { + Info struct { + ProductDisplayVersion string `json:"productDisplayVersion"` + } `json:"info"` + Packages []Package `json:"packages"` + ChannelItems []ChannelItem `json:"channelItems"` +} + +// Manifests (particularly the installer manifest, a single JSON file +// listing every package) can be tens of MB, so give this a generous timeout +// and a few retries - transient network hiccups shouldn't need a full +// restart of `download`. +var httpClient = &http.Client{Timeout: 5 * time.Minute} + +const maxManifestAttempts = 5 + +func httpGet(url string) ([]byte, error) { + var lastErr error + for attempt := 0; attempt < maxManifestAttempts; attempt++ { + data, err := tryHTTPGet(url) + if err == nil { + return data, nil + } + lastErr = err + fmt.Printf("GET %s: %v (retrying)\n", url, err) + } + return nil, fmt.Errorf("GET %s: giving up after %d attempts: %w", url, maxManifestAttempts, lastErr) +} + +func tryHTTPGet(url string) ([]byte, error) { + resp, err := httpClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s", resp.Status) + } + return io.ReadAll(resp.Body) +} + +// FetchChannelManifest downloads the top-level channel manifest for the +// given major VS version ("18" and up use the "stable"/"insiders" channel +// naming, earlier ones use "release"/"pre"), and returns the URL of the +// installer manifest it references. +func FetchChannelManifest(major int, preview bool) (string, error) { + kind := "stable" + if major < 18 { + kind = "release" + } + if preview { + kind = "insiders" + if major < 18 { + kind = "pre" + } + } + url := fmt.Sprintf("https://aka.ms/vs/%d/%s/channel", major, kind) + fmt.Println("Fetching", url) + data, err := httpGet(url) + if err != nil { + return "", err + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return "", fmt.Errorf("parsing channel manifest: %w", err) + } + fmt.Printf("Got toplevel manifest for %s\n", m.Info.ProductDisplayVersion) + for _, item := range m.ChannelItems { + if item.Type == "Manifest" && len(item.Payloads) > 0 { + return item.Payloads[0].URL, nil + } + } + return "", fmt.Errorf("unable to find an installer manifest") +} + +// FetchInstallerManifest downloads and parses the installer manifest at url. +func FetchInstallerManifest(url string) (*Manifest, error) { + data, err := httpGet(url) + if err != nil { + return nil, err + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parsing installer manifest: %w", err) + } + fmt.Printf("Loaded installer manifest for %s\n", m.Info.ProductDisplayVersion) + return &m, nil +} + +// HumanizeBytes renders a byte count as a friendly "1.2 GB" style string. +func HumanizeBytes(s int64) string { + switch { + case s > 900*1024*1024: + return fmt.Sprintf("%.1f GB", float64(s)/(1024*1024*1024)) + case s > 900*1024: + return fmt.Sprintf("%.1f MB", float64(s)/(1024*1024)) + case s > 1024: + return fmt.Sprintf("%.1f KB", float64(s)/1024) + default: + return fmt.Sprintf("%d bytes", s) + } +} diff --git a/internal/download/patch.go b/internal/download/patch.go new file mode 100644 index 0000000..7e413af --- /dev/null +++ b/internal/download/patch.go @@ -0,0 +1,90 @@ +package download + +import ( + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/Cheviiot/msvc-go-wine/assets" +) + +// ApplyCompatibilityFixes applies the embedded Wine compatibility patches +// (assets/patches) against a freshly unpacked+moved dest tree: a +// "foo.props.patch" is git-applied over dest/foo.props (skipped if already +// applied), a "foo.patch"-less file is copied verbatim, and a "foo.remove" +// marker deletes dest/foo. +func ApplyCompatibilityFixes(dest string) error { + return fs.WalkDir(assets.Patches, "patches", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel := strings.TrimPrefix(path, "patches/") + ext := filepath.Ext(rel) + target := strings.TrimSuffix(rel, ext) + + switch ext { + case ".patch": + return applyGitPatch(dest, path, target) + case ".remove": + full := filepath.Join(dest, target) + if isFile(full) { + fmt.Println("Removing", target) + return os.Remove(full) + } + return nil + default: + fmt.Println("Copying", rel) + data, err := assets.Patches.ReadFile(path) + if err != nil { + return err + } + full := filepath.Join(dest, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return err + } + return os.WriteFile(full, data, 0o644) + } + }) +} + +func applyGitPatch(dest, embeddedPath, target string) error { + full := filepath.Join(dest, target) + if !isFile(full) { + return nil + } + data, err := assets.Patches.ReadFile(embeddedPath) + if err != nil { + return err + } + + tmp, err := os.CreateTemp("", "msvc-go-wine-*.patch") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + tmp.Close() + + // Skip if the patch has already been applied (reverse-apply check), + // so re-running download/install stays idempotent. + check := exec.Command("git", "--work-tree=.", "apply", "--quiet", "--reverse", "--check", tmp.Name()) + check.Dir = dest + if check.Run() == nil { + return nil + } + + fmt.Println("Patching", target) + apply := exec.Command("git", "--work-tree=.", "apply", tmp.Name()) + apply.Dir = dest + apply.Stderr = os.Stderr + return apply.Run() +} diff --git a/internal/download/select.go b/internal/download/select.go new file mode 100644 index 0000000..25cb705 --- /dev/null +++ b/internal/download/select.go @@ -0,0 +1,369 @@ +package download + +import ( + "fmt" + "regexp" + "strings" +) + +var reSDKVersion = regexp.MustCompile(`^\d+\.\d+\.\d+`) + +// TriState represents a `--with-*` style flag: nil means "unset, use the +// applicable default"; a set value means the user (or a higher-level +// default) explicitly chose to include/exclude the component. +type TriState = *bool + +func on() TriState { v := true; return &v } +func off() TriState { v := false; return &v } + +// Options holds every flag that feeds package selection and download. +type Options struct { + Package []string + Ignore []string + Architecture []string // subset of x86,x64,arm,arm64,host + HostArch string // x86, x64 or arm64 + OnlyHost bool + + MSVCVersion string // "", "preview", "16.0".."18.0", "15.4".."15.9" + SDKVersion string + + WithDefault TriState + WithWorkload TriState + WithMSVC TriState + WithASAN TriState + WithSDK TriState + WithATL TriState + WithDIA TriState + WithMSBuild TriState + WithDevCmd TriState + + IncludeOptional bool + SkipRecommended bool + Language string + + WithWDKInstallers string +} + +func addIfWanted(opts *Options, flag TriState, pkg string) { + if flag == nil { + return + } + if *flag { + opts.Package = append(opts.Package, pkg) + } else { + opts.Ignore = append(opts.Ignore, pkg) + } +} + +type msvcVersionEntry struct { + gen string // "15" or "16" - which package-name scheme applies + sdk string + toolVersion string +} + +// msvcVersionTable maps a --msvc-version value to the SDK it pulls in by +// default and the toolset version fragment used in package ids. +var msvcVersionTable = map[string]msvcVersionEntry{ + "preview": {"16", "", "Preview"}, + + "16.0": {"16", "10.0.17763", "14.20"}, + "16.1": {"16", "10.0.18362", "14.21"}, + "16.2": {"16", "10.0.18362", "14.22"}, + "16.3": {"16", "10.0.18362", "14.23"}, + "16.4": {"16", "10.0.18362", "14.24"}, + "16.5": {"16", "10.0.18362", "14.25"}, + "16.6": {"16", "10.0.18362", "14.26"}, + "16.7": {"16", "10.0.18362", "14.27"}, + "16.8": {"16", "10.0.18362", "14.28"}, + "16.9": {"16", "10.0.19041", "14.28.16.9"}, + "16.10": {"16", "10.0.19041", "14.29.16.10"}, + "16.11": {"16", "10.0.19041", "14.29.16.11"}, + "17.0": {"16", "10.0.19041", "14.30.17.0"}, + "17.1": {"16", "10.0.19041", "14.31.17.1"}, + "17.2": {"16", "10.0.19041", "14.32.17.2"}, + "17.3": {"16", "10.0.19041", "14.33.17.3"}, + "17.4": {"16", "10.0.22621", "14.34.17.4"}, + "17.5": {"16", "10.0.22621", "14.35.17.5"}, + "17.6": {"16", "10.0.22621", "14.36.17.6"}, + "17.7": {"16", "10.0.22621", "14.37.17.7"}, + "17.8": {"16", "10.0.22621", "14.38.17.8"}, + "17.9": {"16", "10.0.22621", "14.39.17.9"}, + "17.10": {"16", "10.0.22621", "14.40.17.10"}, + "17.11": {"16", "10.0.22621", "14.41.17.11"}, + "17.12": {"16", "10.0.22621", "14.42.17.12"}, + "17.13": {"16", "10.0.22621", "14.43.17.13"}, + "17.14": {"16", "10.0.26100", "14.44.17.14"}, + "18.0": {"16", "10.0.26100", "14.50.18.0"}, + + "15.4": {"15", "10.0.16299", "14.11"}, + "15.5": {"15", "10.0.16299", "14.12"}, + "15.6": {"15", "10.0.16299", "14.13"}, + "15.7": {"15", "10.0.17134", "14.14"}, + "15.8": {"15", "10.0.17134", "14.15"}, + "15.9": {"15", "10.0.17763", "14.16"}, +} + +func selectToolsetV16(opts *Options, idx Index, userVersion, sdk, toolVersion string, defaultPkgs, defaultIgnores []string) { + ext := "" + if toolVersion == "Preview" { + ext = ".Tools" + } + base := "Microsoft.VisualStudio.Component.VC." + toolVersion + ext + if idx.Find(base+".x86.x64", nil) != nil { + if contains(opts.Architecture, "x86") || contains(opts.Architecture, "x64") { + addIfWanted(opts, opts.WithMSVC, base+".x86.x64") + addIfWanted(opts, opts.WithASAN, "Microsoft.VC."+toolVersion+".ASAN.X86") + addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC."+toolVersion+".ATL") + } + if contains(opts.Architecture, "arm") { + addIfWanted(opts, opts.WithMSVC, "Microsoft.VisualStudio.Component.VC."+toolVersion+".ARM") + addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC."+toolVersion+".ATL.ARM") + } + if contains(opts.Architecture, "arm64") { + addIfWanted(opts, opts.WithMSVC, "Microsoft.VisualStudio.Component.VC."+toolVersion+".ARM64") + addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC."+toolVersion+".ATL.ARM64") + } + if opts.SDKVersion == "" { + opts.SDKVersion = sdk + } + } else { + fmt.Printf("Didn't find exact version packages for %s, assuming this is provided by the default/latest version\n", userVersion) + opts.Package = append(opts.Package, defaultPkgs...) + opts.Ignore = append(opts.Ignore, defaultIgnores...) + } +} + +func selectToolsetV15(opts *Options, idx Index, userVersion, sdk, toolVersion string, defaultPkgs, defaultIgnores []string) { + id := "Microsoft.VisualStudio.Component.VC.Tools." + toolVersion + if idx.Find(id, nil) != nil { + addIfWanted(opts, opts.WithMSVC, id) + if opts.SDKVersion == "" { + opts.SDKVersion = sdk + } + } else { + fmt.Printf("Didn't find exact version packages for %s, assuming this is provided by the default/latest version\n", userVersion) + opts.Package = append(opts.Package, defaultPkgs...) + opts.Ignore = append(opts.Ignore, defaultIgnores...) + } +} + +// ResolveSelection turns Options' high-level flags (--with-*, +// --msvc-version, --sdk-version, explicit packages) into the final +// opts.Package/opts.Ignore lists ready for ExpandSelection. +func ResolveSelection(opts *Options, idx Index) error { + if len(opts.Architecture) == 0 { + opts.Architecture = []string{"host", "x86", "x64", "arm", "arm64"} + } + if opts.HostArch != "" && contains(opts.Architecture, "host") { + opts.Architecture = append(opts.Architecture, opts.HostArch) + } + + if opts.MSVCVersion != "" { + if opts.WithMSVC == nil { + opts.WithMSVC = on() + } + if opts.WithASAN == nil { + opts.WithASAN = on() + } + if opts.WithATL == nil { + opts.WithATL = on() + } + if opts.WithSDK == nil { + opts.WithSDK = on() + } + } + if opts.SDKVersion != "" && opts.WithSDK == nil { + opts.WithSDK = on() + } + + if opts.WithDefault == nil && opts.MSVCVersion == "" && len(opts.Package) == 0 { + opts.WithDefault = on() + } + if opts.WithDefault != nil { + for _, pair := range []struct { + flag *TriState + }{ + {&opts.WithWorkload}, {&opts.WithMSVC}, {&opts.WithASAN}, {&opts.WithSDK}, + {&opts.WithATL}, {&opts.WithDIA}, {&opts.WithMSBuild}, {&opts.WithDevCmd}, + } { + if *pair.flag == nil { + v := *opts.WithDefault + *pair.flag = &v + } + } + } + + defaultPkgs, defaultIgnores := opts.Package, opts.Ignore + opts.Package, opts.Ignore = nil, nil + + addIfWanted(opts, opts.WithWorkload, "Microsoft.VisualStudio.Workload.VCTools") + if contains(opts.Architecture, "x86") || contains(opts.Architecture, "x64") { + addIfWanted(opts, opts.WithMSVC, "Microsoft.VisualStudio.Component.VC.Tools.x86.x64") + addIfWanted(opts, opts.WithASAN, "Microsoft.VisualCpp.ASAN.X86") + addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC.ATL") + } + if contains(opts.Architecture, "arm") { + addIfWanted(opts, opts.WithMSVC, "Microsoft.VisualStudio.Component.VC.Tools.ARM") + addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC.ATL.ARM") + } + if contains(opts.Architecture, "arm64") { + addIfWanted(opts, opts.WithMSVC, "Microsoft.VisualStudio.Component.VC.Tools.ARM64") + addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC.ATL.ARM64") + } + + defaultPkgs, opts.Package = opts.Package, defaultPkgs + defaultIgnores, opts.Ignore = opts.Ignore, defaultIgnores + + switch { + case opts.MSVCVersion == "": + opts.Package = append(opts.Package, defaultPkgs...) + opts.Ignore = append(opts.Ignore, defaultIgnores...) + default: + entry, ok := msvcVersionTable[opts.MSVCVersion] + if !ok { + return fmt.Errorf("unsupported MSVC toolchain version %s", opts.MSVCVersion) + } + if entry.gen == "15" { + selectToolsetV15(opts, idx, opts.MSVCVersion, entry.sdk, entry.toolVersion, defaultPkgs, defaultIgnores) + } else { + selectToolsetV16(opts, idx, opts.MSVCVersion, entry.sdk, entry.toolVersion, defaultPkgs, defaultIgnores) + } + } + + if err := selectSDK(opts, idx); err != nil { + return err + } + + addIfWanted(opts, opts.WithDIA, "Microsoft.VisualCpp.DIA.SDK") + addIfWanted(opts, opts.WithMSBuild, "Microsoft.Build") + addIfWanted(opts, opts.WithMSBuild, "Microsoft.Build.Dependencies") + addIfWanted(opts, opts.WithDevCmd, "Microsoft.VisualStudio.VC.vcvars") + addIfWanted(opts, opts.WithDevCmd, "Microsoft.VisualStudio.PackageGroup.VsDevCmd") + + if opts.WithWDKInstallers != "" { + opts.Package = append(opts.Package, "Component.Microsoft.Windows.DriverKit.BuildTools") + } + + normalizeIgnoreCase(opts) + return nil +} + +func selectSDK(opts *Options, idx Index) error { + switch { + case opts.WithSDK == nil: + return nil + case !*opts.WithSDK: + for key := range idx { + if strings.HasPrefix(key, "win10sdk") || strings.HasPrefix(key, "win11sdk") { + opts.Ignore = append(opts.Ignore, key) + } + } + case opts.SDKVersion == "": + saved := *opts + opts.Package = []string{"Microsoft.VisualStudio.Workload.VCTools"} + opts.IncludeOptional = false + opts.SkipRecommended = false + recommended, err := ExpandSelection(idx, opts) + *opts = saved + if err != nil { + return err + } + for _, p := range recommended { + key := strings.ToLower(p.ID) + if strings.HasPrefix(key, "win10sdk") || strings.HasPrefix(key, "win11sdk") { + opts.Package = append(opts.Package, key) + } + } + default: + found := false + var versions []string + for key := range idx { + if !strings.HasPrefix(key, "win10sdk") && !strings.HasPrefix(key, "win11sdk") { + continue + } + version := key[9:] + if reSDKVersion.MatchString(version) { + versions = append(versions, version) + } + if key == key[:8]+"_"+opts.SDKVersion { + found = true + opts.Package = append(opts.Package, key) + } else { + opts.Ignore = append(opts.Ignore, key) + } + } + if !found { + return fmt.Errorf("WinSDK version %s not found (available: %s)", opts.SDKVersion, strings.Join(versions, ", ")) + } + } + return nil +} + +func normalizeIgnoreCase(opts *Options) { + for i, s := range opts.Ignore { + opts.Ignore[i] = strings.ToLower(s) + } +} + +func contains(list []string, v string) bool { + for _, s := range list { + if s == v { + return true + } + } + return false +} + +// collectDependencyClosure recursively resolves target's dependency tree, +// honoring arch matching, --ignore, and Optional/Recommended filtering. +func collectDependencyClosure(idx Index, included map[string]bool, target string, constraints map[string]string, opts *Options) []*Package { + if contains(opts.Ignore, strings.ToLower(target)) { + return nil + } + p := idx.Find(target, constraints) + if p == nil { + return nil + } + if opts.OnlyHost && !HostArchCompatible(p, opts.HostArch) { + return nil + } + if !TargetArchCompatible(p, opts.Architecture) { + return nil + } + key := p.Key() + if included[key] { + return nil + } + included[key] = true + + ret := []*Package{p} + for target, dep := range p.Dependencies() { + id := target + if dep.TargetID != "" { + id = dep.TargetID + } + if dep.Type == "Optional" && !opts.IncludeOptional { + continue + } + if dep.Type == "Recommended" && opts.SkipRecommended { + continue + } + c := map[string]string{} + if dep.Version != "" { + c["version"] = dep.Version + } + ret = append(ret, collectDependencyClosure(idx, included, id, c, opts)...) + } + return ret +} + +// ExpandSelection resolves opts.Package (and everything they transitively +// depend on) into a flat, deduped package list. +func ExpandSelection(idx Index, opts *Options) ([]*Package, error) { + included := map[string]bool{} + var ret []*Package + for _, id := range opts.Package { + ret = append(ret, collectDependencyClosure(idx, included, id, nil, opts)...) + } + return ret, nil +} diff --git a/internal/download/select_test.go b/internal/download/select_test.go new file mode 100644 index 0000000..5f43cb8 --- /dev/null +++ b/internal/download/select_test.go @@ -0,0 +1,221 @@ +package download + +import ( + "encoding/json" + "testing" +) + +// fixtureManifest is a trimmed-down stand-in for a real installer manifest, +// covering: a workload -> component -> {required, Optional, Recommended} +// dependency chain, host-arch variants of the same package id, and +// language variants - enough to exercise BuildIndex/collectDependencyClosure +// offline, without ever hitting the network. +const fixtureManifestJSON = `{ + "info": {"productDisplayVersion": "test"}, + "packages": [ + { + "id": "Microsoft.VisualStudio.Workload.VCTools", + "type": "Workload", + "dependencies": { + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64": "1.0" + } + }, + { + "id": "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "type": "Component", + "dependencies": { + "Microsoft.VC.Tools.Core": "1.0", + "Microsoft.VC.Tools.Optional": {"version": "1.0", "type": "Optional"}, + "Microsoft.VC.Tools.Recommended": {"version": "1.0", "type": "Recommended"} + } + }, + { + "id": "Microsoft.VC.Tools.Core", + "type": "Msi", + "machineArch": "x86", + "payloads": [{"fileName": "core-x86.msi", "url": "https://example.invalid/core-x86.msi", "sha256": "aa", "size": 100}] + }, + { + "id": "Microsoft.VC.Tools.Core", + "type": "Msi", + "machineArch": "arm64", + "payloads": [{"fileName": "core-arm64.msi", "url": "https://example.invalid/core-arm64.msi", "sha256": "bb", "size": 200}] + }, + { + "id": "Microsoft.VC.Tools.Optional", + "type": "Msi", + "payloads": [{"fileName": "optional.msi", "url": "https://example.invalid/optional.msi", "size": 10}] + }, + { + "id": "Microsoft.VC.Tools.Recommended", + "type": "Msi", + "payloads": [{"fileName": "recommended.msi", "url": "https://example.invalid/recommended.msi", "size": 20}] + }, + { + "id": "Microsoft.VisualStudio.Resources", + "type": "Msi", + "language": "en-US", + "payloads": [{"fileName": "res-en.msi", "url": "https://example.invalid/res-en.msi", "size": 1}] + }, + { + "id": "Microsoft.VisualStudio.Resources", + "type": "Msi", + "language": "de-DE", + "payloads": [{"fileName": "res-de.msi", "url": "https://example.invalid/res-de.msi", "size": 1}] + } + ] +}` + +func fixtureIndex(t *testing.T, hostArch string) Index { + t.Helper() + var m Manifest + if err := json.Unmarshal([]byte(fixtureManifestJSON), &m); err != nil { + t.Fatalf("parsing fixture manifest: %v", err) + } + return BuildIndex(&m, hostArch, "en") +} + +func TestBuildIndexPrioritizesHostArch(t *testing.T) { + idx := fixtureIndex(t, "x86") + p := idx.Find("Microsoft.VC.Tools.Core", nil) + if p == nil { + t.Fatal("expected to find Microsoft.VC.Tools.Core") + } + if p.MachineArch != "x86" { + t.Errorf("expected x86 variant to sort first for host x86, got machineArch=%q", p.MachineArch) + } + + idx64 := fixtureIndex(t, "arm64") + p64 := idx64.Find("Microsoft.VC.Tools.Core", nil) + if p64.MachineArch != "arm64" { + t.Errorf("expected arm64 variant to sort first for host arm64, got machineArch=%q", p64.MachineArch) + } +} + +func TestBuildIndexPrioritizesLanguage(t *testing.T) { + idx := fixtureIndex(t, "x86") + p := idx.Find("Microsoft.VisualStudio.Resources", nil) + if p == nil { + t.Fatal("expected to find Microsoft.VisualStudio.Resources") + } + if p.Language != "en-US" { + t.Errorf("expected en-US variant to sort first for language=en, got %q", p.Language) + } +} + +func TestAggregateDependsDefaultExcludesOptionalOnly(t *testing.T) { + // Optional deps are skipped unless --include-optional is passed, but + // Recommended deps are pulled in unless --skip-recommended is passed - + // it's "recommended", not "extra". + idx := fixtureIndex(t, "x86") + opts := &Options{ + Package: []string{"Microsoft.VisualStudio.Workload.VCTools"}, + HostArch: "x86", + OnlyHost: true, + } + selected, err := ExpandSelection(idx, opts) + if err != nil { + t.Fatal(err) + } + ids := idsOf(selected) + + mustContain(t, ids, "Microsoft.VisualStudio.Workload.VCTools") + mustContain(t, ids, "Microsoft.VisualStudio.Component.VC.Tools.x86.x64") + mustContain(t, ids, "Microsoft.VC.Tools.Core") + mustContain(t, ids, "Microsoft.VC.Tools.Recommended") + mustNotContain(t, ids, "Microsoft.VC.Tools.Optional") +} + +func TestAggregateDependsIncludeOptional(t *testing.T) { + idx := fixtureIndex(t, "x86") + opts := &Options{ + Package: []string{"Microsoft.VisualStudio.Workload.VCTools"}, + HostArch: "x86", + OnlyHost: true, + IncludeOptional: true, + } + selected, err := ExpandSelection(idx, opts) + if err != nil { + t.Fatal(err) + } + ids := idsOf(selected) + mustContain(t, ids, "Microsoft.VC.Tools.Optional") + // Recommended is included by default (only --skip-recommended excludes it). + mustContain(t, ids, "Microsoft.VC.Tools.Recommended") +} + +func TestAggregateDependsSkipRecommended(t *testing.T) { + idx := fixtureIndex(t, "x86") + opts := &Options{ + Package: []string{"Microsoft.VisualStudio.Workload.VCTools"}, + HostArch: "x86", + OnlyHost: true, + SkipRecommended: true, + } + selected, err := ExpandSelection(idx, opts) + if err != nil { + t.Fatal(err) + } + ids := idsOf(selected) + mustNotContain(t, ids, "Microsoft.VC.Tools.Recommended") +} + +func TestAggregateDependsRespectsIgnore(t *testing.T) { + idx := fixtureIndex(t, "x86") + opts := &Options{ + Package: []string{"Microsoft.VisualStudio.Workload.VCTools"}, + Ignore: []string{"microsoft.vc.tools.core"}, + HostArch: "x86", + OnlyHost: true, + } + selected, err := ExpandSelection(idx, opts) + if err != nil { + t.Fatal(err) + } + ids := idsOf(selected) + mustNotContain(t, ids, "Microsoft.VC.Tools.Core") + mustContain(t, ids, "Microsoft.VisualStudio.Component.VC.Tools.x86.x64") +} + +func TestAggregateDependsHostArchMismatchExcludes(t *testing.T) { + idx := fixtureIndex(t, "x86") + opts := &Options{ + Package: []string{"Microsoft.VC.Tools.Core"}, + HostArch: "arm", // no arm variant exists, only x86/arm64 + OnlyHost: true, + } + selected, err := ExpandSelection(idx, opts) + if err != nil { + t.Fatal(err) + } + if len(selected) != 0 { + t.Errorf("expected no packages selected for mismatched host arch, got %v", idsOf(selected)) + } +} + +func idsOf(pkgs []*Package) []string { + var ids []string + for _, p := range pkgs { + ids = append(ids, p.ID) + } + return ids +} + +func mustContain(t *testing.T, ids []string, want string) { + t.Helper() + for _, id := range ids { + if id == want { + return + } + } + t.Errorf("expected %v to contain %q", ids, want) +} + +func mustNotContain(t *testing.T, ids []string, unwanted string) { + t.Helper() + for _, id := range ids { + if id == unwanted { + t.Errorf("expected %v to NOT contain %q", ids, unwanted) + } + } +} diff --git a/internal/install/fixinclude.go b/internal/install/fixinclude.go new file mode 100644 index 0000000..e6923eb --- /dev/null +++ b/internal/install/fixinclude.go @@ -0,0 +1,90 @@ +package install + +import ( + "os" + "path/filepath" + "regexp" + "strings" +) + +// FixIncludeOptions controls how FixInclude rewrites include directives. +type FixIncludeOptions struct { + // MapWinSDK restores the canonical "GL/" casing after lowercasing, + // since that's the cross-platform spelling for that header directory. + MapWinSDK bool +} + +// reIncludeLine matches `#include ` or `#include "foo.h"`, but +// not `#include IDENTIFIER` (macro-expanded includes). +var reIncludeLine = regexp.MustCompile(`^\s*#\s*include\s+["<][\w.\\/]+[">]`) + +// FixInclude rewrites #include directives under root to reference the +// lowercase header names produced by Lowercase, since MSVC/WinSDK headers +// reference each other with casing that's internally inconsistent (but +// self-consistent once lowercased). Every text file under root is +// rewritten with normalized (LF) line endings as a side effect. +func FixInclude(root string, opts FixIncludeOptions) error { + var doDir func(dir string) error + doDir = func(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, e := range entries { + path := filepath.Join(dir, e.Name()) + if e.Type()&os.ModeSymlink != 0 { + continue + } + if e.IsDir() { + if err := doDir(path); err != nil { + return err + } + continue + } + if err := fixFile(path, opts); err != nil { + return err + } + } + return nil + } + return doDir(root) +} + +func fixFile(path string, opts FixIncludeOptions) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if reIncludeLine.MatchString(line) { + code, comment := line, "" + if idx := strings.Index(line, "//"); idx >= 0 { + code, comment = line[:idx], line[idx:] + } + code = lowercaseAndSlash(code) + if opts.MapWinSDK { + code = strings.Replace(code, "gl/", "GL/", 1) + } + line = code + comment + } + lines[i] = strings.TrimRight(line, "\r\n") + } + return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644) +} + +// lowercaseAndSlash lowercases ASCII letters and turns backslashes into +// forward slashes, leaving everything else (including non-ASCII bytes) +// untouched. +func lowercaseAndSlash(s string) string { + b := []byte(s) + for i, c := range b { + switch { + case c >= 'A' && c <= 'Z': + b[i] = c - 'A' + 'a' + case c == '\\': + b[i] = '/' + } + } + return string(b) +} diff --git a/internal/install/install.go b/internal/install/install.go new file mode 100644 index 0000000..774ed21 --- /dev/null +++ b/internal/install/install.go @@ -0,0 +1,442 @@ +// Package install wires up a downloaded MSVC/WinSDK tree so the msvc-go-wine +// wrapper commands can find it: locating the installed toolchain/SDK +// versions, fixing up header/library name casing, laying out the +// per-architecture tool symlinks, and building the toolrelay helper. +package install + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + + "github.com/Cheviiot/msvc-go-wine/assets" + "github.com/Cheviiot/msvc-go-wine/internal/wineenv" +) + +var archs = []string{"x86", "x64", "arm", "arm64"} + +// Install wires up dest (a directory previously populated by +// `msvc-go-wine download --dest dest`) with the tool wrapper symlinks and +// env.json config the wrapper runtime expects. selfBinary is the path to +// the currently running msvc-go-wine executable, copied into dest/bin so +// the arch-specific tool symlinks have something to point at. +func Install(dest, selfBinary string) error { + dest, err := filepath.Abs(dest) + if err != nil { + return err + } + if fi, err := os.Stat(dest); err != nil || !fi.IsDir() { + return fmt.Errorf("destination %q is not a directory", dest) + } + + // Targets are relative so the whole installed tree stays relocatable - + // moving or renaming dest doesn't break these symlinks the way an + // absolute target baked in at install time would. + if err := lnS("Windows Kits", filepath.Join(dest, "kits")); err != nil { + return err + } + if err := lnS("VC", filepath.Join(dest, "vc")); err != nil { + return err + } + if err := lnS("Tools", filepath.Join(dest, "vc", "tools")); err != nil { + return err + } + if err := lnS("MSVC", filepath.Join(dest, "vc", "tools", "msvc")); err != nil { + return err + } + + msvcRoot := filepath.Join(dest, "vc", "tools", "msvc") + msvcVer, err := findMSVCVersion(msvcRoot) + if err != nil { + return err + } + fmt.Println("Using MSVC version", msvcVer) + msvcDir := filepath.Join(msvcRoot, msvcVer) + + if err := fixLibCasing(filepath.Join(msvcDir, "lib")); err != nil { + return err + } + + includeDir := filepath.Join(msvcDir, "include") + if err := Lowercase(includeDir, LowercaseOptions{Symlink: true}); err != nil { + return fmt.Errorf("lowercasing %s: %w", includeDir, err) + } + if err := FixInclude(includeDir, FixIncludeOptions{}); err != nil { + return fmt.Errorf("fixing includes in %s: %w", includeDir, err) + } + atlIncludeDir := filepath.Join(msvcDir, "atlmfc", "include") + if isDir(atlIncludeDir) { + if err := FixInclude(atlIncludeDir, FixIncludeOptions{}); err != nil { + return fmt.Errorf("fixing includes in %s: %w", atlIncludeDir, err) + } + } + + binDir := filepath.Join(msvcDir, "bin") + if err := removeVctip(binDir); err != nil { + return err + } + if err := renameHostDirs(binDir); err != nil { + return err + } + + kits10 := filepath.Join(dest, "kits", "10") + if !isDir(kits10) { + return fmt.Errorf("%s not found - expected a Windows SDK already unpacked by `msvc-go-wine download`", kits10) + } + if err := lnS("Lib", filepath.Join(kits10, "lib")); err != nil { + return err + } + if err := lnS("Include", filepath.Join(kits10, "include")); err != nil { + return err + } + + sdkVer, err := findSDKVersion(filepath.Join(kits10, "include")) + if err != nil { + return err + } + fmt.Println("Using SDK version", sdkVer) + + for _, sub := range []string{"um", "shared", "winrt", "km"} { + dir := filepath.Join(kits10, "include", sdkVer, sub) + if !isDir(dir) { + continue + } + if err := Lowercase(dir, LowercaseOptions{Symlink: true, MapWinSDK: true}); err != nil { + return fmt.Errorf("lowercasing %s: %w", dir, err) + } + if err := FixInclude(dir, FixIncludeOptions{MapWinSDK: true}); err != nil { + return fmt.Errorf("fixing includes in %s: %w", dir, err) + } + } + wdfDir := filepath.Join(kits10, "include", "wdf") + if isDir(wdfDir) { + if err := Lowercase(wdfDir, LowercaseOptions{Symlink: true, MapWinSDK: true}); err != nil { + return err + } + if err := FixInclude(wdfDir, FixIncludeOptions{MapWinSDK: true}); err != nil { + return err + } + } + + for _, arch := range archs { + for _, sub := range []string{"um", "km"} { + dir := filepath.Join(kits10, "lib", sdkVer, sub, arch) + if !isDir(dir) { + continue + } + if err := Lowercase(dir, LowercaseOptions{Symlink: true}); err != nil { + return fmt.Errorf("lowercasing %s: %w", dir, err) + } + } + } + + host, dotnetHost := hostArch() + + modulesRel := filepath.Join("VC", "Tools", "MSVC", msvcVer, "modules") + if isDir(filepath.Join(dest, modulesRel)) { + if err := lnS(modulesRel, filepath.Join(dest, "modules")); err != nil { + return err + } + } + + destBin := filepath.Join(dest, "bin") + if err := os.MkdirAll(destBin, 0o755); err != nil { + return err + } + sharedBinary := filepath.Join(destBin, "msvc-go-wine") + if err := copyFile(selfBinary, sharedBinary, 0o755); err != nil { + return fmt.Errorf("installing shared binary: %w", err) + } + + installed := 0 + for _, arch := range archs { + clExe := filepath.Join(msvcDir, "bin", "Host"+host, arch, "cl.exe") + if !isFile(clExe) { + continue + } + if err := setupWrapperDir(destBin, selfBinary, arch, host, dotnetHost, msvcVer, sdkVer); err != nil { + return err + } + installed++ + fmt.Println("Installed tool wrappers for", arch) + } + if installed == 0 { + return fmt.Errorf("no target architecture found under %s/bin/Host%s/*", msvcDir, host) + } + + if bootstrapWine() { + // Best-effort: if this fails, the wrapper runtime just falls back + // to invoking tools directly through wine, without toolrelay.exe's + // mt.exe/CMake exit-code fixup. + if err := buildToolRelay(dest, destBin, host); err != nil { + fmt.Println("Building toolrelay failed (continuing without it):", err) + } else { + fmt.Println("Build toolrelay done.") + } + } + return nil +} + +func findMSVCVersion(msvcRoot string) (string, error) { + entries, err := os.ReadDir(msvcRoot) + if err != nil { + return "", fmt.Errorf("reading %s: %w", msvcRoot, err) + } + var names []string + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + // `ls -r`: reverse of the default (lexical) sort order. + sort.Sort(sort.Reverse(sort.StringSlice(names))) + for _, name := range names { + dir := filepath.Join(msvcRoot, name) + if isDir(filepath.Join(dir, "bin")) && isDir(filepath.Join(dir, "include")) && isDir(filepath.Join(dir, "lib")) { + return name, nil + } + } + return "", fmt.Errorf("no suitable MSVC version found under %s", msvcRoot) +} + +func findSDKVersion(includeDir string) (string, error) { + entries, err := os.ReadDir(includeDir) + if err != nil { + return "", fmt.Errorf("reading %s: %w", includeDir, err) + } + var versions []string + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), "10.") { + versions = append(versions, e.Name()) + } + } + if len(versions) == 0 { + return "", fmt.Errorf("no Windows SDK version (10.*) found under %s", includeDir) + } + sort.Strings(versions) + return versions[len(versions)-1], nil +} + +// fixLibCasing adds uppercase symlinks (LIBCMT.lib -> libcmt.lib, etc) so +// lld-link can resolve the /DEFAULTLIB directives cl.exe emits, which name +// these libs in upper case, on a case-sensitive filesystem. +func fixLibCasing(libDir string) error { + names := []string{"libcmt", "libcmtd", "msvcrt", "msvcrtd", "oldnames"} + for _, arch := range archs { + dir := filepath.Join(libDir, arch) + if !isDir(dir) { + continue + } + for _, n := range names { + lower := filepath.Join(dir, n+".lib") + if !isFile(lower) { + continue + } + upper := filepath.Join(dir, strings.ToUpper(n)+".lib") + if err := lnS(n+".lib", upper); err != nil { + return err + } + } + } + return nil +} + +// removeVctip deletes any vctip.exe found under dir: it phones home to +// Microsoft and is known to cause problems under Wine. +func removeVctip(dir string) error { + return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.EqualFold(d.Name(), "vctip.exe") { + return os.Remove(path) + } + return nil + }) +} + +// renameHostDirs normalizes the several casings different MSVC releases +// have used for their host-arch bin directories. +func renameHostDirs(binDir string) error { + renames := []struct{ from, to string }{ + {"HostX64", "Hostx64"}, // 15.x - 16.4 + {"HostARM64", "Hostarm64"}, // 17.2 - 17.3 + {"HostArm64", "Hostarm64"}, // 17.4 + } + for _, r := range renames { + from := filepath.Join(binDir, r.from) + to := filepath.Join(binDir, r.to) + if isDir(from) && !exists(to) { + if err := os.Rename(from, to); err != nil { + return err + } + } + } + oldArm64 := filepath.Join(binDir, "Hostarm64", "ARM64") + newArm64 := filepath.Join(binDir, "Hostarm64", "arm64") + if isDir(oldArm64) && !exists(newArm64) { + if err := os.Rename(oldArm64, newArm64); err != nil { + return err + } + } + return nil +} + +// setupWrapperDir creates / with its own local copy of the +// msvc-go-wine binary (not a symlink to the shared one in destBin), and +// symlinks every tool name to that LOCAL copy. +// +// This matters: the wrapper runtime locates its own install root via +// os.Executable(), which fully resolves symlinks (like /proc/self/exe does) +// - it can't rely on os.Args[0], since not every shell passes a +// PATH-resolved absolute path as argv[0] (some just pass the bare command +// name, e.g. "cl", which would make a naive argv[0]-based lookup resolve +// against the caller's cwd instead of the install dir). A same-directory +// symlink (cl -> msvc-go-wine) resolves to a binary that's still in the +// right arch dir; a symlink to a binary one level up (cl -> ../msvc-go-wine) +// would not be. +func setupWrapperDir(destBin, selfBinary, arch, host, dotnetHost, msvcVer, sdkVer string) error { + archDir := filepath.Join(destBin, arch) + if err := os.MkdirAll(archDir, 0o755); err != nil { + return err + } + localBinary := filepath.Join(archDir, "msvc-go-wine") + if err := copyFile(selfBinary, localBinary, 0o755); err != nil { + return fmt.Errorf("installing per-arch binary: %w", err) + } + for name := range toolNames { + if err := lnS("msvc-go-wine", filepath.Join(archDir, name)); err != nil { + return err + } + if err := lnS("msvc-go-wine", filepath.Join(archDir, name+".exe")); err != nil { + return err + } + } + cfg := &wineenv.Config{ + Arch: arch, + Host: host, + DotnetHost: dotnetHost, + MSVCVer: msvcVer, + SDKVer: sdkVer, + } + return cfg.Save(archDir) +} + +// toolNames is the set of tool wrapper symlinks created in every arch dir. +var toolNames = map[string]bool{ + "cl": true, "link": true, "lib": true, "ml": true, "ml64": true, + "mc": true, "midl": true, "mt": true, "rc": true, "dumpbin": true, + "msbuild": true, "nmake": true, "armasm": true, "armasm64": true, + "cmd": true, "findstr": true, +} + +func hostArch() (host, dotnetHost string) { + switch runtime.GOARCH { + case "arm64": + return "arm64", "arm64" + default: + return "x64", "amd64" + } +} + +// bootstrapWine runs `wineboot --init` to set up the wine prefix ahead of +// time, and reports whether wine is available at all (so callers can decide +// whether to attempt building toolrelay.exe). +func bootstrapWine() bool { + wine, err := wineenv.FindWine() + if err != nil { + fmt.Println("wine not found, skipping wineboot bootstrap and toolrelay build (install wine before running the tools)") + return false + } + fmt.Println("Bootstrapping wine prefix...") + if err := runQuiet(wine, "wineboot", "--init"); err != nil { + fmt.Println("wineboot --init failed (continuing):", err) + } + return true +} + +// buildToolRelay compiles the vendored toolrelay.cpp helper using the +// freshly-installed host-arch cl wrapper. toolrelay.exe is what lets the +// wrapper runtime give mt.exe's CMake-compatibility exit code +// (0x41020001 -> 0xbb) a chance to survive Wine's own exit-code truncation - +// see internal/wrapper's runViaToolRelay. Best-effort: any failure here just +// means the wrapper runtime falls back to a plain wine invocation without +// that fixup. +func buildToolRelay(dest, destBin, host string) error { + clWrapper := filepath.Join(destBin, host, "cl") + if !isFile(clWrapper) { + return fmt.Errorf("no cl wrapper for host arch %s at %s", host, clWrapper) + } + + srcPath := filepath.Join(dest, "toolrelay.cpp") + if err := os.WriteFile(srcPath, assets.ToolRelaySource, 0o644); err != nil { + return err + } + defer os.Remove(srcPath) + + fmt.Println("Build toolrelay ...") + cmd := exec.Command(clWrapper, "/EHsc", "/O2", srcPath) + cmd.Dir = dest + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("compiling toolrelay.cpp: %w", err) + } + + exePath := filepath.Join(dest, "toolrelay.exe") + objPath := filepath.Join(dest, "toolrelay.obj") + defer os.Remove(objPath) + if !isFile(exePath) { + return fmt.Errorf("cl did not produce %s", exePath) + } + return os.Rename(exePath, filepath.Join(destBin, "toolrelay.exe")) +} + +func runQuiet(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Env = append(os.Environ(), "WINEDEBUG=-all") + return cmd.Run() +} + +func lnS(target, link string) error { + if _, err := os.Lstat(link); err == nil { + return nil + } + return os.Symlink(target, link) +} + +func isDir(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.IsDir() +} + +func isFile(path string) bool { + fi, err := os.Stat(path) + return err == nil && !fi.IsDir() +} + +func exists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + _ = os.Remove(dst) + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} diff --git a/internal/install/lowercase.go b/internal/install/lowercase.go new file mode 100644 index 0000000..f105076 --- /dev/null +++ b/internal/install/lowercase.go @@ -0,0 +1,131 @@ +package install + +import ( + "os" + "path/filepath" + "strings" +) + +// LowercaseOptions controls how Lowercase renames entries. +type LowercaseOptions struct { + // Symlink adds lowercase-named symlinks alongside the original entries + // instead of renaming them in place (used for WinSDK/MSVC headers so + // both casings stay available; renaming would break other packages + // that reference the original casing). + Symlink bool + // MapWinSDK is shorthand for path-keyed overrides that keep the "GL" + // header directory's canonical uppercase spelling. + MapWinSDK bool +} + +// Lowercase recursively lowercases every file/dir name under root, merging +// into an existing same-named lowercase directory on collision. +func Lowercase(root string, opts LowercaseOptions) error { + mapPaths := map[string]string{} + if opts.MapWinSDK { + mapPaths["gl"] = "GL" + } + + remap := func(relPath string) string { + rp := strings.TrimSuffix(relPath, "/") + base := rp + if idx := strings.LastIndexByte(rp, '/'); idx >= 0 { + base = rp[idx+1:] + } + if opts.MapWinSDK { + if v, ok := mapPaths[strings.ToLower(rp)]; ok { + return v + } + } + return strings.ToLower(base) + } + + var doDir func(dir, relPath string) error + doDir = func(dir, relPath string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, e := range entries { + name := e.Name() + childPath := filepath.Join(dir, name) + if e.IsDir() { + if err := doDir(childPath, relPath+name+"/"); err != nil { + return err + } + continue + } + newName := remap(relPath + name) + if newName != name { + if err := renameOrSymlink(childPath, dir, newName, opts.Symlink); err != nil { + return err + } + } + } + + var newName string + if relPath == "" { + newName = strings.ToLower(filepath.Base(dir)) + } else { + newName = remap(relPath) + } + oldName := filepath.Base(dir) + if oldName == newName { + return nil + } + parent := filepath.Dir(dir) + newPath := filepath.Join(parent, newName) + if fi, err := os.Stat(newPath); err == nil && fi.IsDir() { + return combineIntoDir(dir, newPath, opts.Symlink) + } + return renameOrSymlink(dir, parent, newName, opts.Symlink) + } + + return doDir(root, "") +} + +func renameOrSymlink(src, destDir, destName string, symlink bool) error { + dest := filepath.Join(destDir, destName) + if symlink { + if _, err := os.Lstat(dest); err == nil { + // A conflicting entry already exists at dest - this happens on + // case-insensitive filesystems where dest and src are the same + // path, so treat it as already done rather than failing. + return nil + } + rel, err := filepath.Rel(destDir, src) + if err != nil { + rel = src + } + return os.Symlink(rel, dest) + } + return os.Rename(src, dest) +} + +// combineIntoDir moves (or symlinks) every entry of src into the already-existing +// dest, recursing into same-named subdirectories, then removes src (unless +// symlink mode, where src's real content must be left in place). +func combineIntoDir(src, dest string, symlink bool) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, e := range entries { + name := e.Name() + srcChild := filepath.Join(src, name) + destChild := filepath.Join(dest, name) + if fi, err := os.Stat(destChild); err == nil && fi.IsDir() && e.IsDir() { + if err := combineIntoDir(srcChild, destChild, symlink); err != nil { + return err + } + continue + } + if err := renameOrSymlink(srcChild, dest, name, symlink); err != nil { + return err + } + } + if !symlink { + return os.Remove(src) + } + return nil +} diff --git a/internal/install/lowercase_test.go b/internal/install/lowercase_test.go new file mode 100644 index 0000000..e1eab5f --- /dev/null +++ b/internal/install/lowercase_test.go @@ -0,0 +1,168 @@ +package install + +import ( + "os" + "path/filepath" + "testing" +) + +func mustMkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } +} + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + mustMkdirAll(t, filepath.Dir(path)) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestLowercaseRenameMode(t *testing.T) { + root := t.TempDir() + // The top-level dir passed to Lowercase is itself lowercased too when + // not already lowercase (matching the original perl script's dodir, + // which lowercases relpath=="" using the dir's own basename). + tree := filepath.Join(root, "Include") + mustWriteFile(t, filepath.Join(tree, "Foo", "Bar.H"), "content") + + if err := Lowercase(tree, LowercaseOptions{}); err != nil { + t.Fatal(err) + } + + lowerTree := filepath.Join(root, "include") + if !isFile(filepath.Join(lowerTree, "foo", "bar.h")) { + t.Fatalf("expected lowercased path to exist under %s", lowerTree) + } + if isDir(tree) { + t.Fatalf("original-cased top dir should have been renamed away") + } +} + +func TestLowercaseSymlinkMode(t *testing.T) { + root := t.TempDir() + tree := filepath.Join(root, "include") + mustWriteFile(t, filepath.Join(tree, "Foo", "Bar.h"), "content") + + if err := Lowercase(tree, LowercaseOptions{Symlink: true}); err != nil { + t.Fatal(err) + } + + // Original casing must still be reachable (symlink mode never deletes). + if !isFile(filepath.Join(tree, "Foo", "Bar.h")) { + t.Fatalf("original-cased file should still exist in symlink mode") + } + // Lowercase alias must resolve to the same content. + data, err := os.ReadFile(filepath.Join(tree, "foo", "bar.h")) + if err != nil { + t.Fatalf("expected lowercase alias to resolve: %v", err) + } + if string(data) != "content" { + t.Errorf("got %q", data) + } +} + +func TestLowercaseMergeOnCollision(t *testing.T) { + root := t.TempDir() + tree := filepath.Join(root, "include") + // Both "GL" and "gl" exist as siblings; lowercasing "GL" must merge its + // contents into the already-lowercase "gl" rather than clobbering it. + mustWriteFile(t, filepath.Join(tree, "gl", "existing.h"), "existing") + mustWriteFile(t, filepath.Join(tree, "GL", "New.h"), "new") + + if err := Lowercase(tree, LowercaseOptions{}); err != nil { + t.Fatal(err) + } + + if !isFile(filepath.Join(tree, "gl", "existing.h")) { + t.Errorf("pre-existing lowercase file lost during merge") + } + if !isFile(filepath.Join(tree, "gl", "new.h")) { + t.Errorf("merged file not found at lowercase destination") + } + if isDir(filepath.Join(tree, "GL")) { + t.Errorf("source dir should have been removed after merge (non-symlink mode)") + } +} + +func TestLowercaseMapWinSDKPreservesGL(t *testing.T) { + root := t.TempDir() + tree := filepath.Join(root, "um") + mustWriteFile(t, filepath.Join(tree, "GL", "gl.h"), "content") + + if err := Lowercase(tree, LowercaseOptions{Symlink: true, MapWinSDK: true}); err != nil { + t.Fatal(err) + } + + if !isDir(filepath.Join(tree, "GL")) { + t.Errorf("GL directory casing should be preserved under -map_winsdk") + } + if isDir(filepath.Join(tree, "gl")) { + t.Errorf("no lowercase alias should be created for GL under -map_winsdk (name maps back to itself)") + } +} + +func TestFixIncludeLowercasesAndConvertsSlashes(t *testing.T) { + root := t.TempDir() + header := filepath.Join(root, "foo.h") + mustWriteFile(t, header, "#include \r\n#include \"Other.h\" // comment\r\nplain line\r\n") + + if err := FixInclude(root, FixIncludeOptions{}); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(header) + if err != nil { + t.Fatal(err) + } + want := "#include \n#include \"other.h\" // comment\nplain line\n" + if string(data) != want { + t.Errorf("got %q want %q", data, want) + } +} + +func TestFixIncludeMapWinSDKPreservesGL(t *testing.T) { + root := t.TempDir() + header := filepath.Join(root, "foo.h") + mustWriteFile(t, header, "#include \n") + + if err := FixInclude(root, FixIncludeOptions{MapWinSDK: true}); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(header) + if err != nil { + t.Fatal(err) + } + want := "#include \n" + if string(data) != want { + t.Errorf("got %q want %q", data, want) + } +} + +func TestFixIncludeSkipsSymlinks(t *testing.T) { + root := t.TempDir() + real := filepath.Join(root, "Real.h") + mustWriteFile(t, real, "#include \n") + link := filepath.Join(root, "alias.h") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + + if err := FixInclude(root, FixIncludeOptions{}); err != nil { + t.Fatal(err) + } + + // The symlink itself must be untouched (still a symlink to the same + // target); only Real.h's contents get rewritten. + target, err := os.Readlink(link) + if err != nil { + t.Fatalf("alias.h should still be a symlink: %v", err) + } + if target != real { + t.Errorf("symlink target changed: %q", target) + } +} diff --git a/internal/install/toolrelay_test.go b/internal/install/toolrelay_test.go new file mode 100644 index 0000000..3b4aafb --- /dev/null +++ b/internal/install/toolrelay_test.go @@ -0,0 +1,54 @@ +package install + +import ( + "os" + "path/filepath" + "testing" +) + +// TestBuildToolRelay exercises the file shuffling around the actual +// compiler invocation (write embedded source, run "cl", move the resulting +// .exe into bin/, clean up the .obj and temp source) using a fake `cl` +// shell script standing in for the real Wine-hosted compiler - this can +// run fully offline, without wine or a real MSVC install. +func TestBuildToolRelay(t *testing.T) { + dest := t.TempDir() + destBin := filepath.Join(dest, "bin") + hostDir := filepath.Join(destBin, "x64") + if err := os.MkdirAll(hostDir, 0o755); err != nil { + t.Fatal(err) + } + + // Fake `cl`: just drop toolrelay.exe/.obj next to the source it was + // given, like the real cl.exe would when invoked without /Fe or /Fo. + fakeCl := filepath.Join(hostDir, "cl") + script := "#!/bin/sh\ntouch toolrelay.exe toolrelay.obj\n" + if err := os.WriteFile(fakeCl, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + if err := buildToolRelay(dest, destBin, "x64"); err != nil { + t.Fatalf("buildToolRelay: %v", err) + } + + if !isFile(filepath.Join(destBin, "toolrelay.exe")) { + t.Errorf("expected %s/toolrelay.exe to exist", destBin) + } + if exists(filepath.Join(dest, "toolrelay.obj")) { + t.Errorf("toolrelay.obj should have been removed from %s", dest) + } + if exists(filepath.Join(dest, "toolrelay.cpp")) { + t.Errorf("temp toolrelay.cpp source should have been removed from %s", dest) + } +} + +func TestBuildToolRelayNoClWrapper(t *testing.T) { + dest := t.TempDir() + destBin := filepath.Join(dest, "bin") + if err := os.MkdirAll(destBin, 0o755); err != nil { + t.Fatal(err) + } + if err := buildToolRelay(dest, destBin, "x64"); err == nil { + t.Fatal("expected an error when no host-arch cl wrapper exists") + } +} diff --git a/internal/wineenv/config.go b/internal/wineenv/config.go new file mode 100644 index 0000000..3f79b7b --- /dev/null +++ b/internal/wineenv/config.go @@ -0,0 +1,46 @@ +// Package wineenv computes the environment (INCLUDE/LIB/WINEPATH/...) needed +// to run a Wine-hosted MSVC tool. +package wineenv + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// ConfigFileName is the per-architecture config dropped next to the tool +// symlinks by `msvc-go-wine install`. +const ConfigFileName = "env.json" + +// Config is the per-architecture info generated at install time. +type Config struct { + Arch string `json:"arch"` // x86, x64, arm, arm64 (target arch) + Host string `json:"host"` // x64 or arm64 (Host bin dir suffix) + DotnetHost string `json:"dotnet_host"` // amd64 or arm64 (.NET tool host dir suffix) + MSVCVer string `json:"msvc_ver"` + SDKVer string `json:"sdk_ver"` +} + +// Load reads env.json from dir (the directory the running binary was +// invoked from, i.e. /bin/). +func Load(dir string) (*Config, error) { + data, err := os.ReadFile(filepath.Join(dir, ConfigFileName)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", ConfigFileName, err) + } + var c Config + if err := json.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("parsing %s: %w", ConfigFileName, err) + } + return &c, nil +} + +// Save writes env.json into dir. +func (c *Config) Save(dir string) error { + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, ConfigFileName), data, 0o644) +} diff --git a/internal/wineenv/paths.go b/internal/wineenv/paths.go new file mode 100644 index 0000000..5912278 --- /dev/null +++ b/internal/wineenv/paths.go @@ -0,0 +1,108 @@ +package wineenv + +import ( + "os" + "path/filepath" + "strings" +) + +// Paths holds every path/env-var derived from a Config plus the on-disk +// installation root - INCLUDE, LIB, WINEPATH and friends, in the Windows +// notation Wine expects. +type Paths struct { + BaseUnix string // , absolute unix path + MSVCDirUnix string // /vc/tools/msvc/ + BinDir string // /vc/tools/msvc//bin/Host/ - cl/link/lib/ml/nmake/armasm live here + SDKBinDir string // /kits/10/bin// - mc/midl/mt/rc live here + MSBuildBinDir string // /MSBuild/Current/Bin/ - MSBuild.exe lives here + + Include string + Lib string + LibPath string + WinePath string + WineDLLOverrides string +} + +// FindBaseUnix locates the installation root starting from the directory the +// tool wrapper was invoked from (scriptDir), searching one or two levels up +// for a "vc" entry - matching the original wrappers' `dirname "$0"`/".." walk, +// which tolerates wrappers living either directly in /bin/ or +// nested one level deeper. +func FindBaseUnix(scriptDir string) (string, error) { + base, err := filepath.Abs(filepath.Join(scriptDir, "..")) + if err != nil { + return "", err + } + if _, err := os.Stat(filepath.Join(base, "vc")); err != nil { + base = filepath.Join(base, "..") + } + return filepath.Abs(base) +} + +// NewPaths computes all derived paths/env-vars for cfg installed at baseUnix. +func NewPaths(cfg *Config, baseUnix string) *Paths { + winBase := "z:" + toWindowsSlashes(baseUnix) + msvcBase := winBase + `\vc` + sdkBase := winBase + `\kits\10` + msvcDirWin := msvcBase + `\tools\msvc\` + cfg.MSVCVer + sdkIncludeWin := sdkBase + `\include\` + cfg.SDKVer + sdkLibWin := sdkBase + `\lib\` + cfg.SDKVer + + msvcDirUnix := filepath.Join(baseUnix, "vc", "tools", "msvc", cfg.MSVCVer) + binDir := filepath.Join(msvcDirUnix, "bin", "Host"+cfg.Host, cfg.Arch) + sdkBinDir := filepath.Join(baseUnix, "kits", "10", "bin", cfg.SDKVer, cfg.Host) + msbuildBinDir := filepath.Join(baseUnix, "MSBuild", "Current", "Bin", cfg.DotnetHost) + + include := strings.Join([]string{ + msvcDirWin + `\atlmfc\include`, + msvcDirWin + `\include`, + sdkIncludeWin + `\shared`, + sdkIncludeWin + `\ucrt`, + sdkIncludeWin + `\um`, + sdkIncludeWin + `\winrt`, + sdkIncludeWin + `\km`, + }, ";") + + lib := strings.Join([]string{ + msvcDirWin + `\atlmfc\lib\` + cfg.Arch, + msvcDirWin + `\lib\` + cfg.Arch, + sdkLibWin + `\ucrt\` + cfg.Arch, + sdkLibWin + `\um\` + cfg.Arch, + sdkLibWin + `\km\` + cfg.Arch, + }, ";") + + // WINEPATH: unix dirs with slashes swapped for backslashes (no drive + // letter - Wine accepts this for locating DLLs), plus the MSVC host bin + // dir in full windows notation, deliberately always the *host* (not + // target) x64/arm64 tool dir for the third entry - that's where DLLs + // like mspdbcore.dll actually live. + winePath := strings.Join([]string{ + toWindowsSlashes(binDir), + toWindowsSlashes(sdkBinDir), + msvcDirWin + `\bin\Host` + cfg.Host + `\` + cfg.Host, + }, ";") + + return &Paths{ + BaseUnix: baseUnix, + MSVCDirUnix: msvcDirUnix, + BinDir: binDir, + SDKBinDir: sdkBinDir, + MSBuildBinDir: msbuildBinDir, + + Include: include, + Lib: lib, + LibPath: lib, + WinePath: winePath, + WineDLLOverrides: "vcruntime140=n;vcruntime140_1=n", + } +} + +func toWindowsSlashes(p string) string { + return strings.ReplaceAll(p, "/", `\`) +} + +// ToWinPath prefixes a unix absolute path with the "z:" drive Wine maps the +// unix root to, converting slashes to backslashes. +func ToWinPath(unixPath string) string { + return "z:" + toWindowsSlashes(unixPath) +} diff --git a/internal/wineenv/paths_test.go b/internal/wineenv/paths_test.go new file mode 100644 index 0000000..a1b777b --- /dev/null +++ b/internal/wineenv/paths_test.go @@ -0,0 +1,56 @@ +package wineenv + +import "testing" + +// Values lifted from the original wrappers/cl template +// (MSVCVER=14.13.26128, SDKVER=10.0.16299.0, ARCH=x86) to cross-check the Go +// port produces byte-identical INCLUDE/LIB strings. +func TestNewPathsMatchesOriginalTemplate(t *testing.T) { + cfg := &Config{ + Arch: "x86", + Host: "x64", + DotnetHost: "amd64", + MSVCVer: "14.13.26128", + SDKVer: "10.0.16299.0", + } + p := NewPaths(cfg, "/opt/msvc") + + wantInclude := `z:\opt\msvc\vc\tools\msvc\14.13.26128\atlmfc\include;` + + `z:\opt\msvc\vc\tools\msvc\14.13.26128\include;` + + `z:\opt\msvc\kits\10\include\10.0.16299.0\shared;` + + `z:\opt\msvc\kits\10\include\10.0.16299.0\ucrt;` + + `z:\opt\msvc\kits\10\include\10.0.16299.0\um;` + + `z:\opt\msvc\kits\10\include\10.0.16299.0\winrt;` + + `z:\opt\msvc\kits\10\include\10.0.16299.0\km` + if p.Include != wantInclude { + t.Errorf("INCLUDE mismatch:\n got: %s\nwant: %s", p.Include, wantInclude) + } + + wantLib := `z:\opt\msvc\vc\tools\msvc\14.13.26128\atlmfc\lib\x86;` + + `z:\opt\msvc\vc\tools\msvc\14.13.26128\lib\x86;` + + `z:\opt\msvc\kits\10\lib\10.0.16299.0\ucrt\x86;` + + `z:\opt\msvc\kits\10\lib\10.0.16299.0\um\x86;` + + `z:\opt\msvc\kits\10\lib\10.0.16299.0\km\x86` + if p.Lib != wantLib { + t.Errorf("LIB mismatch:\n got: %s\nwant: %s", p.Lib, wantLib) + } + + if p.WineDLLOverrides != "vcruntime140=n;vcruntime140_1=n" { + t.Errorf("WINEDLLOVERRIDES mismatch: %s", p.WineDLLOverrides) + } + + wantBinDir := "/opt/msvc/vc/tools/msvc/14.13.26128/bin/Hostx64/x86" + if p.BinDir != wantBinDir { + t.Errorf("BinDir mismatch:\n got: %s\nwant: %s", p.BinDir, wantBinDir) + } + + wantSDKBinDir := "/opt/msvc/kits/10/bin/10.0.16299.0/x64" + if p.SDKBinDir != wantSDKBinDir { + t.Errorf("SDKBinDir mismatch:\n got: %s\nwant: %s", p.SDKBinDir, wantSDKBinDir) + } + + wantMSBuildBinDir := "/opt/msvc/MSBuild/Current/Bin/amd64" + if p.MSBuildBinDir != wantMSBuildBinDir { + t.Errorf("MSBuildBinDir mismatch:\n got: %s\nwant: %s", p.MSBuildBinDir, wantMSBuildBinDir) + } +} diff --git a/internal/wineenv/wine.go b/internal/wineenv/wine.go new file mode 100644 index 0000000..9269f44 --- /dev/null +++ b/internal/wineenv/wine.go @@ -0,0 +1,18 @@ +package wineenv + +import ( + "fmt" + "os/exec" +) + +// FindWine locates the wine binary to use, preferring wine64 like the +// original wrappers (`command -v wine64 || command -v wine`). +func FindWine() (string, error) { + if p, err := exec.LookPath("wine64"); err == nil { + return p, nil + } + if p, err := exec.LookPath("wine"); err == nil { + return p, nil + } + return "", fmt.Errorf("neither wine64 nor wine found in PATH") +} diff --git a/internal/wrapper/clpost.go b/internal/wrapper/clpost.go new file mode 100644 index 0000000..5767078 --- /dev/null +++ b/internal/wrapper/clpost.go @@ -0,0 +1,39 @@ +package wrapper + +import ( + "os" + "strings" +) + +// clPostProcess mirrors cl's post-run fixup for `/P /Fi` (preprocess- +// to-file): the generated file still has CRLF endings and z:-prefixed +// #line directives, so run it through the same line-directive rewrite used +// for stdout. +func clPostProcess(origArgs []string) { + var hasP bool + var fiFile string + for _, a := range origArgs { + switch { + case a == "-P" || a == "/P": + hasP = true + case strings.HasPrefix(a, "-Fi") || strings.HasPrefix(a, "/Fi"): + fiFile = a[3:] + } + } + if !hasP || fiFile == "" { + return + } + data, err := os.ReadFile(fiFile) + if err != nil { + return + } + lines := strings.Split(string(data), "\n") + for i, line := range lines { + line = stripCR(line) + if reLineDirective.MatchString(line) { + line = strings.ReplaceAll(stripFirstZDrive(line), `\\`, `/`) + } + lines[i] = line + } + _ = os.WriteFile(fiFile, []byte(strings.Join(lines, "\n")), 0o644) +} diff --git a/internal/wrapper/filters.go b/internal/wrapper/filters.go new file mode 100644 index 0000000..a79634d --- /dev/null +++ b/internal/wrapper/filters.go @@ -0,0 +1,72 @@ +package wrapper + +import ( + "regexp" + "strings" +) + +// lineFilter rewrites a single line of tool output. nil means "no extra +// rewriting" (CR-stripping still always happens, see stripCR). +type lineFilter func(line string) string + +// reZDrive matches the wine "z:" drive prefix followed by a path separator, +// case-insensitively, anywhere in the line - mirroring sed's `s/z:([\\/])/\1/i` +// (no /g flag: only the first occurrence is touched). +var reZDrive = regexp.MustCompile(`(?i)z:([\\/])`) + +// stripFirstZDrive removes the first "z:" preceding a path separator, +// keeping the separator itself, matching the original's un-global sed rule. +func stripFirstZDrive(line string) string { + loc := reZDrive.FindStringSubmatchIndex(line) + if loc == nil { + return line + } + sep := line[loc[2]:loc[3]] + return line[:loc[0]] + sep + line[loc[1]:] +} + +var ( + reNoteIncluding = regexp.MustCompile(`^Note: including file: `) + reLineDirective = regexp.MustCompile(`^[ \t]*#[ \t]*line[ \t]`) + reNoteErrorWarning = regexp.MustCompile(`(?i)^z:.*\([0-9]+\): (note|error c[0-9]{4}|warning c[0-9]{4}): `) + reDumpbinPath = regexp.MustCompile(`^(Dump of file | PDB file found at )`) +) + +// clStdoutFilter rewrites cl's "Note: including file:", "#line", and +// note/warning/error diagnostic lines from wine's z:\... notation back to +// plain unix paths. +func clStdoutFilter(line string) string { + switch { + case reNoteIncluding.MatchString(line): + return strings.ReplaceAll(stripFirstZDrive(line), `\`, `/`) + case reLineDirective.MatchString(line): + return strings.ReplaceAll(stripFirstZDrive(line), `\\`, `/`) + case reNoteErrorWarning.MatchString(line): + return strings.ReplaceAll(stripFirstZDrive(line), `\`, `/`) + default: + return line + } +} + +// clStderrFilter only rewrites "Note: including file:" lines - cl's stderr +// diagnostics don't carry the z:\... prefix the stdout ones do. +func clStderrFilter(line string) string { + if reNoteIncluding.MatchString(line) { + return strings.ReplaceAll(stripFirstZDrive(line), `\`, `/`) + } + return line +} + +// dumpbinStdoutFilter mirrors dumpbin's unixify_path variant. +func dumpbinStdoutFilter(line string) string { + if reDumpbinPath.MatchString(line) { + return strings.ReplaceAll(stripFirstZDrive(line), `\`, `/`) + } + return line +} + +// stripCR mirrors the base `s/\r//` rule every wrapper prepends: removes the +// first carriage return in the line (CRLF line endings only ever carry one). +func stripCR(line string) string { + return strings.Replace(line, "\r", "", 1) +} diff --git a/internal/wrapper/filters_test.go b/internal/wrapper/filters_test.go new file mode 100644 index 0000000..4d998eb --- /dev/null +++ b/internal/wrapper/filters_test.go @@ -0,0 +1,78 @@ +package wrapper + +import "testing" + +func TestClStdoutFilterNoteIncluding(t *testing.T) { + in := `Note: including file: z:\home\user\project\test.h` + want := `Note: including file: /home/user/project/test.h` + if got := clStdoutFilter(in); got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestClStdoutFilterLineDirective(t *testing.T) { + in := `#line 5 "z:\\home\\user\\project\\test.c"` + want := `#line 5 "/home/user/project/test.c"` + if got := clStdoutFilter(in); got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestClStdoutFilterErrorLine(t *testing.T) { + in := `z:\home\user\project\test.c(5): warning C4996: 'foo' was declared deprecated` + want := `/home/user/project/test.c(5): warning C4996: 'foo' was declared deprecated` + if got := clStdoutFilter(in); got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestClStdoutFilterNoteLine(t *testing.T) { + in := `z:\home\user\project\test.h(3): note: see declaration of 'foo'` + want := `/home/user/project/test.h(3): note: see declaration of 'foo'` + if got := clStdoutFilter(in); got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestClStdoutFilterPassthrough(t *testing.T) { + in := "test.c" + if got := clStdoutFilter(in); got != in { + t.Errorf("got %q want %q", got, in) + } +} + +func TestClStderrFilter(t *testing.T) { + in := `Note: including file: z:\a\b.h` + want := `Note: including file: /a/b.h` + if got := clStderrFilter(in); got != want { + t.Errorf("got %q want %q", got, want) + } + // stderr does NOT get the error/warning/line-directive rewrite. + in2 := `z:\a\b.c(1): error C2065: undeclared identifier` + if got := clStderrFilter(in2); got != in2 { + t.Errorf("stderr should pass through error lines unchanged, got %q", got) + } +} + +func TestDumpbinStdoutFilter(t *testing.T) { + in := ` PDB file found at z:\a\b\file.pdb` + want := ` PDB file found at /a/b/file.pdb` + if got := dumpbinStdoutFilter(in); got != want { + t.Errorf("got %q want %q", got, want) + } + + in2 := `Dump of file z:\a\b\file.exe` + want2 := `Dump of file /a/b/file.exe` + if got := dumpbinStdoutFilter(in2); got != want2 { + t.Errorf("got %q want %q", got, want2) + } +} + +func TestStripCR(t *testing.T) { + if got := stripCR("hello\r"); got != "hello" { + t.Errorf("got %q", got) + } + if got := stripCR("no cr here"); got != "no cr here" { + t.Errorf("got %q", got) + } +} diff --git a/internal/wrapper/native.go b/internal/wrapper/native.go new file mode 100644 index 0000000..26aaf62 --- /dev/null +++ b/internal/wrapper/native.go @@ -0,0 +1,39 @@ +package wrapper + +import ( + "os" + "os/exec" +) + +// runNative handles the two tool names that need no Wine/MSVC install at +// all: `cmd` (strips a leading "//c" and execs the rest) and `findstr` +// (delegates to grep). +func runNative(tool string, args []string) int { + switch tool { + case "cmd": + if len(args) > 0 && args[0] == "//c" { + args = args[1:] + } + return execInherit(args) + case "findstr": + return execInherit(append([]string{"grep"}, args...)) + } + return 127 +} + +func execInherit(args []string) int { + if len(args) == 0 { + return 0 + } + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + return 127 + } + return 0 +} diff --git a/internal/wrapper/rewrite.go b/internal/wrapper/rewrite.go new file mode 100644 index 0000000..c85ef19 --- /dev/null +++ b/internal/wrapper/rewrite.go @@ -0,0 +1,59 @@ +package wrapper + +import ( + "os" + "path/filepath" + "regexp" +) + +// Argument path rewriting: MSVC/Wine sometimes fails to resolve relative- +// looking includes passed as plain unix absolute paths (see +// https://bugs.winehq.org/show_bug.cgi?id=55200); the fix is to rewrite +// `-I/abs/path` into `-Iz:/abs/path` and similar, trying each option-prefix +// shape in priority order until one matches. +var ( + reOpt1 = regexp.MustCompile(`^[-/][A-Za-z](/.*)$`) // -I/path, /I/path + reOpt2 = regexp.MustCompile(`^[-/][A-Za-z][A-Za-z](/.*)$`) // -Fo/path + reOpt3 = regexp.MustCompile(`^[-/][A-Za-z][A-Za-z][A-Za-z]*:(/.*)$`) // -MANIFESTINPUT:/path + reBare = regexp.MustCompile(`^(/.*)$`) // /abs/path alone +) + +// RewriteArgs rewrites absolute unix paths embedded in tool arguments into +// "z:/abs/path" form, only when the path's parent directory actually exists +// on disk and isn't "/" - matching the original bash's `[ -d "$(dirname ..)" ]` +// guard, which keeps short flags like "/P" or "/D" untouched. +func RewriteArgs(args []string) []string { + out := make([]string, len(args)) + for i, a := range args { + out[i] = rewriteArg(a) + } + return out +} + +func rewriteArg(a string) string { + var path string + switch { + case reOpt1.MatchString(a): + path = reOpt1.FindStringSubmatch(a)[1] + case reOpt2.MatchString(a): + path = reOpt2.FindStringSubmatch(a)[1] + case reOpt3.MatchString(a): + path = reOpt3.FindStringSubmatch(a)[1] + case reBare.MatchString(a): + path = reBare.FindStringSubmatch(a)[1] + default: + return a + } + + dir := filepath.Dir(path) + if dir == "/" { + return a + } + fi, err := os.Stat(dir) + if err != nil || !fi.IsDir() { + return a + } + + prefix := a[:len(a)-len(path)] + return prefix + "z:" + path +} diff --git a/internal/wrapper/rewrite_test.go b/internal/wrapper/rewrite_test.go new file mode 100644 index 0000000..1f45e35 --- /dev/null +++ b/internal/wrapper/rewrite_test.go @@ -0,0 +1,49 @@ +package wrapper + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRewriteArg(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "inc") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + file := filepath.Join(sub, "foo.h") + if err := os.WriteFile(file, nil, 0o644); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + in string + want string + }{ + {"single-letter option + abs dir", "-I" + sub, "-Iz:" + sub}, + {"two-letter option + abs file", "-Fo" + file, "-Foz:" + file}, + {"long colon option + abs file", "-MANIFESTINPUT:" + file, "-MANIFESTINPUT:z:" + file}, + {"bare absolute path", file, "z:" + file}, + {"plain flag untouched", "-nologo", "-nologo"}, + {"nonexistent dir untouched", "-I/does/not/exist/at/all", "-I/does/not/exist/at/all"}, + {"root-level bare path untouched", "/nologo", "/nologo"}, + {"relative path untouched", "test.c", "test.c"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := rewriteArg(tc.in); got != tc.want { + t.Errorf("rewriteArg(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestRewriteArgsPreservesOrderAndLength(t *testing.T) { + in := []string{"/nologo", "-c", "test.c"} + out := RewriteArgs(in) + if len(out) != len(in) { + t.Fatalf("length changed: %v -> %v", in, out) + } +} diff --git a/internal/wrapper/run.go b/internal/wrapper/run.go new file mode 100644 index 0000000..e0ef47f --- /dev/null +++ b/internal/wrapper/run.go @@ -0,0 +1,255 @@ +package wrapper + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + + "github.com/Cheviiot/msvc-go-wine/internal/wineenv" +) + +// toolRelayName is where `msvc-go-wine install` places the compiled +// toolrelay.exe helper, shared across all arch bin dirs. +const toolRelayName = "toolrelay.exe" + +// Run executes the named multi-call tool with args, exactly as the original +// bash wrappers would, and returns the process exit code. +func Run(tool string, args []string) int { + if nativeTools[tool] { + return runNative(tool, args) + } + + s, ok := Tools[tool] + if !ok { + fmt.Fprintf(os.Stderr, "msvc-go-wine: unknown tool %q\n", tool) + return 127 + } + + // os.Executable() (backed by /proc/self/exe on Linux) fully resolves + // symlinks, unlike os.Args[0]: not every shell passes a PATH-resolved + // absolute path as argv[0] (some just pass the bare command name), which + // would make an argv[0]-based lookup resolve against the caller's cwd + // instead of the actual install dir. `install` sets each arch dir up + // with its own local copy of the binary precisely so this resolves to + // /bin/, not /bin. + exePath, err := os.Executable() + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) + return 1 + } + scriptDir := filepath.Dir(exePath) + + cfg, err := wineenv.Load(scriptDir) + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine: loading install config:", err) + return 1 + } + baseUnix, err := wineenv.FindBaseUnix(scriptDir) + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine: locating installation root:", err) + return 1 + } + paths := wineenv.NewPaths(cfg, baseUnix) + + toolExePath := filepath.Join(s.exeDir(paths), s.exeName) + + wineBin, err := wineenv.FindWine() + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) + return 1 + } + + rewritten := RewriteArgs(args) + + var exitCode int + switch { + case s.rawStdout: + // MSBuild: skip all filtering/toolrelay, inherit stdio directly. + cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...) + cmd.Env = buildEnv(paths) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + exitCode = runAndWait(cmd) + default: + relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName) + if fi, err := os.Stat(relay); err == nil && !fi.IsDir() { + exitCode = runViaToolRelay(wineBin, relay, toolExePath, rewritten, paths, s.stdoutFilter, s.stderrFilter) + } else { + cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...) + cmd.Env = buildEnv(paths) + cmd.Stdin = os.Stdin + exitCode = runFiltered(cmd, s.stdoutFilter, s.stderrFilter) + } + } + + if s.postProcess != nil { + s.postProcess(args) + } + + return exitCode +} + +// runViaToolRelay runs exePath through the compiled toolrelay.exe helper: +// toolrelay.exe spawns the real tool natively under Windows, redirecting +// its stdio to two named FIFOs we create and read from here. This is what +// lets `mt.exe`'s CMake-compatibility exit code translation (0x41020001 -> +// 0xbb) survive Wine's own exit-code truncation, since toolrelay.exe +// observes the real 32-bit exit code via Win32 before translating and +// re-exiting with a value that fits in a byte. +func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wineenv.Paths, stdoutF, stderrF lineFilter) int { + stdoutFifo := filepath.Join(os.TempDir(), fmt.Sprintf("msvc-go-wine.stdout.%d", os.Getpid())) + stderrFifo := filepath.Join(os.TempDir(), fmt.Sprintf("msvc-go-wine.stderr.%d", os.Getpid())) + os.Remove(stdoutFifo) + os.Remove(stderrFifo) + if err := syscall.Mkfifo(stdoutFifo, 0o600); err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) + return 1 + } + defer os.Remove(stdoutFifo) + if err := syscall.Mkfifo(stderrFifo, 0o600); err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) + return 1 + } + defer os.Remove(stderrFifo) + + cmdArgs := append([]string{relayExe, exePath}, args...) + cmd := exec.Command(wineBin, cmdArgs...) + cmd.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo) + if devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil { + defer devNull.Close() + cmd.Stdout = devNull + cmd.Stderr = devNull + } + + if err := cmd.Start(); err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) + return 1 + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + f, err := os.Open(stdoutFifo) // blocks until toolrelay.exe opens its end + if err != nil { + return + } + defer f.Close() + pumpLines(f, os.Stdout, stdoutF) + }() + go func() { + defer wg.Done() + f, err := os.Open(stderrFifo) + if err != nil { + return + } + defer f.Close() + pumpLines(f, os.Stderr, stderrF) + }() + + err := cmd.Wait() + wg.Wait() + + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) + return 1 + } + return 0 +} + +func buildEnv(p *wineenv.Paths) []string { + overrides := map[string]string{ + "INCLUDE": p.Include, + "LIB": p.Lib, + "LIBPATH": p.LibPath, + "WINEPATH": p.WinePath, + "WINEDLLOVERRIDES": p.WineDLLOverrides, + } + base := os.Environ() + if _, set := os.LookupEnv("WINEDEBUG"); !set { + overrides["WINEDEBUG"] = "-all" + } + + out := make([]string, 0, len(base)+len(overrides)) + for _, kv := range base { + key := kv[:strings.IndexByte(kv, '=')] + if _, skip := overrides[key]; skip { + continue + } + out = append(out, kv) + } + for k, v := range overrides { + out = append(out, k+"="+v) + } + return out +} + +// runFiltered streams stdout/stderr line by line through the tool's +// filters (CR-stripping always applied first), then waits for completion. +func runFiltered(cmd *exec.Cmd, stdoutF, stderrF lineFilter) int { + stdout, err := cmd.StdoutPipe() + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) + return 1 + } + stderr, err := cmd.StderrPipe() + if err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) + return 1 + } + + if err := cmd.Start(); err != nil { + fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) + return 1 + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); pumpLines(stdout, os.Stdout, stdoutF) }() + go func() { defer wg.Done(); pumpLines(stderr, os.Stderr, stderrF) }() + wg.Wait() + + if err := cmd.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) + return 1 + } + return 0 +} + +// pumpLines reads r line by line, CR-stripping and applying filter (if +// non-nil) before writing each line to w. +func pumpLines(r io.Reader, w *os.File, filter lineFilter) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for scanner.Scan() { + line := stripCR(scanner.Text()) + if filter != nil { + line = filter(line) + } + fmt.Fprintln(w, line) + } +} + +func runAndWait(cmd *exec.Cmd) int { + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) + return 1 + } + return 0 +} diff --git a/internal/wrapper/tools.go b/internal/wrapper/tools.go new file mode 100644 index 0000000..8f2adaf --- /dev/null +++ b/internal/wrapper/tools.go @@ -0,0 +1,58 @@ +// Package wrapper implements the per-tool command dispatch (cl, link, lib, +// ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, +// findstr): loading each tool's environment, invoking it under Wine, and +// filtering its output. +package wrapper + +import "github.com/Cheviiot/msvc-go-wine/internal/wineenv" + +// dirKind selects which install directory a tool's real .exe lives in. +type dirKind int + +const ( + dirBin dirKind = iota // - vc/tools/msvc//bin/Host/ + dirSDK // - kits/10/bin// + dirMSBuild +) + +type spec struct { + exeName string + dir dirKind + rawStdout bool // MSBuild: skip line filtering, inherit stdio directly + stdoutFilter lineFilter + stderrFilter lineFilter + postProcess func(origArgs []string) // cl: fix up /P /Fi output +} + +// Tools lists every multi-call name this binary answers to, besides its own +// management-CLI name. +var Tools = map[string]spec{ + "cl": {exeName: "cl.exe", dir: dirBin, stdoutFilter: clStdoutFilter, stderrFilter: clStderrFilter, postProcess: clPostProcess}, + "link": {exeName: "link.exe", dir: dirBin}, + "lib": {exeName: "lib.exe", dir: dirBin}, + "ml": {exeName: "ml.exe", dir: dirBin}, + "ml64": {exeName: "ml64.exe", dir: dirBin}, + "nmake": {exeName: "nmake.exe", dir: dirBin}, + "armasm": {exeName: "armasm.exe", dir: dirBin}, + "armasm64": {exeName: "armasm64.exe", dir: dirBin}, + "dumpbin": {exeName: "dumpbin.exe", dir: dirBin, stdoutFilter: dumpbinStdoutFilter}, + "mc": {exeName: "mc.exe", dir: dirSDK}, + "midl": {exeName: "midl.exe", dir: dirSDK}, + "mt": {exeName: "mt.exe", dir: dirSDK}, + "rc": {exeName: "rc.exe", dir: dirSDK}, + "msbuild": {exeName: "MSBuild.exe", dir: dirMSBuild, rawStdout: true}, +} + +// nativeTools are handled entirely without Wine. +var nativeTools = map[string]bool{"cmd": true, "findstr": true} + +func (s spec) exeDir(p *wineenv.Paths) string { + switch s.dir { + case dirSDK: + return p.SDKBinDir + case dirMSBuild: + return p.MSBuildBinDir + default: + return p.BinDir + } +}