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