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.
This commit is contained in:
Cheviiot
2026-07-25 00:57:41 +10:00
commit 6464da7847
41 changed files with 4207 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/msvc-go-wine
*.test
.claude/
+25
View File
@@ -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.
+126
View File
@@ -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 <dir> [options] fetch and unpack MSVC/WinSDK
msvc-go-wine install <dir> wire up wrappers for a downloaded MSVC
msvc-go-wine env --bin <dir>/bin/<arch> 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.
+13
View File
@@ -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
@@ -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
@@ -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...
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,24 @@
<!--
msvc-go-wine: make MSBuild resolve the Windows SDK / UCRT / preferred tool
architecture from the VS install layout directly, since there's no
Windows Registry under Wine for the stock .props files to query.
Populates MsvcGoWine_ExtraSdkRoots, which
Microsoft.Cpp.WindowsSDK.props.patch (see the assets/patches directory)
passes into GetLatestSDKTargetPlatformVersion as an extra search root.
-->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition="'$(WindowsSdkDir_10)' == '' and (
'$(WindowsTargetPlatformVersion)' == '' or
'$(WindowsTargetPlatformVersion)' == '10.0' or
Exists('$(VSInstallRoot)\Windows Kits\10\Include\$(WindowsTargetPlatformVersion)\shared\sdkddkver.h'))">
<MsvcGoWine_ExtraSdkRoots>$(VSInstallRoot)</MsvcGoWine_ExtraSdkRoots>
<WindowsSdkDir_10>$(VSInstallRoot)\Windows Kits\10\</WindowsSdkDir_10>
<UniversalCRTSdkDir_10 Condition="'$(UniversalCRTSdkDir_10)' == ''">$(WindowsSdkDir_10)</UniversalCRTSdkDir_10>
<UCRTContentRoot Condition="'$(UCRTContentRoot)' == ''">$(WindowsSdkDir_10)</UCRTContentRoot>
</PropertyGroup>
<PropertyGroup>
<PreferredToolArchitecture Condition="'$(PROCESSOR_ARCHITECTURE)' == 'AMD64'">x64</PreferredToolArchitecture>
</PropertyGroup>
</Project>
@@ -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.
<SDKVersion Condition="'$(SDKVersion)' == ''">10.0</SDKVersion>
<SDKDisplayName Condition="'$(SDKDisplayName)' == ''">Windows 10</SDKDisplayName>
- <_LatestWindowsTargetPlatformVersion>$([Microsoft.Build.Utilities.ToolLocationHelper]::GetLatestSDKTargetPlatformVersion($(SDKIdentifier), $(SDKVersion)))</_LatestWindowsTargetPlatformVersion>
+ <_LatestWindowsTargetPlatformVersion>$([Microsoft.Build.Utilities.ToolLocationHelper]::GetLatestSDKTargetPlatformVersion($(SDKIdentifier), $(SDKVersion), $(MsvcGoWine_ExtraSdkRoots.Split(';'))))</_LatestWindowsTargetPlatformVersion>
<WindowsTargetPlatformVersion Condition="'$(WindowsTargetPlatformVersion)' == '' and '$(_LatestWindowsTargetPlatformVersion)' != ''">10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
+126
View File
@@ -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 <real-tool.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 <windows.h>
#include <string>
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<int>(exitCode);
}
+211
View File
@@ -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 <dependentAssembly> 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
}
+64
View File
@@ -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 <dest>/bin/<arch>)"
func runEnv(args []string) int {
fs := flag.NewFlagSet("env", flag.ContinueOnError)
bin := fs.String("bin", "", "the <dest>/bin/<arch> 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 <dest>/bin/<arch>")
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
}
+29
View File
@@ -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 <dest>")
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/<arch> to PATH to use cl, link, lib, ...")
return 0
}
+70
View File
@@ -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 <dir> [options] fetch and unpack MSVC/WinSDK
msvc-go-wine install <dir> wire up wrappers for a downloaded MSVC
msvc-go-wine env --bin <dir/bin/arch> print INCLUDE/LIB for native clang-cl/lld-link use
msvc-go-wine version print the version
Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
`)
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/Cheviiot/msvc-go-wine
go 1.26.5
+309
View File
@@ -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 <dependentAssembly>
// 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
}
+194
View File
@@ -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/<packageKey>/<payloadName>, 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
}
+170
View File
@@ -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
}
+237
View File
@@ -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)
}
}
+90
View File
@@ -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()
}
+369
View File
@@ -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
}
+221
View File
@@ -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)
}
}
}
+90
View File
@@ -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 <foo/bar.h>` 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)
}
+442
View File
@@ -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 <destBin>/<arch> 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
}
+131
View File
@@ -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
}
+168
View File
@@ -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 <Some\\Path.H>\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 <some/path.h>\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 <GL/gl.h>\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 <GL/gl.h>\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 <Foo.h>\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)
}
}
+54
View File
@@ -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")
}
}
+46
View File
@@ -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<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. <dest>/bin/<arch>).
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)
}
+108
View File
@@ -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 // <dest>, absolute unix path
MSVCDirUnix string // <dest>/vc/tools/msvc/<ver>
BinDir string // <dest>/vc/tools/msvc/<ver>/bin/Host<host>/<arch> - cl/link/lib/ml/nmake/armasm live here
SDKBinDir string // <dest>/kits/10/bin/<sdkver>/<host> - mc/midl/mt/rc live here
MSBuildBinDir string // <dest>/MSBuild/Current/Bin/<dotnetHost> - 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 <dest>/bin/<arch> 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)
}
+56
View File
@@ -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)
}
}
+18
View File
@@ -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")
}
+39
View File
@@ -0,0 +1,39 @@
package wrapper
import (
"os"
"strings"
)
// clPostProcess mirrors cl's post-run fixup for `/P /Fi<file>` (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)
}
+72
View File
@@ -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)
}
+78
View File
@@ -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)
}
}
+39
View File
@@ -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
}
+59
View File
@@ -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
}
+49
View File
@@ -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)
}
}
+255
View File
@@ -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
// <dest>/bin/<arch>, not <dest>/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
}
+58
View File
@@ -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 // <bindir> - vc/tools/msvc/<ver>/bin/Host<host>/<arch>
dirSDK // <sdkbindir> - kits/10/bin/<sdkver>/<host>
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<file> 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
}
}