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
+ }
+}