27 Commits
Author SHA1 Message Date
АлександрandGitHub 6220d26072 Update README.md 2026-07-31 19:38:18 +10:00
Cheviiot de49e80cb7 Resume interrupted downloads instead of restarting from scratch
MSVC/WinSDK/WDK/DXSDK payloads run into the hundreds of MB to several
GB, so a dropped connection or a retry after a transient error used to
mean throwing away everything already fetched and starting over from
byte 0. Track progress in a dest+".part" file and resume it via an
HTTP Range request, falling back to a full restart when the server
doesn't honor Range (200 instead of 206) or the local part is stale
(416).
2026-07-25 18:17:59 +10:00
Cheviiot 98bac75767 Make the wine-not-found error actionable
FindWine's error used to just say wine64/wine weren't on PATH, with no
next step - surface the exact install fix ("install the wine
package") instead of leaving the reader to figure that out
themselves.
2026-07-25 18:17:59 +10:00
Cheviiot 5cf52c9f9f Add vintner doctor for diagnosing a broken wine/toolchain setup
A misconfigured environment (wine missing, msitools not installed, a
partially-built toolchain) otherwise only surfaces as a wine-specific
error buried deep inside a build. `vintner doctor` checks wine itself
(found and actually runs), the optional extraction tools download
needs, and every installed <dest>/bin/<arch> toolchain's on-disk
layout, printing a pass/fail checklist and exiting non-zero if
anything's broken.
2026-07-25 18:14:47 +10:00
Cheviiot b366dfa9ac Add a concurrency lock for download/install against the same destination
combineDirTrees' merge logic assumes it's the only thing moving files
into a given target at a time; two `vintner download`/`install` runs
racing against the same --dest could otherwise interleave os.Rename
calls and corrupt the tree instead of erroring cleanly. Take an
exclusive, non-blocking flock(2) on the destination for the duration
of each run, so a second invocation fails immediately with a clear
message instead of silently colliding with the first.
2026-07-25 18:10:56 +10:00
Cheviiot 738234186d Run wrapped tools directly as vintner <tool> ..., no PATH needed
`vintner cl ...`, `vintner msbuild ...`, etc. now work without adding
<dest>/bin/<arch> to PATH or relying on the same-directory symlinks
`install` sets up there. wrapper.Run gained an explicit binDir
parameter (empty string preserves the existing os.Executable()-based
self-location for the ordinary multi-call/symlink case) so
cmd/vintner's new runTool can point it at a resolved toolchain
directory instead.

Resolution order: VINTNER_BIN if set (same meaning as `env --bin` -
point it at a <dest>/bin/<arch> directory directly, for a non-default
--dest or a specific architecture), else <defaultToolchainDir>/bin/
<hostArch> - the layout a plain `vintner download && vintner install`
with no --dest override produces. A missing toolchain gets a clear
error pointing at both fixes, rather than bubbling up whatever
wineenv.Load's env.json error looks like.

Also fixed shell completion falling out of sync with the actual tool
list: the bash/zsh scripts previously hand-copied tool/flag names
(and had already gone stale once - --with-dxsdk was missing from the
download flag completions since it was added). The tool name list is
now generated from wrapper.ToolNames() instead of hand-maintained,
and both scripts now complete tool names too, so `vintner <TAB>`
suggests `cl`, `link`, `msbuild`, etc. alongside the management
subcommands.

Verified end-to-end with a minimal PATH (/usr/bin:/bin only, no
toolchain dir on it at all): `vintner cl /nologo hello.c` compiled
successfully, and `vintner msbuild -t:Rebuild ...` rebuilt the same
real KMDF driver verified earlier this session - both via the default
~/.vintner/bin/<hostArch> resolution, no VINTNER_BIN override needed.
2026-07-25 16:23:46 +10:00
Cheviiot f6b9a0811a Prevent and recover from wedged Wine-hosted processes
Prompted by a real incident: an MSBuild node-reuse worker (its own
/nodeReuse:true default) survived a build getting interrupted, came
back deadlocked, and got reused by the next `msbuild` invocation -
which then failed with a confusing, unrelated-looking
`System.TypeLoadException` on Microsoft.VisualStudio.Telemetry on
every call for hours, until the stale process was killed by hand.
That's exactly the "unrelated blocker" noted in this repo's own
earlier session notes (CLAUDE.md) while debugging a real project's
build - it wasn't a missing dependency, it was a corrupted reused
process.

Two changes:

- vintner now forces /nodeReuse:false on every msbuild invocation
  (unless the caller already passed their own /nodeReuse or /nr
  switch), so a wedged worker can never poison a later, unrelated
  build in the first place. Costs each invocation the couple-hundred-
  ms/node startup time node reuse exists to save.

- VINTNER_TIMEOUT (a duration string, e.g. "30m") bounds how long any
  single tool invocation is allowed to run, for the case something
  wedges that isn't MSBuild-specific. Every exec.Command site in
  internal/wrapper now goes through a shared newToolCommand
  constructor that, when the timeout is set, kills the *whole*
  process group (not just the immediate `wine` process - a wedged
  child surviving under it is exactly the scenario this needs to
  reach) via a context deadline, and reports a clear "timed out after
  Xm" message (exit 124, matching the timeout(1) convention) instead
  of a bare "signal: killed". Unset by default - every real build
  observed stays unbounded, matching Windows' own behavior.

Verified end-to-end, not just at the unit level: a real `sleep 30`
through the `cmd` native wrapper with VINTNER_TIMEOUT=1s was killed
within the deadline and reported the timeout clearly (exit 124); a
real `cl` invocation with the same 1s timeout finished normally
(0.26s) without being mistaken for a hang.
2026-07-25 15:58:22 +10:00
Cheviiot bdea270d71 docs: list cabextract as a prerequisite for --with-dxsdk 2026-07-25 15:44:36 +10:00
Cheviiot 25f751e874 Build old (pre-v145) PlatformToolset-pinned .vcxproj files
vintner only ever downloads one compiler generation, but real-world
.vcxproj files are pinned to whichever PlatformToolset they were last
saved under - v142 (VS2019) for anything not actively maintained is
extremely common. MSBuild checks toolset "installed-ness" (MSB8020) by
testing whether MSBuild/Microsoft/VC/v<schema>/Platforms/<arch>/
PlatformToolsets/<toolset>/ exists on disk - a plain file lookup our
downloaded MSBuild package only satisfies for the exact generation it
shipped. `install` now symlinks every historical numeric PlatformToolset
name (v90 through v143) onto whichever real toolset directory is
actually present, so any of them resolves transparently; Toolset.props/
.targets don't hardcode a version number, so aliasing is correct, not
just a workaround.

Three more MSBuild property/environment issues came with it, all found
building a real years-old project against the one modern toolchain
vintner installs:

- VCInstallDir_<N>/VCToolsInstallDir_<N> needed a third numbering
  source (PlatformToolset short names from Microsoft.VCToolsVersion.
  v<N>.default.props) alongside the existing MSBuild schema-version and
  known-toolset lists, so the env-var-driven half of toolset resolution
  covers the same names the on-disk alias does.
- VCToolsVersion must be a real version string: left unset, it falls
  back to a literal placeholder that then hits an unconditional
  version-string comparison elsewhere in Microsoft.CppBuild.targets
  (MSB4184). Setting it to the real installed version in turn requires
  CheckMSVCComponents=false, since CheckVCToolsetVersion (MSB8052)
  otherwise rejects an aliased PlatformToolset whenever its numeric
  generation doesn't match VCToolsVersion's - exactly the case aliasing
  creates on purpose. Everything else CheckMSVCComponents gates is
  diagnostic-only (MFC/ATL/Spectre presence warnings), so disabling it
  costs nothing else.
- WindowsTargetPlatformVersion needed to become an explicit /p: global
  property on the msbuild command line, not just an env var: legacy
  .vcxproj files commonly hardcode this in a PropertyGroup, and an
  explicit project assignment always wins over an inherited environment
  variable of the same name. A command-line global property is the one
  thing a project file can't override. Only injected when the caller
  hasn't already pinned it themselves.
2026-07-25 15:41:09 +10:00
Cheviiot 4795c7c105 Add DirectX SDK (D3DX9) download support
download --with-dxsdk fetches the DirectX SDK (June 2010) - the last
standalone release of D3DX9/10/11, XInput and XAudio2, dropped from
the Windows SDK entirely once D3DX was deprecated, but still needed
by plenty of legacy code. Like the WDK, it isn't part of the VS
installer manifest, so this is its own self-contained fetch+unpack
path: the installer is a self-extracting CAB, unpacked directly via
cabextract (already a prerequisite for the WinSDK .msi payloads) with
its -F filter restricting extraction to just Include/ and Lib/ -
about 21MB out of the installer's 1.2GB uncompressed payload.

Verified against the real installer: real d3dx9.h and d3dx9.lib
(x86 and x64) extracted correctly and linked into an actual legacy
game client build.
2026-07-25 15:40:41 +10:00
Cheviiot 91471397fa Fix toolrelay.exe's mt.exe detection: it never matched a real path
IsMtExe() looked for the last '\\' in the target executable path to
find the bare filename, but vintner passes toolExePath straight from
Go's filepath.Join (forward slashes) all the way through to
CreateProcessW - so the path toolrelay.exe actually receives has no
backslash in it at all, and IsMtExe() compared the *entire path*
against "mt.exe", which of course never matched. The whole point of
this file - translating mt.exe's CMake-compatibility exit code
(0x41020001 -> 0xbb) before Wine's own exit-code truncation destroys
it - has silently never fired in real usage.

This had gone unnoticed because MSBuild's rawStdout path bypasses
toolrelay.exe entirely, and every real end-to-end test so far
(msbuild-driven builds, including the KMDF driver) went through that
path. Found while getting a CMake+Ninja-generated MSVC build of a
real project (Ogre3D) past its `cmake -E vs_link_exe` manifest step,
which depends on exactly this translation.

Now checks for both '\\' and '/' and uses whichever separator occurs
last in the path. Verified directly: `mt /manifest ... /notify_update`
through the wrapper now exits 187 (0xbb) instead of 1, and the Ogre
build gets past the manifest-embedding step it was failing at.
2026-07-25 11:55:05 +10:00
Cheviiot 94a19e6b43 Show git commit and build time in vintner version
Uses Go's automatic VCS build-info stamping (vcs.revision/vcs.time/
vcs.modified, embedded by `go build` since Go 1.18 - no -ldflags
changes needed, works the same for a CI release build and a plain
local `go build`). Knowing the exact commit a bug report's binary was
built from, not just the X.Y.Z tag, is the point - two builds of the
same tag could still differ.

Split the pure formatting logic (formatVersion) from the
debug.ReadBuildInfo() call so it's actually unit-testable: `go test`
binaries don't get VCS stamping the way `go build` ones do, so there
was no way to exercise the revision-formatting branch through
versionString() itself.
2026-07-25 11:37:02 +10:00
Cheviiot 6186837fd2 docs: note that Nivora installs get shell completion automatically 2026-07-25 11:33:41 +10:00
Cheviiot c049274626 Rewrite README prose: cut repeated self-assurance, tighten sentences
The old version restated "independently implemented"/"original to
this project" in three separate places, which reads as protesting
too much rather than as confidence. Credits the msvc-wine inspiration
once, plainly, and drops the rest. Also split several overlong
comma-chained sentences, fixed the WDK section (a sentence was cut in
half by its own code block), and cleaned up the license paragraph's
phrasing. Content is unchanged - verified the two claims most worth
double-checking (the clang-cl/lld-link path, and that `stplr install
nivora/vintner` actually resolves) rather than just rewording them.
2026-07-25 10:55:01 +10:00
Cheviiot fc20b2fb15 Add test coverage for cmd/vintner and internal/i18n
Both were at 0% coverage. Focused on what's safely testable without
touching the network or filesystem: flag validation (--architecture/
--host-arch, the same guard added in the stability pass), subcommand
dispatch and aliases, help/usage error paths, and - for i18n - full
language-detection table coverage plus a completeness check that
every catalog key has both an EN and RU entry (an English-only or
Russian-only entry would silently degrade rather than fail loudly,
so this is worth locking in). cmd/vintner: 0% -> 28.8%, i18n: 0% ->
94.1%.
2026-07-25 10:41:48 +10:00
Cheviiot 26f6df6a9a Add bash/zsh shell completion
vintner completion bash|zsh prints a completion script meant to be
sourced (source <(vintner completion bash)); completes subcommands
(including short aliases), download's flags, and directory arguments
for install/env --bin. Mentioned in the top-level usage text and
documented in the README.

The Nivora package doesn't auto-install these system-wide yet - it'd
need Stapler's install-completion helper, whose calling convention
isn't documented anywhere in this repo or Nivora's other packages, so
guessing at it risked a broken package build for a nice-to-have.
source <(vintner completion bash) works today regardless of install
method (Nivora, prebuilt binary, or from source).
2026-07-25 10:39:22 +10:00
Cheviiot 98ee39018a Stop orphaning wine subprocesses when vintner is killed by PID
Every wrapped tool invocation (cl/link/msbuild/etc via wine, plus the
native cmd/findstr shims) now starts its child in its own process
group and forwards SIGINT/SIGTERM to that group, escalating to
SIGKILL after a 5s grace period if it doesn't exit.

Previously, interactive Ctrl-C happened to work by accident (the
child inherited the terminal's foreground process group and got the
signal directly), but anything that signals vintner by PID alone - a
CI job's timeout, a supervisor's `kill <pid>` - never reached the
wine/wineserver tree underneath it, which got reparented to init and
kept running: wasted CPU, held file locks, stray FIFOs/temp files.

Verified two ways: a unit test (signals_test.go) that starts a
detached `sleep 30`, signals the test process itself, and checks the
child actually dies; and a real end-to-end run - killed an in-flight
`msbuild` driver build by PID mid-compile and confirmed no orphaned
msbuild/cl/link/vintner process was left behind (wineserver and its
persistent service processes are expected to survive, by design - see
pipeDrainGrace's doc comment).
2026-07-25 10:34:45 +10:00
Cheviiot 4b3aacfdf7 fix(ci): let staticcheck install its own newer Go instead of reusing 1.23 2026-07-25 04:21:15 +10:00
Cheviiot 0b7b686e3a Add staticcheck to CI
Caught a real dead-code finding (an unused off() helper) during this
session's stability pass; running it on every push/PR catches this
class of issue automatically instead of relying on someone happening
to run it locally.
2026-07-25 04:19:34 +10:00
Cheviiot 0a4f05c673 Add regression test for deterministic dependency resolution order
Loops ExpandSelection 50 times and checks the result order never
changes, guarding against the map-iteration-order bug fixed in the
previous commit ever coming back unnoticed. Verified this actually
catches the regression by temporarily reverting the fix locally.
2026-07-25 04:17:57 +10:00
Cheviiot e0475101b2 Rewrite README with real install paths, TOC, and current command surface
Adds the two installation options that now actually exist (Nivora
package, prebuilt GitHub Release binary) alongside building from
source, a table of contents, CI/release/license badges, and a
Language section for VINTNER_LANG. Moves the more implementation-
focused toolrelay.exe/compatibility-patches explanations into
collapsible sections so the top of the page stays focused on using
the tool rather than how it's built.
2026-07-25 04:16:33 +10:00
Cheviiot d11b534fa1 Stability pass: deterministic dependency order, retry backoff, input validation
Found via manual audit plus a staticcheck run:

- collectDependencyClosure iterated a package's dependencies map
  directly, so which package "won" a same-key collision (and the
  order things got downloaded/unpacked in) could vary between runs
  of the exact same download command. Sort the dependency targets
  first, matching what --print-deps-tree's tree-printer already did.
  Verified two consecutive --print-deps-tree runs now produce
  byte-identical output.
- HTTP retry loops (manifest fetch, payload download) retried
  immediately with no backoff, which just hammers a server harder
  during exactly the kind of transient failure retries exist for.
  Added a capped exponential backoff (1s/2s/4s/8s/10s).
- --architecture/--host-arch accepted any string silently; a typo'd
  value matched nothing during package selection and surfaced as a
  confusing downstream failure far from the actual mistake. Now
  rejected up front with a clear error.
- pumpLines' bufio.Scanner silently stops (dropping the rest of a
  tool's output) if a single line ever exceeds its buffer - narrow but
  real for pathological cases like heavily templated C++ diagnostics.
  Now at least reports that truncation happened instead of losing
  output with no trace.
- Removed select.go's unused off() helper (staticcheck U1000).

Re-verified end-to-end after these changes: a real KMDF driver build
and a plain cl/link build both still succeed.
2026-07-25 04:14:34 +10:00
Cheviiot a1743e4435 Rebrand project to vintner
Renamed the GitHub repo, Go module path, binary, and default install
directory from msvc-go-wine to vintner. Updated every user-facing
string (usage text, error prefixes, README, LICENSE, CI/release
workflow) and the embedded compatibility patches' own header text to
match; the VINTNER_LANG env var replaces VSMC_GO_WINE_LANG.

Also fixes a real bug found while re-verifying the rename end-to-end:
Microsoft.Cpp.WindowsSDK.props.patch had LF-only line endings in its
hunk body while the real Microsoft-shipped file it targets is CRLF,
so `git apply` silently failed on every real install and the SDK
detection fix it's meant to provide was never actually taking effect.
Restored matching CRLF endings in the hunk (checked against the other
five patches, which already had this right). Left the patch's
internal MsvcGoWine_ExtraSdkRoots MSBuild property name alone rather
than renaming it too - changing hunk content, even just an identifier,
breaks reverse-apply idempotency for anyone re-running download
against an already-patched tree, which the fix above depends on. Added
a .remove marker so an existing install's old-named props file gets
cleaned up on the next download.

Re-verified end-to-end after the rename: general MSBuild/cl/link
still work, and a real KMDF driver build (compile, link, INF stamping,
Inf2Cat signability check) still succeeds under the renamed binary.

README also gets an accuracy pass: WDK support and the download/env
CLI flags it lists were out of date (WDK was previously listed under
"Known gaps" despite being implemented and verified), the full tool
list was missing mc/cmd/findstr, and the new command aliases and
VINTNER_LANG option are now documented.
2026-07-25 03:55:22 +10:00
Cheviiot 23ea620ce2 Add short subcommand aliases and EN/RU CLI localization
Subcommands gain one/two-letter aliases (dl, i, e, v, h) alongside
their full names. CLI-owned chrome - top-level usage, per-subcommand
usage lines, progress messages, and the license prompt - now goes
through internal/i18n, an env-driven message catalog (MSVC_GO_WINE_LANG,
falling back to the standard LC_ALL/LC_MESSAGES/LANG locale variables)
with English and Russian translations. Deeper error text from internal
packages stays in English.

Also refreshed the top-level usage text, which hadn't kept up with
--with-wdk/--list-workloads/--list-components/--print-deps-tree.
2026-07-25 03:31:59 +10:00
Cheviiot 8551d7fe99 Add Windows Driver Kit (WDK) support for building KMDF/UMDF drivers
download --with-wdk fetches the WDK headers/libs/host-tools NuGet
packages (nuget.org has no vsman-manifest entry for this content) and
lays them out where the DriverKit.BuildTools PlatformToolset expects
them. msbuildEnv wires WDKContentRoot/WDKBuildFolder through
DisableRegistryUse the same way the SDK/toolset paths already are.

Two WDK-package fixups were needed for a real driver to actually
build under Wine: the bundled build-task assembly is versioned for an
older VisualStudioVersion than ours, and the package ships no x86
host-tools directory at all (only x64/arm64), which StampInf hardcodes
a path to.

Also force TZ=UTC for msbuild invocations: StampInf stamps DriverVer
using the local wall-clock date while Inf2Cat validates it against
UTC "now", so any timezone east of UTC sees a "postdated DriverVer"
failure for most of the day.

Verified against a real KMDF sample driver (microsoft/Windows-driver-
samples' echo_2): compiles, links, INF stamps and passes Inf2Cat's
signability check with SignMode=off.
2026-07-25 03:24:44 +10:00
Cheviiot 44fee57ddd Add download --list-workloads/--list-components/--print-deps-tree, and CI
- download --list-workloads / --list-components print every workload or
  component id (with its manifest title) without downloading anything, for
  discovering what to pass as a package id or --with-* toggle.
- download --print-deps-tree prints the dependency tree of whatever the
  current flags would actually select, sharing the exact same
  arch/--ignore/Optional/Recommended filtering ExpandSelection uses so the
  output matches a real download; diamond dependencies are shown once and
  referenced as "(see above)" afterwards to keep it finite.
- Fixed --manifest (offline/predownloaded manifest testing) never having
  worked at all: it builds a "file:" URL but the shared http.Client had no
  handler registered for that scheme.
- Added a CI workflow running gofmt/vet/build/test on every push and PR;
  previously only the tag-triggered release workflow existed.
2026-07-25 02:47:24 +10:00
Cheviiot 47471e007c Fix stdout/stderr pipe hang from lingering Wine background processes
msbuild (and the toolrelay.exe-less fallback path for cl/link/etc.) inherited
os.Stdout/os.Stderr directly into the wine subprocess. Wine's wineserver and
its service processes (services.exe, winedevice.exe, explorer.exe, ...)
inherit those same descriptors and keep running well after the actual build
finishes, so a caller piping our output (`| tee`, `| tail`, CI log capture)
would never see EOF and hang indefinitely - even though the real build
completed in seconds.

Both paths now pipe stdout/stderr through our own copy goroutines, wait for
the tool's own process (not pipe EOF) to determine completion, and grant a
bounded 500ms grace period to drain whatever's already buffered before
moving on. Verified against a real hang (msbuild building freetype.vcxproj
piped through `tail`) and confirmed instant return after the fix, both on
success and on a build error.
2026-07-25 02:39:26 +10:00
71 changed files with 4756 additions and 539 deletions
+55
View File
@@ -0,0 +1,55 @@
name: CI
on:
push:
branches: ["master"]
pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
test:
name: Build, vet & test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.23"
- name: gofmt
run: |
out="$(gofmt -l .)"
if [ -n "$out" ]; then
echo "gofmt would reformat:"
echo "$out"
exit 1
fi
- name: go vet
run: go vet ./...
- name: go build
run: go build ./...
- name: go test
run: go test ./...
- name: staticcheck
uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1.4.1
with:
version: latest
# Staticcheck itself needs a newer Go than the 1.23 this repo
# targets (go.mod's floor) - let the action install its own,
# separate from the "Set up Go" step above. min-go-version
# defaults to reading go.mod, so diagnostics still target 1.23.
install-go: true
+8 -8
View File
@@ -48,17 +48,17 @@ jobs:
tag="${{ inputs.tag || github.ref_name }}" tag="${{ inputs.tag || github.ref_name }}"
version="${tag#v}" version="${tag#v}"
go build -trimpath -ldflags="-s -w -X main.version=${version}" \ go build -trimpath -ldflags="-s -w -X main.version=${version}" \
-o "msvc-go-wine-linux-${{ matrix.goarch }}" ./cmd/msvc-go-wine -o "vintner-linux-${{ matrix.goarch }}" ./cmd/vintner
- name: Verify it runs - name: Verify it runs
if: matrix.goarch == 'amd64' if: matrix.goarch == 'amd64'
run: ./msvc-go-wine-linux-amd64 version run: ./vintner-linux-amd64 version
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: msvc-go-wine-linux-${{ matrix.goarch }} name: vintner-linux-${{ matrix.goarch }}
path: msvc-go-wine-linux-${{ matrix.goarch }} path: vintner-linux-${{ matrix.goarch }}
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
@@ -71,11 +71,11 @@ jobs:
- name: Download all architectures - name: Download all architectures
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
pattern: msvc-go-wine-linux-* pattern: vintner-linux-*
merge-multiple: true merge-multiple: true
- name: Checksums - name: Checksums
run: sha256sum msvc-go-wine-linux-* > SHA256SUMS run: sha256sum vintner-linux-* > SHA256SUMS
- name: Create or update GitHub Release - name: Create or update GitHub Release
env: env:
@@ -85,10 +85,10 @@ jobs:
tag="${{ inputs.tag || github.ref_name }}" tag="${{ inputs.tag || github.ref_name }}"
if ! gh release view "$tag" >/dev/null 2>&1; then if ! gh release view "$tag" >/dev/null 2>&1; then
gh release create "$tag" \ gh release create "$tag" \
--title "msvc-go-wine $tag" \ --title "vintner $tag" \
--notes "Linux amd64/arm64 binaries built by CI from this tag. See README.md for install instructions." --notes "Linux amd64/arm64 binaries built by CI from this tag. See README.md for install instructions."
fi fi
gh release upload "$tag" \ gh release upload "$tag" \
msvc-go-wine-linux-* \ vintner-linux-* \
SHA256SUMS \ SHA256SUMS \
--clobber --clobber
+1 -1
View File
@@ -1,3 +1,3 @@
/msvc-go-wine /vintner
*.test *.test
.claude/ .claude/
+2 -2
View File
@@ -20,6 +20,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
This license covers msvc-go-wine's own source code. It does not cover the This license covers vintner's own source code. It does not cover the
Microsoft Visual C++ Build Tools / Windows SDK that `msvc-go-wine download` Microsoft Visual C++ Build Tools / Windows SDK that `vintner download`
fetches and unpacks, which remain governed by Microsoft's own license terms. fetches and unpacks, which remain governed by Microsoft's own license terms.
+253 -81
View File
@@ -1,129 +1,301 @@
# msvc-go-wine # Vintner
Cross compile with MSVC on Linux, using Wine — a single-binary Go tool [![CI](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml/badge.svg)](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml)
inspired by [mstorsjo/msvc-wine](https://github.com/mstorsjo/msvc-wine)'s [![Release](https://img.shields.io/github/v/release/Cheviiot/vintner)](https://github.com/Cheviiot/vintner/releases/latest)
approach (download the real MSVC/WinSDK, wrap the compiler under Wine), [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
implemented independently.
Once installed, you invoke the real Microsoft toolchain exactly like on vintner cross-compiles with the real MSVC toolchain on Linux, using Wine.
Windows: `cl`, `link`, `lib`, `rc`, `midl`, `mt`, `dumpbin`, `msbuild`, One Go binary drops in as `cl`, `link`, `lib`, `rc`, `midl`, `mc`, `mt`,
`nmake`, `ml`, `ml64`, `armasm`, `armasm64` all just work from your `PATH`. `dumpbin`, `msbuild`, `nmake`, `ml`, `ml64`, `armasm`, `armasm64`, plus
`cmd`/`findstr` shims, so once installed you invoke the real Microsoft
tools exactly like on Windows. It handles full MSBuild projects, with
`--with-wdk` real KMDF/UMDF Windows drivers, and with `--with-dxsdk` the
real D3DX9 headers/libs.
Inspired by [mstorsjo/msvc-wine](https://github.com/mstorsjo/msvc-wine)'s
approach: download the real MSVC/WinSDK, wrap the compiler under Wine.
## Contents
- [How it works](#how-it-works)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Commands](#commands)
- [Invoking tools without PATH](#invoking-tools-without-path)
- [Diagnosing problems (vintner doctor)](#diagnosing-problems-vintner-doctor)
- [Building drivers (WDK)](#building-drivers-wdk)
- [Building against D3DX9 (DirectX SDK)](#building-against-d3dx9-directx-sdk)
- [Automated/scripted builds](#automatedscripted-builds)
- [Language](#language)
- [Shell completion](#shell-completion)
- [Using clang-cl/lld-link instead of Wine](#using-clang-cllld-link-instead-of-wine)
- [toolrelay.exe](#toolrelayexe)
- [Compatibility patches](#compatibility-patches)
- [Building from source](#building-from-source)
- [License](#license)
## How it works ## How it works
`msvc-go-wine` is one Go binary that behaves differently depending on the vintner is a multi-call binary, like busybox: it behaves differently
name it's invoked as (a "multi-call binary", like busybox): depending on the name it's invoked as.
- Invoked as `cl`, `link`, `lib`, ... → it loads a small per-architecture - As `cl`, `link`, `lib`, and the rest: it loads a per-architecture
`env.json`, builds the `INCLUDE`/`LIB`/`WINEPATH` environment Wine needs, `env.json`, sets `INCLUDE`/`LIB`/`WINEPATH`, and rewrites absolute Unix
rewrites absolute unix paths in the arguments into Wine's `z:\...` form paths in the arguments to Wine's `z:\...` form (Wine and cl.exe
(working around [a Wine/cl.exe include-path bug](https://bugs.winehq.org/show_bug.cgi?id=55200)), otherwise mishandle relative includes — see
runs the real `.exe` under `wine`/`wine64`, and rewrites the tool's output [winehq bug 55200](https://bugs.winehq.org/show_bug.cgi?id=55200)). It
back from `z:\...` paths to plain unix paths so your build system's error then runs the real `.exe` under `wine`/`wine64`, and rewrites `z:\...`
paths back to Unix paths in the output, so your build system's error
parsing keeps working. parsing keeps working.
- Invoked as `msvc-go-wine` it exposes the `download`, `install`, `env` and - As `vintner`: it exposes the `download`, `install`, `env`, `version`,
`version` management subcommands described below. `doctor` and `completion` subcommands below (short aliases: `dl`, `i`,
`e`, `v`; `help`/`h` prints usage).
## Quick start ## Installation
On ALT Linux, via [Nivora](https://github.com/Cheviiot/Nivora):
```bash ```bash
# 1. Download and unpack MSVC + Windows SDK into ~/.msvc-go-wine (requires stplr install nivora/vintner
# accepting Microsoft's Visual Studio Build Tools license, and msitools
# for unpacking .msi payloads). Pass --dest <dir> for a different location.
msvc-go-wine download --accept-license
# 2. Wire up the tool wrappers
msvc-go-wine install
# 3. Add the toolchain to PATH and build
export PATH=~/.msvc-go-wine/bin/x64:$PATH
cl /nologo /EHsc hello.cpp
``` ```
Prebuilt binary, from the [latest release](https://github.com/Cheviiot/vintner/releases/latest):
```bash
curl -fLo vintner "https://github.com/Cheviiot/vintner/releases/latest/download/vintner-linux-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')"
chmod +x vintner
sudo install vintner /usr/local/bin/vintner
```
From source: see [Building from source](#building-from-source).
Either way, `wine`/`wine64`, `msitools` (for `msiextract`) and `git` need
to be on `PATH` at run time. Nivora installs pull these in automatically
as package dependencies.
### Prerequisites ### Prerequisites
- `wine` (or `wine64`) — runs the real `cl.exe`/`link.exe`/etc. - `wine` (or `wine64`) — runs the real `cl.exe`/`link.exe`/etc.
- `msitools` (`msiextract`) — unpacks the `.msi` payloads MSVC/WinSDK ship as. - `msitools` (`msiextract`) — unpacks the `.msi` payloads MSVC/WinSDK ship as.
- `git` used to apply the small compatibility patches bundled with - `git` — applies the compatibility patches bundled with `download` (see
`download` (see Compatibility patches below). [Compatibility patches](#compatibility-patches)).
- `cabextract` — only needed for `download --with-dxsdk` (see
[Building against D3DX9](#building-against-d3dx9-directx-sdk)).
On ALT Linux: On ALT Linux:
```bash ```bash
pkcon install wine msitools pkcon install wine msitools git cabextract
``` ```
## Quick start
```bash
# 1. Download and unpack MSVC + Windows SDK into ~/.vintner (accepts
# Microsoft's Visual Studio Build Tools license). Pass --dest <dir>
# for a different location.
vintner download --accept-license
# 2. Wire up the tool wrappers
vintner install
# 3. Add the toolchain to PATH and build
export PATH=~/.vintner/bin/x64:$PATH
cl /nologo /EHsc hello.cpp
```
Don't want to touch PATH? Skip step 3 and run tools through vintner
directly instead - see [Invoking tools without PATH](#invoking-tools-without-path).
## Commands ## Commands
``` ```
msvc-go-wine download --accept-license [--dest <dir>] [options] fetch and unpack MSVC/WinSDK vintner download (dl) --accept-license [--dest <dir>] [options] fetch and unpack MSVC/WinSDK/WDK
msvc-go-wine install [dir] wire up wrappers for a downloaded MSVC vintner install (i) [dir] wire up wrappers for a downloaded MSVC
msvc-go-wine env --bin <dir>/bin/<arch> print INCLUDE/LIB for native clang-cl/lld-link use vintner env (e) --bin <dir>/bin/<arch> print INCLUDE/LIB for native clang-cl/lld-link use
msvc-go-wine version print the version vintner version (v) print the version
vintner doctor check wine/toolchain setup
vintner help (h) print usage
vintner completion bash|zsh print a shell completion script
``` ```
`--dest`/`[dir]` both default to `~/.msvc-go-wine` when omitted. `--dest`/`[dir]` both default to `~/.vintner` when omitted.
`download` supports `--msvc-version`, `--sdk-version`, `--architecture`, `download`'s main options: `--msvc-version`, `--sdk-version`,
`--host-arch`, `--with-*` component toggles, `--ignore`, `--only-download`, `--architecture` (repeatable: `x86`/`x64`/`arm`/`arm64`/`host`),
`--only-unpack`, `--keep-unpack`, `--cache`, `--language`, `--host-arch`, `--only-host`, `--with-wdk` (see below), `--with-dxsdk`
(see below), `--ignore` (repeatable), `--only-download`, `--only-unpack`,
`--keep-unpack`, `--skip-patch`, `--cache`, `--language`,
`--include-optional`, `--skip-recommended`, `--major`, `--preview`, `--include-optional`, `--skip-recommended`, `--major`, `--preview`,
`--manifest`. Run `msvc-go-wine download -h` for the full list. `--manifest`, `--list-workloads`, `--list-components`,
`--print-deps-tree`. Run `vintner download -h` for the full list with
descriptions.
### Using clang-cl/lld-link instead of Wine `--list-workloads`/`--list-components` print every workload/component id
and its human-readable title from the fetched manifest, then exit
without downloading anything. Useful for finding what to pass as a bare
package id or through `--with-*`. `--print-deps-tree` prints the
dependency tree of whatever would actually be selected — honoring every
other flag — without downloading anything.
You don't need Wine at all if you drive the (nonredistributable) MSVC/WinSDK ## Invoking tools without PATH
headers and libraries with Clang/LLD in MSVC-compatible mode:
`cl`, `link`, `msbuild`, and the rest also work as `vintner <tool>
[args...]`, with no need to add `<dest>/bin/<arch>` to `PATH` or rely on
the symlinks `install` sets up there:
```bash ```bash
eval "$(msvc-go-wine env --bin ~/.msvc-go-wine/bin/x64)" vintner cl /nologo /EHsc hello.cpp
vintner msbuild MyProject.sln
```
Resolves the toolchain to use from `VINTNER_BIN` if set (same meaning as
`env --bin`: point it at a `<dest>/bin/<arch>` directory directly — useful
for a non-default `--dest`, or to pick a specific architecture when more
than one is installed), otherwise defaults to
`~/.vintner/bin/<host-arch>`, the layout a plain `vintner download &&
vintner install` with no `--dest` override produces.
## Diagnosing problems (vintner doctor)
```bash
vintner doctor
```
Checks the things vintner actually needs at runtime — that `wine`/`wine64`
is on `PATH` and actually runs, that `msitools`/`cabextract` are present,
and that every installed `<dest>/bin/<arch>` toolchain (or the one
`VINTNER_BIN` points at) has its MSVC/SDK/MSBuild directories in place —
and prints a pass/fail checklist. Exits non-zero if anything failed.
Useful before filing a bug, or after a `download`/`install` that seemed to
finish but left tools failing in confusing ways.
## Building drivers (WDK)
`--with-wdk` also fetches the Windows Driver Kit: headers, import libs,
and the MSBuild `WindowsKernelModeDriver10.0`/`WindowsUserModeDriver10.0`
PlatformToolsets.
```bash
vintner download --accept-license --with-wdk
```
With it, `msbuild` builds real KMDF/UMDF drivers — compiling, linking,
INF stamping, and the `Inf2Cat` signability check (`SignMode=off`) all
work under Wine. Tested against a real sample driver from
[microsoft/Windows-driver-samples](https://github.com/microsoft/Windows-driver-samples).
Only x64 and arm64 targets have a WDK package upstream; there's no x86 or
arm one.
## Building against D3DX9 (DirectX SDK)
`--with-dxsdk` fetches the DirectX SDK (June 2010) — the last standalone
release of D3DX9/10/11, XInput and XAudio2, dropped from the Windows SDK
entirely once D3DX was deprecated. It unpacks the real headers and x86/x64
import libs (`d3dx9.h`/`d3dx9.lib` included) to `<dest>/DXSDK`.
```bash
vintner download --accept-license --with-dxsdk
```
Point your project's `IncludePath`/`LibraryPath` at
`<dest>/DXSDK/Include` and `<dest>/DXSDK/Lib/x86` or `<dest>/DXSDK/Lib/x64`.
Requires `cabextract` on `PATH` (the installer is a self-extracting CAB
archive).
## Automated/scripted builds
Every tool invocation runs unbounded by default, same as the real thing on
Windows. Set `VINTNER_TIMEOUT` (a `time.ParseDuration` string, e.g. `30m`,
`2h`) to have vintner kill and fail a build that runs longer than that
instead of hanging forever - meant for CI and other unattended callers, not
interactive use. This guards against one confirmed failure mode: an
MSBuild node-reuse worker (`/nodeReuse:true` is MSBuild's own default) can
survive its parent process under Wine and, if a prior build was
interrupted mid-compile, come back wedged - reused by the next `msbuild`
call and failing every subsequent build with a confusing, unrelated-looking
error, indefinitely, until it's killed by hand. vintner already forces
`/nodeReuse:false` on every `msbuild` invocation to prevent this in the
first place; `VINTNER_TIMEOUT` is the backstop for whatever else might
wedge under Wine that isn't MSBuild-specific.
```bash
VINTNER_TIMEOUT=30m msbuild MyProject.sln
```
## Language
CLI text (usage, progress lines, prompts) defaults to English. Set
`VINTNER_LANG=ru` (or a `ru`-prefixed `LC_ALL`/`LC_MESSAGES`/`LANG`, e.g.
`ru_RU.UTF-8`) for Russian:
```bash
VINTNER_LANG=ru vintner help
```
Error text from internal packages stays in English regardless.
## Shell completion
Already set up if you installed via Nivora. Otherwise:
```bash
source <(vintner completion bash) # or add to ~/.bashrc
source <(vintner completion zsh) # or add to ~/.zshrc
```
Completes subcommands, including the short aliases, `download`'s flags,
and directory arguments for `install`/`env --bin`.
## Using clang-cl/lld-link instead of Wine
The MSVC/WinSDK headers and libraries work directly with Clang/LLD in
MSVC-compatible mode. No Wine needed:
```bash
eval "$(vintner env --bin ~/.vintner/bin/x64)"
clang-cl -c hello.c clang-cl -c hello.c
lld-link hello.obj -out:hello.exe lld-link hello.obj -out:hello.exe
``` ```
## Building from source
```bash
go build -o msvc-go-wine ./cmd/msvc-go-wine
```
Go 1.23+ is all you need to build it; `wine`/`msitools` are only needed at
run time (`install`/tool invocation and `download` respectively).
## toolrelay.exe ## toolrelay.exe
`install` compiles `assets/vendor/toolrelay.cpp` (a small native Windows `install` compiles `assets/vendor/toolrelay.cpp`, a small native Windows
launcher, original to this project) with the freshly-installed host-arch launcher, with the freshly-installed host-arch `cl.exe`. This is
`cl.exe` (best-effort: if `wine` isn't present yet, or the compile fails, best-effort: if `wine` isn't available yet, or the compile fails, install
install still succeeds and the wrapper runtime just falls back to invoking still succeeds, and tool invocations just skip it. When present, every
tools directly through wine). When present, every non-MSBuild tool non-MSBuild tool call is routed through it via two named FIFOs.
invocation is routed through it via two named FIFOs. This is what lets
`mt.exe`'s CMake-compatibility exit-code translation (`0x41020001``0xbb`) That's what lets `mt.exe`'s CMake-compatibility exit code
survive Wine's own exit-code truncation: only a native Windows process (`0x41020001``0xbb`) survive Wine's own exit-code truncation: a native
observing the untranslated code via `GetExitCodeProcess()` can catch it Windows process can read the real 32-bit exit code via
before Wine marshals the process exit back to Unix and drops everything but `GetExitCodeProcess()` before Wine collapses it to a single byte on the
the low byte. way back to Unix.
## Compatibility patches ## Compatibility patches
`download` applies a handful of small patches (`assets/patches`) to the `download` applies a few small patches (`assets/patches`) to the
downloaded MSVC/WinSDK tree - independently written for this project - that downloaded MSVC/WinSDK tree, so `VsDevCmd.bat` and MSBuild's
make `VsDevCmd.bat` and MSBuild's SDK-detection props work without a SDK-detection props work without a Windows Registry, which doesn't exist
Windows Registry (which doesn't exist under Wine): they check the SDK under Wine. They look up the SDK directly under the VS install root
directly under the VS install root instead of querying the registry, skip instead of querying the registry, skip telemetry, and don't fail devcmd
telemetry, and don't hard-fail devcmd setup when an optional component setup when an optional component (ConnectionManagerExe, bundled
(ConnectionManagerExe, bundled CMake/Ninja) wasn't downloaded. CMake/Ninja) is missing.
## Known gaps ## Building from source
- `download` doesn't yet support printing the dependency/reverse-dependency ```bash
tree, listing available workloads/components/packages, or installing the go build -o vintner ./cmd/vintner
Windows Driver Kit via `--with-wdk-installers`; the core selection/ go vet ./...
download/unpack/install pipeline is fully implemented. go test ./...
```
Go 1.23+ builds it. `wine`/`msitools` are only needed at run time, for
`install`/tool invocation and `download` respectively.
## License ## License
MIT, see [LICENSE.txt](LICENSE.txt) - covers msvc-go-wine's own source only. MIT (see [LICENSE.txt](LICENSE.txt)) for vintner's own source. The MSVC
The MSVC Build Tools / Windows SDK that `download` fetches remain governed Build Tools, Windows SDK, and WDK that `download` fetches stay under
by Microsoft's own license (accepted via `--accept-license`), same as with Microsoft's own license (accepted via `--accept-license`), same as with
any other way of obtaining them. any other way of obtaining them.
@@ -1,4 +1,4 @@
msvc-go-wine: skip VsDevCmd's telemetry upload. vintner: skip VsDevCmd's telemetry upload.
There's no telemetry endpoint reachable (or wanted) under Wine. Rather than There's no telemetry endpoint reachable (or wanted) under Wine. Rather than
touching the conditional logic below, just set the opt-out switch the touching the conditional logic below, just set the opt-out switch the
@@ -1,4 +1,4 @@
msvc-go-wine: always record the starting directory. vintner: always record the starting directory.
vsdevcmd_start.bat only saved __VSCMD_CURRENT_DIR when the caller passed vsdevcmd_start.bat only saved __VSCMD_CURRENT_DIR when the caller passed
-startdir=none, which doesn't hold up under Wine's cmd.exe (see -startdir=none, which doesn't hold up under Wine's cmd.exe (see
@@ -1,4 +1,4 @@
msvc-go-wine: find the Windows SDK/UCRT dirs on disk before trying the registry. vintner: find the Windows SDK/UCRT dirs on disk before trying the registry.
winsdk.bat locates the Windows 10/11 SDK and Universal CRT SDK by querying winsdk.bat locates the Windows 10/11 SDK and Universal CRT SDK by querying
the Windows Registry, which doesn't have real SDK installation entries the Windows Registry, which doesn't have real SDK installation entries
@@ -1,4 +1,4 @@
msvc-go-wine: don't hard-fail devcmd setup when ConnectionManagerExe is missing. vintner: don't hard-fail devcmd setup when ConnectionManagerExe is missing.
Not every MSVC/WinSDK download selection includes the Linux Not every MSVC/WinSDK download selection includes the Linux
ConnectionManagerExe component; a missing optional component shouldn't ConnectionManagerExe component; a missing optional component shouldn't
@@ -1,4 +1,4 @@
msvc-go-wine: don't hard-fail devcmd setup when the bundled CMake/Ninja is missing. vintner: don't hard-fail devcmd setup when the bundled CMake/Ninja is missing.
Same reasoning as the ConnectionManagerExe fix: a missing optional Same reasoning as the ConnectionManagerExe fix: a missing optional
component shouldn't abort the whole Developer Command Prompt setup. component shouldn't abort the whole Developer Command Prompt setup.
@@ -1,5 +1,5 @@
<!-- <!--
msvc-go-wine: make MSBuild resolve the Windows SDK / UCRT / preferred tool vintner: make MSBuild resolve the Windows SDK / UCRT / preferred tool
architecture from the VS install layout directly, since there's no architecture from the VS install layout directly, since there's no
Windows Registry under Wine for the stock .props files to query. Windows Registry under Wine for the stock .props files to query.
@@ -1,8 +1,8 @@
msvc-go-wine: let MSBuild find the Windows SDK without the registry. vintner: let MSBuild find the Windows SDK without the registry.
GetLatestSDKTargetPlatformVersion normally resolves the SDK via the GetLatestSDKTargetPlatformVersion normally resolves the SDK via the
registry, which has nothing to find under Wine. Pass it an extra list of registry, which has nothing to find under Wine. Pass it an extra list of
roots (populated by msvc-go-wine.props, see the ImportBefore directory next roots (populated by vintner.props, see the ImportBefore directory next
to this file) so it can also look directly under the VS install root. to this file) so it can also look directly under the VS install root.
diff --git a/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props b/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props diff --git a/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props b/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props
+11 -3
View File
@@ -1,5 +1,5 @@
// toolrelay.exe - a thin native launcher for a Wine-hosted MSVC tool // toolrelay.exe - a thin native launcher for a Wine-hosted MSVC tool
// invocation, built and used by msvc-go-wine (see internal/install's // invocation, built and used by vintner (see internal/install's
// buildToolRelay and internal/wrapper's runViaToolRelay). // buildToolRelay and internal/wrapper's runViaToolRelay).
// //
// It exists to solve two problems that are only solvable from inside a // It exists to solve two problems that are only solvable from inside a
@@ -79,8 +79,16 @@ HANDLE MakeKillOnCloseJob() {
} }
bool IsMtExe(const wchar_t *path) { bool IsMtExe(const wchar_t *path) {
const wchar_t *name = wcsrchr(path, L'\\'); // vintner passes toolExePath straight through from Go's filepath.Join,
name = name ? name + 1 : path; // which uses forward slashes even for a path that's about to be handed
// to a native Windows process - so the separator here isn't reliably
// '\\'. Check both; using whichever comes later in the string covers a
// mixed-separator path too.
const wchar_t *back = wcsrchr(path, L'\\');
const wchar_t *fwd = wcsrchr(path, L'/');
const wchar_t *sep = back;
if (fwd && (!sep || fwd > sep)) sep = fwd;
const wchar_t *name = sep ? sep + 1 : path;
return _wcsicmp(name, L"mt.exe") == 0; return _wcsicmp(name, L"mt.exe") == 0;
} }
-216
View File
@@ -1,216 +0,0 @@
package main
import (
"bufio"
"flag"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/Cheviiot/msvc-go-wine/internal/download"
)
func runDownload(args []string) int {
fs := flag.NewFlagSet("download", flag.ContinueOnError)
dest := fs.String("dest", "", "directory to install into (default: ~/.msvc-go-wine)")
cacheDir := fs.String("cache", "", "directory to use as a persistent download cache (default: a temp dir, removed afterwards)")
major := fs.Int("major", 18, "the major VS version to download")
preview := fs.Bool("preview", false, "download the preview/insiders channel instead of release/stable")
manifestFile := fs.String("manifest", "", "use a predownloaded installer manifest file instead of fetching one")
acceptLicense := fs.Bool("accept-license", false, "don't prompt for accepting the license")
msvcVersion := fs.String("msvc-version", "", "install a specific MSVC toolchain version, e.g. 17.10")
sdkVersion := fs.String("sdk-version", "", "install a specific Windows SDK version")
hostArch := fs.String("host-arch", "", "host architecture of packages to install (x86, x64, arm64; auto-detected)")
onlyHost := fs.Bool("only-host", true, "only download packages matching the host architecture")
language := fs.String("language", "en", "preferred language code for packages available in multiple languages")
includeOptional := fs.Bool("include-optional", false, "include all optional dependencies")
skipRecommended := fs.Bool("skip-recommended", false, "don't include recommended dependencies")
onlyDownload := fs.Bool("only-download", false, "stop after downloading package files")
onlyUnpack := fs.Bool("only-unpack", false, "unpack selected packages and keep everything, without pruning to just the CLI tools")
keepUnpack := fs.Bool("keep-unpack", false, "keep the scratch unpack dir instead of removing it after moving files into place")
skipPatch := fs.Bool("skip-patch", false, "don't apply the Wine compatibility patches")
var archsFlag stringList
fs.Var(&archsFlag, "architecture", "target architecture to include (x86, x64, arm, arm64, host); repeatable")
var ignoreFlag stringList
fs.Var(&ignoreFlag, "ignore", "package id to skip; repeatable")
if err := fs.Parse(args); err != nil {
return 2
}
packages := fs.Args()
opts := &download.Options{
Package: packages,
Ignore: []string(ignoreFlag),
Architecture: []string(archsFlag),
HostArch: *hostArch,
OnlyHost: *onlyHost,
MSVCVersion: *msvcVersion,
SDKVersion: *sdkVersion,
IncludeOptional: *includeOptional,
SkipRecommended: *skipRecommended,
Language: *language,
}
manifestURL := *manifestFile
if manifestURL == "" {
url, err := download.FetchChannelManifest(*major, *preview)
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
manifestURL = url
} else {
manifestURL = "file:" + manifestURL
}
manifest, err := download.FetchInstallerManifest(manifestURL)
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
if opts.HostArch == "" {
opts.HostArch = detectHostArch()
}
fmt.Println("Install packages for", opts.HostArch, "host architecture")
idx := download.BuildIndex(manifest, opts.HostArch, opts.Language)
if !*acceptLicense {
license := "the Visual Studio Build Tools license"
if p := idx.Find("Microsoft.VisualStudio.Product.BuildTools", nil); p != nil && len(p.LocalizedResources) > 0 {
license = p.LocalizedResources[0].License
}
if !promptAcceptLicense(license) {
return 0
}
}
if err := download.ResolveSelection(opts, idx); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
selected, err := download.ExpandSelection(idx, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
var downloadSize, installSize int64
for _, p := range selected {
downloadSize += p.DownloadSize()
installSize += p.InstalledSize()
}
fmt.Printf("Selected %d packages, for a total download size of %s, install size of %s\n",
len(selected), download.HumanizeBytes(downloadSize), download.HumanizeBytes(installSize))
cache := *cacheDir
removeCache := false
if cache == "" {
tmp, err := os.MkdirTemp("", "msvc-go-wine-cache-")
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
cache = tmp
removeCache = true
}
if removeCache {
defer os.RemoveAll(cache)
}
if !*onlyDownload && *dest == "" {
def, err := defaultToolchainDir()
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
*dest = def
fmt.Println("--dest not set, using default:", *dest)
}
if err := download.FetchPayloads(selected, cache, *onlyDownload); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
if *onlyDownload {
return 0
}
destAbs, err := filepath.Abs(*dest)
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
unpack := destAbs
if !*onlyUnpack {
unpack = filepath.Join(destAbs, "unpack")
}
if err := download.UnpackSelectedPackages(selected, cache, unpack); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
// Wine doesn't honor .exe.config <dependentAssembly> redirects, so copy
// MSBuild's redirected assemblies next to it directly.
for _, hostArch := range []string{"amd64", "arm64"} {
msbuildExe := filepath.Join(unpack, "MSBuild", "Current", "Bin", hostArch, "MSBuild.exe")
if err := download.CopyRedirectedAssemblies(msbuildExe); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
}
if !*onlyUnpack {
if err := download.RelocateBuildTools(unpack, destAbs); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
if !*keepUnpack {
os.RemoveAll(unpack)
}
if !*skipPatch && *major == 18 {
if err := download.ApplyCompatibilityFixes(destAbs); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err)
return 1
}
}
}
fmt.Println("Done. Next: msvc-go-wine install", destAbs)
return 0
}
func detectHostArch() string {
if runtime.GOARCH == "arm64" {
return "arm64"
}
return "x64"
}
func promptAcceptLicense(license string) bool {
fmt.Printf("Do you accept the license at %s (yes/no)? ", license)
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
switch scanner.Text() {
case "yes":
return true
case "no":
return false
}
fmt.Print("Do you accept the license? Answer \"yes\" or \"no\": ")
}
return false
}
// stringList implements flag.Value to collect a repeatable string flag.
type stringList []string
func (s *stringList) String() string { return fmt.Sprint([]string(*s)) }
func (s *stringList) Set(v string) error {
*s = append(*s, v)
return nil
}
-77
View File
@@ -1,77 +0,0 @@
// Command msvc-go-wine cross compiles with the real MSVC toolchain on Linux
// via Wine. It's a multi-call binary that behaves as `cl`, `link`, `lib`,
// `rc`, `midl`, `mt`, `dumpbin`, `msbuild`, etc. when invoked under one of
// those names (via symlinks set up by `msvc-go-wine install`), and
// otherwise exposes the `download`/`install`/`env`/`version` management
// subcommands.
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/Cheviiot/msvc-go-wine/internal/wrapper"
)
// version is set at build time via -ldflags "-X main.version=X.Y.Z";
// left as "dev" for plain `go build`/`go run`.
var version = "dev"
func main() {
base := filepath.Base(os.Args[0])
name := strings.TrimSuffix(strings.ToLower(base), ".exe")
if _, ok := wrapper.Tools[name]; ok {
os.Exit(wrapper.Run(name, os.Args[1:]))
}
if name == "cmd" || name == "findstr" {
os.Exit(wrapper.Run(name, os.Args[1:]))
}
os.Exit(runCLI(os.Args[1:]))
}
func runCLI(args []string) int {
if len(args) == 0 {
printUsage()
return 1
}
switch args[0] {
case "download":
return runDownload(args[1:])
case "install":
return runInstall(args[1:])
case "env":
return runEnv(args[1:])
case "version":
fmt.Println("msvc-go-wine " + version)
return 0
case "-h", "--help", "help":
printUsage()
return 0
default:
fmt.Fprintf(os.Stderr, "msvc-go-wine: unknown subcommand %q\n\n", args[0])
printUsage()
return 1
}
}
func printUsage() {
fmt.Fprint(os.Stderr, `msvc-go-wine - cross compile with MSVC on Linux via Wine
Usage:
msvc-go-wine download --accept-license [--dest <dir>] [options]
fetch and unpack MSVC/WinSDK
msvc-go-wine install [dir] wire up wrappers for a downloaded MSVC
msvc-go-wine env --bin <dir/bin/arch> print INCLUDE/LIB for native clang-cl/lld-link use
msvc-go-wine version print the version
--dest/[dir] default to ~/.msvc-go-wine if omitted.
Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
`)
}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"fmt"
"strings"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// runCompletion prints a shell completion script for shell ("bash" or
// "zsh") to stdout, meant to be sourced directly:
//
// source <(vintner completion bash) # or add to ~/.bashrc
// source <(vintner completion zsh) # or add to ~/.zshrc
//
// The flag lists below are hand-maintained alongside download.go/env.go's
// flag.FlagSet definitions rather than generated from them - there's no
// reflection-friendly registry to walk, and the flag set rarely changes.
// The tool name list isn't hand-maintained, though (see wrapper.ToolNames):
// a hand-copied one already went stale once already for a flag
// (--with-dxsdk missing from here after being added to download.go), and a
// list of every wrapped tool has more entries and changes for the same
// reasons the flag lists do, so it's worth generating for real.
func runCompletion(args []string) int {
if len(args) != 1 {
fmt.Println("usage: vintner completion bash|zsh")
return 1
}
switch args[0] {
case "bash":
fmt.Print(bashCompletionScript())
return 0
case "zsh":
fmt.Print(zshCompletionScript())
return 0
default:
fmt.Printf("vintner completion: unsupported shell %q (want bash or zsh)\n", args[0])
return 1
}
}
const downloadFlags = "--dest --cache --major --preview --manifest --accept-license " +
"--msvc-version --sdk-version --host-arch --only-host --language " +
"--include-optional --skip-recommended --only-download --only-unpack " +
"--keep-unpack --skip-patch --list-workloads --list-components " +
"--print-deps-tree --with-wdk --with-dxsdk --architecture --ignore -h --help"
// subcommandNames lists vintner's own management subcommands (long form
// plus every short alias) - unlike the wrapped-tool names, these really are
// fixed enough to hand-maintain: adding one is rare and always touches
// main.go's dispatch switch right next to this file anyway.
const subcommandNames = "download dl install i env e version v doctor help h completion"
func bashCompletionScript() string {
return `# vintner bash completion - eval "$(vintner completion bash)"
_vintner_complete() {
local cur cmd
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
cmd="${COMP_WORDS[1]}"
if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=($(compgen -W "` + subcommandNames + ` ` + strings.Join(wrapper.ToolNames(), " ") + `" -- "$cur"))
return 0
fi
case "$cmd" in
download|dl)
COMPREPLY=($(compgen -W "` + downloadFlags + `" -- "$cur"))
;;
install|i)
COMPREPLY=($(compgen -d -- "$cur"))
;;
env|e)
COMPREPLY=($(compgen -W "--bin -h --help" -- "$cur"))
;;
completion)
COMPREPLY=($(compgen -W "bash zsh" -- "$cur"))
;;
esac
return 0
}
complete -F _vintner_complete vintner
`
}
func zshCompletionScript() string {
var toolEntries strings.Builder
for _, name := range wrapper.ToolNames() {
fmt.Fprintf(&toolEntries, " %q\n", name+":run this tool directly, e.g. \"vintner "+name+" ...\"")
}
return `#compdef vintner
# vintner zsh completion - source <(vintner completion zsh)
_vintner() {
local -a subcommands
subcommands=(
'download:fetch and unpack MSVC/WinSDK/WDK'
'dl:alias for download'
'install:wire up wrappers for a downloaded MSVC'
'i:alias for install'
'env:print INCLUDE/LIB for native clang-cl/lld-link use'
'e:alias for env'
'version:print the version'
'v:alias for version'
'doctor:check wine/toolchain setup'
'help:print usage'
'h:alias for help'
'completion:print a shell completion script'
` + toolEntries.String() + ` )
if (( CURRENT == 2 )); then
_describe 'command' subcommands
return
fi
case "${words[2]}" in
download|dl)
local -a flags
flags=(
'--dest[directory to install into]:directory:_files -/'
'--cache[persistent download cache directory]:directory:_files -/'
'--major[major VS version]:version:'
'--preview[use the preview/insiders channel]'
'--manifest[use a predownloaded installer manifest file]:file:_files'
'--accept-license[do not prompt for accepting the license]'
'--msvc-version[install a specific MSVC toolchain version]:version:'
'--sdk-version[install a specific Windows SDK version]:version:'
'--host-arch[host architecture]:arch:(x86 x64 arm64)'
'--only-host[only download packages matching the host architecture]'
'--language[preferred package language]:language:'
'--include-optional[include all optional dependencies]'
'--skip-recommended[skip recommended dependencies]'
'--only-download[stop after downloading package files]'
'--only-unpack[unpack without pruning to just the CLI tools]'
'--keep-unpack[keep the scratch unpack dir]'
'--skip-patch[do not apply the Wine compatibility patches]'
'--list-workloads[list available workloads and exit]'
'--list-components[list available components and exit]'
'--print-deps-tree[print the dependency tree and exit]'
'--with-wdk[also fetch the Windows Driver Kit]'
'--with-dxsdk[also fetch the DirectX SDK]'
'--architecture[target architecture]:arch:(x86 x64 arm arm64 host)'
'--ignore[package id to skip]:package id:'
'-h[show help]'
'--help[show help]'
)
_arguments $flags
;;
install|i)
_files -/
;;
env|e)
_arguments \
'--bin[bin/<arch> directory produced by install]:directory:_files -/' \
'-h[show help]' '--help[show help]'
;;
completion)
_values 'shell' bash zsh
;;
esac
}
_vintner "$@"
`
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"os/exec"
"strings"
"testing"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// TestCompletionScriptsAreSyntacticallyValid catches the easy way to break
// these: a typo in the hand-maintained flag lists that produces invalid
// shell syntax. It shells out to bash/zsh -n rather than parsing the script
// itself, so it's testing exactly what a user's shell would see.
func TestCompletionScriptsAreSyntacticallyValid(t *testing.T) {
for _, tc := range []struct {
shell string
script string
}{
{"bash", bashCompletionScript()},
{"zsh", zshCompletionScript()},
} {
t.Run(tc.shell, func(t *testing.T) {
if _, err := exec.LookPath(tc.shell); err != nil {
t.Skipf("%s not installed", tc.shell)
}
cmd := exec.Command(tc.shell, "-n", "/dev/stdin")
cmd.Stdin = strings.NewReader(tc.script)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s -n rejected the completion script: %v\n%s", tc.shell, err, out)
}
})
}
}
// TestCompletionScriptsListEveryTool guards against the exact staleness bug
// found and fixed alongside this test: a hand-copied tool/flag list here
// drifting from the real set in internal/wrapper (or download.go's flags)
// as tools/flags get added. Every current tool name must appear in both
// generated scripts.
func TestCompletionScriptsListEveryTool(t *testing.T) {
bash := bashCompletionScript()
zsh := zshCompletionScript()
for _, name := range wrapper.ToolNames() {
if !strings.Contains(bash, name) {
t.Errorf("bash completion script doesn't mention tool %q", name)
}
if !strings.Contains(zsh, name+":") {
t.Errorf("zsh completion script doesn't mention tool %q", name)
}
}
}
func TestRunCompletionUnknownShell(t *testing.T) {
if code := runCompletion([]string{"fish"}); code != 1 {
t.Errorf("runCompletion([\"fish\"]) = %d, want 1", code)
}
if code := runCompletion(nil); code != 1 {
t.Errorf("runCompletion(nil) = %d, want 1", code)
}
if code := runCompletion([]string{"bash", "extra"}); code != 1 {
t.Errorf("runCompletion with extra arg = %d, want 1", code)
}
}
+176
View File
@@ -0,0 +1,176 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/Cheviiot/vintner/internal/i18n"
"github.com/Cheviiot/vintner/internal/wineenv"
)
// runDoctor checks the pieces vintner actually needs at runtime - wine
// itself, the optional extraction tools download needs, and every
// installed <dest>/bin/<arch> toolchain's on-disk layout - and prints a
// pass/fail checklist. The point is surfacing a broken setup as a short,
// readable report instead of a wine-specific error buried deep inside a
// build (see internal/wineenv.FindWine's own error, which this reuses).
func runDoctor(args []string) int {
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
fmt.Fprintln(os.Stderr, i18n.T("doctor.usage"))
return 1
}
d := &doctorReport{}
d.checkWine()
d.checkExtractionTools()
d.checkToolchains()
if d.failed {
fmt.Println(i18n.T("doctor.summary_fail"))
return 1
}
fmt.Println(i18n.T("doctor.summary_ok"))
return 0
}
type doctorReport struct {
failed bool
}
func (d *doctorReport) ok(format string, args ...any) {
fmt.Printf(" [ok] "+format+"\n", args...)
}
func (d *doctorReport) warn(format string, args ...any) {
fmt.Printf(" [warn] "+format+"\n", args...)
}
func (d *doctorReport) fail(format string, args ...any) {
fmt.Printf(" [FAIL] "+format+"\n", args...)
d.failed = true
}
func (d *doctorReport) checkWine() {
fmt.Println(i18n.T("doctor.section_wine"))
wineBin, err := wineenv.FindWine()
if err != nil {
d.fail("%s", err)
return
}
d.ok("found: %s", wineBin)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, wineBin, "--version").Output()
if err != nil {
d.fail("%s --version failed: %v", wineBin, err)
return
}
d.ok("runs: %s", trimNewline(string(out)))
}
func (d *doctorReport) checkExtractionTools() {
fmt.Println(i18n.T("doctor.section_extract"))
if _, err := exec.LookPath("msiextract"); err != nil {
d.warn(i18n.T("doctor.msitools_missing"))
} else {
d.ok("msitools: found (needed by `vintner download`)")
}
if _, err := exec.LookPath("cabextract"); err != nil {
d.warn(i18n.T("doctor.cabextract_missing"))
} else {
d.ok("cabextract: found (needed by --with-wdk/--with-dxsdk)")
}
}
func (d *doctorReport) checkToolchains() {
fmt.Println(i18n.T("doctor.section_toolchain"))
if binDir := os.Getenv("VINTNER_BIN"); binDir != "" {
d.checkToolchainAt(binDir, "VINTNER_BIN="+binDir)
return
}
def, err := defaultToolchainDir()
if err != nil {
d.fail("%s", err)
return
}
destBin := filepath.Join(def, "bin")
archDirs := installedArchDirs(destBin)
if len(archDirs) == 0 {
d.fail(i18n.T("doctor.no_toolchain", destBin))
return
}
for _, arch := range archDirs {
d.checkToolchainAt(filepath.Join(destBin, arch), arch)
}
}
// installedArchDirs returns the subdirectories of destBin that carry their
// own env.json, i.e. every architecture `vintner install` actually set up
// (there can be more than one - e.g. x86 and x64 side by side).
func installedArchDirs(destBin string) []string {
entries, err := os.ReadDir(destBin)
if err != nil {
return nil
}
var dirs []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(destBin, e.Name(), wineenv.ConfigFileName)); err == nil {
dirs = append(dirs, e.Name())
}
}
return dirs
}
func (d *doctorReport) checkToolchainAt(binDir, label string) {
cfg, err := wineenv.Load(binDir)
if err != nil {
d.fail("%s: %s", label, err)
return
}
baseUnix, err := wineenv.FindBaseUnix(binDir)
if err != nil {
d.fail("%s: %s", label, err)
return
}
d.ok("%s: MSVC %s, SDK %s, root %s", label, cfg.MSVCVer, cfg.SDKVer, baseUnix)
paths := wineenv.NewPaths(cfg, baseUnix)
for _, dir := range []struct{ name, path string }{
{"MSVC bin", paths.BinDir},
{"SDK bin", paths.SDKBinDir},
{"MSBuild bin", paths.MSBuildBinDir},
} {
if fi, err := os.Stat(dir.path); err != nil || !fi.IsDir() {
d.fail("%s: %s missing: %s", label, dir.name, dir.path)
} else {
d.ok("%s: %s: %s", label, dir.name, dir.path)
}
}
relay := filepath.Join(baseUnix, "bin", "toolrelay.exe")
if fi, err := os.Stat(relay); err != nil || fi.IsDir() {
d.warn("%s: toolrelay.exe not built (mt.exe's CMake exit-code translation won't apply; re-run `vintner install` to retry)", label)
} else {
d.ok("%s: toolrelay.exe: %s", label, relay)
}
}
func trimNewline(s string) string {
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
s = s[:len(s)-1]
}
return s
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/Cheviiot/vintner/internal/wineenv"
)
func TestRunDoctorUsage(t *testing.T) {
for _, flag := range []string{"-h", "--help"} {
if code := runDoctor([]string{flag}); code != 1 {
t.Errorf("runDoctor([%q]) = %d, want 1", flag, code)
}
}
}
func TestDoctorReportOkWarnFail(t *testing.T) {
d := &doctorReport{}
d.ok("fine")
d.warn("meh")
if d.failed {
t.Fatal("ok/warn must not mark the report as failed")
}
d.fail("broken")
if !d.failed {
t.Fatal("fail must mark the report as failed")
}
}
func TestInstalledArchDirsFindsOnlyDirsWithEnvJSON(t *testing.T) {
destBin := t.TempDir()
for _, dir := range []string{"x64", "x86", "not-a-toolchain"} {
if err := os.MkdirAll(filepath.Join(destBin, dir), 0o755); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"x64", "x86"} {
if err := os.WriteFile(filepath.Join(destBin, dir, wineenv.ConfigFileName), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
}
got := installedArchDirs(destBin)
want := map[string]bool{"x64": true, "x86": true}
if len(got) != len(want) {
t.Fatalf("installedArchDirs = %v, want exactly %v", got, want)
}
for _, arch := range got {
if !want[arch] {
t.Errorf("installedArchDirs returned unexpected entry %q", arch)
}
}
}
func TestInstalledArchDirsMissingDir(t *testing.T) {
if got := installedArchDirs(filepath.Join(t.TempDir(), "does-not-exist")); got != nil {
t.Errorf("installedArchDirs on a missing dir = %v, want nil", got)
}
}
func TestCheckToolchainAtMissingEnvJSON(t *testing.T) {
d := &doctorReport{}
d.checkToolchainAt(t.TempDir(), "test")
if !d.failed {
t.Error("checkToolchainAt against a dir with no env.json should fail the report")
}
}
func TestTrimNewline(t *testing.T) {
cases := map[string]string{
"wine-9.0\n": "wine-9.0",
"wine-9.0\r\n": "wine-9.0",
"wine-9.0": "wine-9.0",
"": "",
}
for in, want := range cases {
if got := trimNewline(in); got != want {
t.Errorf("trimNewline(%q) = %q, want %q", in, got, want)
}
}
}
+340
View File
@@ -0,0 +1,340 @@
package main
import (
"bufio"
"flag"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/Cheviiot/vintner/internal/download"
"github.com/Cheviiot/vintner/internal/i18n"
"github.com/Cheviiot/vintner/internal/lock"
)
func runDownload(args []string) int {
fs := flag.NewFlagSet("download", flag.ContinueOnError)
dest := fs.String("dest", "", "directory to install into (default: ~/.vintner)")
cacheDir := fs.String("cache", "", "directory to use as a persistent download cache (default: a temp dir, removed afterwards)")
major := fs.Int("major", 18, "the major VS version to download")
preview := fs.Bool("preview", false, "download the preview/insiders channel instead of release/stable")
manifestFile := fs.String("manifest", "", "use a predownloaded installer manifest file instead of fetching one")
acceptLicense := fs.Bool("accept-license", false, "don't prompt for accepting the license")
msvcVersion := fs.String("msvc-version", "", "install a specific MSVC toolchain version, e.g. 17.10")
sdkVersion := fs.String("sdk-version", "", "install a specific Windows SDK version")
hostArch := fs.String("host-arch", "", "host architecture of packages to install (x86, x64, arm64; auto-detected)")
onlyHost := fs.Bool("only-host", true, "only download packages matching the host architecture")
language := fs.String("language", "en", "preferred language code for packages available in multiple languages")
includeOptional := fs.Bool("include-optional", false, "include all optional dependencies")
skipRecommended := fs.Bool("skip-recommended", false, "don't include recommended dependencies")
onlyDownload := fs.Bool("only-download", false, "stop after downloading package files")
onlyUnpack := fs.Bool("only-unpack", false, "unpack selected packages and keep everything, without pruning to just the CLI tools")
keepUnpack := fs.Bool("keep-unpack", false, "keep the scratch unpack dir instead of removing it after moving files into place")
skipPatch := fs.Bool("skip-patch", false, "don't apply the Wine compatibility patches")
listWorkloads := fs.Bool("list-workloads", false, "list available workloads from the manifest and exit, without downloading anything")
listComponents := fs.Bool("list-components", false, "list available components from the manifest and exit, without downloading anything")
printDepsTree := fs.Bool("print-deps-tree", false, "print the dependency tree of the selected packages and exit, without downloading anything")
withWDK := fs.Bool("with-wdk", false, "also fetch and install the Windows Driver Kit (headers, libs and MSBuild driver PlatformToolsets, for building KMDF/UMDF drivers)")
withDXSDK := fs.Bool("with-dxsdk", false, "also fetch and install the DirectX SDK (June 2010): real D3DX9/10/11, XInput and XAudio2 headers and import libs, dropped from the modern Windows SDK")
var archsFlag stringList
fs.Var(&archsFlag, "architecture", "target architecture to include (x86, x64, arm, arm64, host); repeatable")
var ignoreFlag stringList
fs.Var(&ignoreFlag, "ignore", "package id to skip; repeatable")
if err := fs.Parse(args); err != nil {
return 2
}
packages := fs.Args()
for _, a := range archsFlag {
if !validArchitectures[a] {
fmt.Fprintf(os.Stderr, "vintner download: invalid --architecture %q (expected one of x86, x64, arm, arm64, host)\n", a)
return 2
}
}
if *hostArch != "" && !validHostArchs[*hostArch] {
fmt.Fprintf(os.Stderr, "vintner download: invalid --host-arch %q (expected one of x86, x64, arm64)\n", *hostArch)
return 2
}
opts := &download.Options{
Package: packages,
Ignore: []string(ignoreFlag),
Architecture: []string(archsFlag),
HostArch: *hostArch,
OnlyHost: *onlyHost,
MSVCVersion: *msvcVersion,
SDKVersion: *sdkVersion,
IncludeOptional: *includeOptional,
SkipRecommended: *skipRecommended,
Language: *language,
WithWDK: *withWDK,
}
manifestURL := *manifestFile
if manifestURL == "" {
url, err := download.FetchChannelManifest(*major, *preview)
if err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
manifestURL = url
} else {
manifestURL = "file:" + manifestURL
}
manifest, err := download.FetchInstallerManifest(manifestURL)
if err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
if opts.HostArch == "" {
opts.HostArch = detectHostArch()
}
fmt.Println(i18n.T("download.host_arch", opts.HostArch))
idx := download.BuildIndex(manifest, opts.HostArch, opts.Language)
if *listWorkloads || *listComponents {
if *listWorkloads {
printPackageList("download.workloads_header", download.PackagesByType(idx, "Workload"), opts.Language)
}
if *listComponents {
printPackageList("download.components_header", download.PackagesByType(idx, "Component"), opts.Language)
}
return 0
}
if !*acceptLicense && !*printDepsTree {
license := "the Visual Studio Build Tools license"
if p := idx.Find("Microsoft.VisualStudio.Product.BuildTools", nil); p != nil && len(p.LocalizedResources) > 0 {
license = p.LocalizedResources[0].License
}
if !promptAcceptLicense(license) {
return 0
}
}
if err := download.ResolveSelection(opts, idx); err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
if *printDepsTree {
download.PrintDependencyTree(os.Stdout, idx, opts)
return 0
}
selected, err := download.ExpandSelection(idx, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
var downloadSize, installSize int64
for _, p := range selected {
downloadSize += p.DownloadSize()
installSize += p.InstalledSize()
}
fmt.Print(i18n.T("download.selected",
len(selected), download.HumanizeBytes(downloadSize), download.HumanizeBytes(installSize)))
cache := *cacheDir
removeCache := false
if cache == "" {
tmp, err := os.MkdirTemp("", "vintner-cache-")
if err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
cache = tmp
removeCache = true
}
if removeCache {
defer os.RemoveAll(cache)
}
if !*onlyDownload && *dest == "" {
def, err := defaultToolchainDir()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
*dest = def
fmt.Println(i18n.T("download.default_dest", *dest))
}
if err := download.FetchPayloads(selected, cache, *onlyDownload); err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
if *onlyDownload {
return 0
}
destAbs, err := filepath.Abs(*dest)
if err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
unlock, err := lock.Acquire(destAbs)
if err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
defer unlock()
unpack := destAbs
if !*onlyUnpack {
unpack = filepath.Join(destAbs, "unpack")
}
if err := download.UnpackSelectedPackages(selected, cache, unpack); err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
// Wine doesn't honor .exe.config <dependentAssembly> redirects, so copy
// MSBuild's redirected assemblies next to it directly.
for _, hostArch := range []string{"amd64", "arm64"} {
msbuildExe := filepath.Join(unpack, "MSBuild", "Current", "Bin", hostArch, "MSBuild.exe")
if err := download.CopyRedirectedAssemblies(msbuildExe); err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
}
if !*onlyUnpack {
if err := download.RelocateBuildTools(unpack, destAbs); err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
if !*keepUnpack {
os.RemoveAll(unpack)
}
if !*skipPatch && *major == 18 {
if err := download.ApplyCompatibilityFixes(destAbs); err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
}
}
if opts.WithWDK && !*onlyUnpack {
if err := downloadWDK(opts, selected, cache, destAbs, *major); err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
}
if *withDXSDK && !*onlyUnpack {
if err := downloadDXSDK(cache, destAbs); err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
}
fmt.Println(i18n.T("download.done", destAbs))
return 0
}
// downloadWDK fetches the WDK NuGet package(s) matching opts.Architecture
// (only x64 and arm64 have one - there's no WDK package for x86/arm
// targets) into destAbs/wdk/<arch>, preferring a version matching the
// Windows SDK actually selected. See wdk.go for why this is a separate
// download path from the rest of ExpandSelection/FetchPayloads/Unpack.
func downloadWDK(opts *download.Options, selected []*download.Package, cache, destAbs string, major int) error {
sdkBuild := download.SDKBuildPrefix(selected)
vsVersion := fmt.Sprintf("%d.0", major)
var archs []string
for _, a := range []string{"x64", "arm64"} {
if contains(opts.Architecture, a) {
archs = append(archs, a)
}
}
if len(archs) == 0 {
fmt.Println(i18n.T("download.wdk_skip"))
return nil
}
for _, arch := range archs {
version, err := download.FetchLatestWDKVersion(arch, sdkBuild)
if err != nil {
return err
}
wdkDir, err := download.DownloadWDK(arch, version, cache, destAbs, vsVersion)
if err != nil {
return err
}
fmt.Print(i18n.T("download.wdk_installed", arch, version, wdkDir))
}
return nil
}
// downloadDXSDK fetches and unpacks the DirectX SDK (June 2010) into
// destAbs/DXSDK. See internal/download/dxsdk.go for why this is a separate
// download path from the rest of ExpandSelection/FetchPayloads/Unpack.
func downloadDXSDK(cache, destAbs string) error {
dxsdkDir, err := download.DownloadDXSDK(cache, destAbs)
if err != nil {
return err
}
fmt.Print(i18n.T("download.dxsdk_installed", dxsdkDir))
return nil
}
func contains(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
// printPackageList prints one line per package: its ID, and (when the
// manifest carries one) its human-readable title in the requested language.
// headerKey is an i18n catalog key taking the package count as its one arg.
func printPackageList(headerKey string, pkgs []*download.Package, language string) {
fmt.Print(i18n.T(headerKey, len(pkgs)))
for _, p := range pkgs {
if lr := p.Localized(language); lr != nil && lr.Title != "" {
fmt.Printf(" %-65s %s\n", p.ID, lr.Title)
} else {
fmt.Printf(" %s\n", p.ID)
}
}
}
var validArchitectures = map[string]bool{"x86": true, "x64": true, "arm": true, "arm64": true, "host": true}
var validHostArchs = map[string]bool{"x86": true, "x64": true, "arm64": true}
func detectHostArch() string {
if runtime.GOARCH == "arm64" {
return "arm64"
}
return "x64"
}
func promptAcceptLicense(license string) bool {
fmt.Print(i18n.T("download.license_prompt", license))
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
switch scanner.Text() {
case "yes":
return true
case "no":
return false
}
fmt.Print(i18n.T("download.license_reprompt"))
}
return false
}
// stringList implements flag.Value to collect a repeatable string flag.
type stringList []string
func (s *stringList) String() string { return fmt.Sprint([]string(*s)) }
func (s *stringList) Set(v string) error {
*s = append(*s, v)
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package main
import "testing"
// TestRunDownloadRejectsInvalidArchFlags exercises the validation added
// after the flags are parsed, which must reject typos before runDownload
// gets anywhere near the network (FetchChannelManifest) - these tests would
// hang/fail on network access if that ordering ever regressed.
func TestRunDownloadRejectsInvalidArchFlags(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{"bad architecture", []string{"--architecture", "x866"}},
{"bad architecture, valid mixed with invalid", []string{"--architecture", "x64", "--architecture", "sparc"}},
{"bad host-arch", []string{"--host-arch", "sparc"}},
} {
t.Run(tc.name, func(t *testing.T) {
if code := runDownload(tc.args); code != 2 {
t.Errorf("runDownload(%v) = %d, want 2", tc.args, code)
}
})
}
}
func TestValidArchitectureSets(t *testing.T) {
for _, a := range []string{"x86", "x64", "arm", "arm64", "host"} {
if !validArchitectures[a] {
t.Errorf("validArchitectures[%q] = false, want true", a)
}
}
for _, a := range []string{"x86", "x64", "arm64"} {
if !validHostArchs[a] {
t.Errorf("validHostArchs[%q] = false, want true", a)
}
}
if validHostArchs["arm"] {
t.Error(`validHostArchs["arm"] = true, want false (no 32-bit ARM host toolchain exists)`)
}
}
@@ -6,39 +6,40 @@ import (
"os" "os"
"strings" "strings"
"github.com/Cheviiot/msvc-go-wine/internal/wineenv" "github.com/Cheviiot/vintner/internal/i18n"
"github.com/Cheviiot/vintner/internal/wineenv"
) )
// runEnv prints shell `export` statements for INCLUDE/LIB (converted from // runEnv prints shell `export` statements for INCLUDE/LIB (converted from
// wine's "z:\..." notation to plain unix paths) and TARGET_TRIPLE, for // wine's "z:\..." notation to plain unix paths) and TARGET_TRIPLE, for
// driving clang-cl/lld-link directly without Wine. // driving clang-cl/lld-link directly without Wine.
// Usage: eval "$(msvc-go-wine env --bin <dest>/bin/<arch>)" // Usage: eval "$(vintner env --bin <dest>/bin/<arch>)"
func runEnv(args []string) int { func runEnv(args []string) int {
fs := flag.NewFlagSet("env", flag.ContinueOnError) fs := flag.NewFlagSet("env", flag.ContinueOnError)
bin := fs.String("bin", "", "the <dest>/bin/<arch> directory produced by `msvc-go-wine install`") bin := fs.String("bin", "", "the <dest>/bin/<arch> directory produced by `vintner install`")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
} }
if *bin == "" { if *bin == "" {
fmt.Fprintln(os.Stderr, "usage: msvc-go-wine env --bin <dest>/bin/<arch>") fmt.Fprintln(os.Stderr, i18n.T("env.usage"))
return 1 return 1
} }
cfg, err := wineenv.Load(*bin) cfg, err := wineenv.Load(*bin)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine env:", err) fmt.Fprintln(os.Stderr, "vintner env:", err)
return 1 return 1
} }
baseUnix, err := wineenv.FindBaseUnix(*bin) baseUnix, err := wineenv.FindBaseUnix(*bin)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine env:", err) fmt.Fprintln(os.Stderr, "vintner env:", err)
return 1 return 1
} }
paths := wineenv.NewPaths(cfg, baseUnix) paths := wineenv.NewPaths(cfg, baseUnix)
triple, ok := targetTriples[cfg.Arch] triple, ok := targetTriples[cfg.Arch]
if !ok { if !ok {
fmt.Fprintf(os.Stderr, "msvc-go-wine env: unknown arch %q\n", cfg.Arch) fmt.Fprint(os.Stderr, i18n.T("env.unknown_arch", cfg.Arch))
return 1 return 1
} }
+23
View File
@@ -0,0 +1,23 @@
package main
import "testing"
func TestRunEnvRequiresBin(t *testing.T) {
if code := runEnv(nil); code != 1 {
t.Errorf("runEnv(nil) = %d, want 1", code)
}
}
func TestRunEnvRejectsMissingBinDir(t *testing.T) {
if code := runEnv([]string{"--bin", "/nonexistent/path/for/vintner/tests"}); code != 1 {
t.Errorf("runEnv with a nonexistent --bin = %d, want 1", code)
}
}
func TestToUnixPathList(t *testing.T) {
got := toUnixPathList(`z:\vc\include;z:\kits\10\include`)
want := "/vc/include;/kits/10/include"
if got != want {
t.Errorf("toUnixPathList(...) = %q, want %q", got, want)
}
}
@@ -4,12 +4,13 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/Cheviiot/msvc-go-wine/internal/install" "github.com/Cheviiot/vintner/internal/i18n"
"github.com/Cheviiot/vintner/internal/install"
) )
func runInstall(args []string) int { func runInstall(args []string) int {
if len(args) > 1 || (len(args) == 1 && (args[0] == "-h" || args[0] == "--help")) { if len(args) > 1 || (len(args) == 1 && (args[0] == "-h" || args[0] == "--help")) {
fmt.Fprintln(os.Stderr, "usage: msvc-go-wine install [dest] (default: ~/.msvc-go-wine)") fmt.Fprintln(os.Stderr, i18n.T("install.usage"))
return 1 return 1
} }
@@ -19,23 +20,23 @@ func runInstall(args []string) int {
} else { } else {
def, err := defaultToolchainDir() def, err := defaultToolchainDir()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine install:", err) fmt.Fprintln(os.Stderr, "vintner install:", err)
return 1 return 1
} }
dest = def dest = def
fmt.Println("No directory given, using default:", dest) fmt.Println(i18n.T("install.default_dir", dest))
} }
self, err := os.Executable() self, err := os.Executable()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine install:", err) fmt.Fprintln(os.Stderr, "vintner install:", err)
return 1 return 1
} }
if err := install.Install(dest, self); err != nil { if err := install.Install(dest, self); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine install:", err) fmt.Fprintln(os.Stderr, "vintner install:", err)
return 1 return 1
} }
fmt.Println("Done. Add", dest+"/bin/<arch> to PATH to use cl, link, lib, ...") fmt.Println(i18n.T("install.done", dest+"/bin/<arch>"))
return 0 return 0
} }
+17
View File
@@ -0,0 +1,17 @@
package main
import "testing"
func TestRunInstallRejectsExtraArgs(t *testing.T) {
if code := runInstall([]string{"one", "two"}); code != 1 {
t.Errorf("runInstall with two args = %d, want 1", code)
}
}
func TestRunInstallHelp(t *testing.T) {
for _, flag := range []string{"-h", "--help"} {
if code := runInstall([]string{flag}); code != 1 {
t.Errorf("runInstall([%q]) = %d, want 1", flag, code)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
// Command vintner cross compiles with the real MSVC toolchain on Linux
// via Wine. It's a multi-call binary that behaves as `cl`, `link`, `lib`,
// `rc`, `midl`, `mt`, `dumpbin`, `msbuild`, etc. when invoked under one of
// those names (via symlinks set up by `vintner install`) or as
// `vintner <tool> ...` directly (see runTool), and otherwise exposes the
// `download`/`install`/`env`/`version` management subcommands.
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/Cheviiot/vintner/internal/i18n"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// version is set at build time via -ldflags "-X main.version=X.Y.Z";
// left as "dev" for plain `go build`/`go run`.
var version = "dev"
func main() {
base := filepath.Base(os.Args[0])
name := strings.TrimSuffix(strings.ToLower(base), ".exe")
if wrapper.IsTool(name) {
os.Exit(wrapper.Run(name, os.Args[1:], ""))
}
os.Exit(runCLI(os.Args[1:]))
}
func runCLI(args []string) int {
if len(args) == 0 {
printUsage()
return 1
}
if wrapper.IsTool(args[0]) {
return runTool(args[0], args[1:])
}
switch args[0] {
case "download", "dl":
return runDownload(args[1:])
case "install", "i":
return runInstall(args[1:])
case "env", "e":
return runEnv(args[1:])
case "completion":
return runCompletion(args[1:])
case "doctor":
return runDoctor(args[1:])
case "version", "v", "--version":
fmt.Println(versionString())
return 0
case "-h", "--help", "help", "h":
printUsage()
return 0
default:
fmt.Fprint(os.Stderr, i18n.T("main.unknown_subcommand", args[0]))
printUsage()
return 1
}
}
func printUsage() {
fmt.Fprint(os.Stderr, i18n.T("main.usage"))
}
+30
View File
@@ -0,0 +1,30 @@
package main
import "testing"
// TestRunCLIDispatch covers the subset of runCLI's switch that has no side
// effects (no filesystem/network touched) - the actual subcommand bodies
// (download/install/env) get their own focused tests.
func TestRunCLIDispatch(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want int
}{
{"no args prints usage", nil, 1},
{"unknown subcommand", []string{"frobnicate"}, 1},
{"help long", []string{"--help"}, 0},
{"help short flag", []string{"-h"}, 0},
{"help word", []string{"help"}, 0},
{"help alias", []string{"h"}, 0},
{"version word", []string{"version"}, 0},
{"version alias", []string{"v"}, 0},
{"version flag", []string{"--version"}, 0},
} {
t.Run(tc.name, func(t *testing.T) {
if got := runCLI(tc.args); got != tc.want {
t.Errorf("runCLI(%v) = %d, want %d", tc.args, got, tc.want)
}
})
}
}
@@ -6,12 +6,12 @@ import (
) )
// defaultToolchainDir is where `download`/`install` operate when the user // defaultToolchainDir is where `download`/`install` operate when the user
// doesn't specify a directory: a hidden ~/.msvc-go-wine, so it doesn't // doesn't specify a directory: a hidden ~/.vintner, so it doesn't
// clutter a plain `ls ~`. // clutter a plain `ls ~`.
func defaultToolchainDir() (string, error) { func defaultToolchainDir() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(home, ".msvc-go-wine"), nil return filepath.Join(home, ".vintner"), nil
} }
+22
View File
@@ -0,0 +1,22 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultToolchainDir(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home directory available: %v", err)
}
got, err := defaultToolchainDir()
if err != nil {
t.Fatalf("defaultToolchainDir() error: %v", err)
}
want := filepath.Join(home, ".vintner")
if got != want {
t.Errorf("defaultToolchainDir() = %q, want %q", got, want)
}
}
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// runTool dispatches `vintner <tool> [args...]` (cl, link, msbuild, ...)
// directly, without needing <dest>/bin/<arch> on PATH or a same-directory
// symlink pointing back at this binary.
//
// Resolves which toolchain bin dir to use from VINTNER_BIN if set (same
// meaning as `env --bin`: point it directly at a <dest>/bin/<arch>
// directory - useful for a non-default --dest, or to pick a specific
// architecture when more than one is installed), else defaults to
// <defaultToolchainDir>/bin/<hostArch>, the layout a plain `vintner
// download && vintner install` with no --dest override produces.
func runTool(tool string, args []string) int {
binDir := os.Getenv("VINTNER_BIN")
if binDir == "" {
def, err := defaultToolchainDir()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
binDir = filepath.Join(def, "bin", detectHostArch())
}
if fi, err := os.Stat(binDir); err != nil || !fi.IsDir() {
fmt.Fprintf(os.Stderr,
"vintner: no installed toolchain found at %s\n"+
"Run `vintner download --accept-license && vintner install` first, "+
"or set VINTNER_BIN to an existing <dest>/bin/<arch> directory.\n",
binDir)
return 1
}
return wrapper.Run(tool, args, binDir)
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"path/filepath"
"testing"
)
func TestRunToolReportsMissingToolchain(t *testing.T) {
t.Setenv("VINTNER_BIN", filepath.Join(t.TempDir(), "does-not-exist"))
if code := runTool("cl", nil); code != 1 {
t.Errorf("runTool with a nonexistent VINTNER_BIN = %d, want 1", code)
}
}
func TestRunToolUsesVINTNERBinOverDefault(t *testing.T) {
// A directory that exists but has no env.json - past the "toolchain
// found at all" check, into wrapper.Run's own (already-tested)
// env.json-loading error path. Confirms VINTNER_BIN is actually being
// read and passed through, without needing a full fake toolchain.
t.Setenv("VINTNER_BIN", t.TempDir())
if code := runTool("cl", nil); code != 1 {
t.Errorf("runTool with an empty VINTNER_BIN dir = %d, want 1 (from the missing env.json)", code)
}
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"fmt"
"runtime/debug"
)
// versionString renders "vintner <version>" plus, when available, the git
// commit and build time Go's toolchain embeds automatically (since Go
// 1.18, `go build` stamps vcs.revision/vcs.time/vcs.modified into the
// binary on its own - no -ldflags needed for this part, so it works the
// same whether the binary came from CI or a plain local `go build`).
// Knowing the exact commit a reported bug was built from, not just the
// X.Y.Z tag, is the point: two builds of the same tag could still differ
// if the tag was ever moved, or if someone built from an uncommitted tree.
//
// Note for anyone testing this: `go build` stamps vcs.* build settings,
// but `go test` binaries don't get them - there's no environment where a
// `go test` run can exercise the revision-formatting branch below, hence
// formatVersion is split out and tested directly instead.
func versionString() string {
revision, buildTime, dirty := "", "", false
if info, ok := debug.ReadBuildInfo(); ok {
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
revision = s.Value
case "vcs.time":
buildTime = s.Value
case "vcs.modified":
dirty = s.Value == "true"
}
}
}
return formatVersion(version, revision, buildTime, dirty)
}
// formatVersion is the pure part of versionString: given a revision (full
// git SHA, may be empty), it's shortened to 12 chars and marked "-dirty" if
// the build tree had uncommitted changes.
func formatVersion(ver, revision, buildTime string, dirty bool) string {
v := "vintner " + ver
if revision == "" {
return v
}
if len(revision) > 12 {
revision = revision[:12]
}
if dirty {
revision += "-dirty"
}
if buildTime != "" {
return fmt.Sprintf("%s (%s, %s)", v, revision, buildTime)
}
return fmt.Sprintf("%s (%s)", v, revision)
}
+46
View File
@@ -0,0 +1,46 @@
package main
import "testing"
func TestFormatVersion(t *testing.T) {
for _, tc := range []struct {
name string
ver, revision, buildTime string
dirty bool
want string
}{
{
name: "no VCS info at all",
ver: "dev",
want: "vintner dev",
},
{
name: "clean build with full info",
ver: "0.3.0",
revision: "6186837fd23616335ba8aff830801692a756799c",
buildTime: "2026-07-25T01:33:41Z",
want: "vintner 0.3.0 (6186837fd236, 2026-07-25T01:33:41Z)",
},
{
name: "dirty tree",
ver: "0.3.0",
revision: "6186837fd23616335ba8aff830801692a756799c",
dirty: true,
want: "vintner 0.3.0 (6186837fd236-dirty)",
},
{
name: "short revision left untouched",
ver: "dev",
revision: "abc123",
want: "vintner dev (abc123)",
},
} {
t.Run(tc.name, func(t *testing.T) {
got := formatVersion(tc.ver, tc.revision, tc.buildTime, tc.dirty)
if got != tc.want {
t.Errorf("formatVersion(%q, %q, %q, %v) = %q, want %q",
tc.ver, tc.revision, tc.buildTime, tc.dirty, got, tc.want)
}
})
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Cheviiot/msvc-go-wine module github.com/Cheviiot/vintner
go 1.23 go 1.23
+94
View File
@@ -0,0 +1,94 @@
package download
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// The DirectX SDK (June 2010) is the last standalone release of D3DX9 (and
// D3DX10/11, XInput, XAudio2, ...). Microsoft never carried D3DX forward
// into the Windows 10/11 SDK - it's deprecated in favor of D3DCompiler/
// WICTextureLoader/DirectXTex, but plenty of legacy code (this included)
// still links against the real d3dx9.h/d3dx9.lib. Like the WDK (see
// wdk.go), it isn't part of the VS installer manifest or any package feed
// vsman knows about, so this is a separate, self-contained download path
// outside ExpandSelection/FetchPayloads/UnpackSelectedPackages.
//
// The installer is a self-extracting PE with an appended CAB archive.
// cabextract (already a vintner prerequisite - see runMsiExtract's sibling
// extractWindowsSDKPackage) unpacks it directly, without needing Wine or a
// separate archive tool. Its -F/--filter flag (repeatable) restricts
// extraction to the Include and Lib subtrees actually needed for building -
// about 21MB out of the installer's 1.2GB uncompressed payload.
const dxsdkURL = "https://download.microsoft.com/download/A/E/7/AE743F1F-632B-4809-87A9-AA1BB3458E31/DXSDK_Jun10.exe"
// dxsdkSHA256 pins the exact installer build this code was written against
// (verified by fully extracting it with both cabextract and 7z and cross
// checking the file lists) - the June 2010 DirectX SDK is a frozen legacy
// artifact Microsoft is not going to rebuild. A var, not a const, so tests
// can point it at a small fake payload instead of the real 600MB installer.
var dxsdkSHA256 = "705271dc83bfee54d9b94e028426e288d5f070784b7446d164f48ecfbb2a02cb"
// DownloadDXSDK fetches (or reuses a cached copy of) the DirectX SDK (June
// 2010) installer into cacheDir, then unpacks its Include and Lib trees -
// headers and x86/x64 import libs for D3DX9/10/11, XInput, XAudio2, and the
// rest - into destDir/DXSDK. Returns that directory.
func DownloadDXSDK(cacheDir, destDir string) (string, error) {
if _, err := exec.LookPath("cabextract"); err != nil {
return "", fmt.Errorf("cabextract not found in PATH (install the cabextract package): %w", err)
}
cacheFile := filepath.Join(cacheDir, "DXSDK_Jun10.exe")
if !isFile(cacheFile) {
fmt.Println("Downloading DirectX SDK (June 2010)")
if err := httpDownloadFile(dxsdkURL, cacheFile); err != nil {
return "", fmt.Errorf("downloading DirectX SDK: %w", err)
}
} else {
fmt.Println("Using existing file", filepath.Base(cacheFile))
}
sum, err := sha256File(cacheFile)
if err != nil {
return "", err
}
if !equalFoldHex(sum, dxsdkSHA256) {
return "", fmt.Errorf("incorrect hash for downloaded file %s, aborting", filepath.Base(cacheFile))
}
scratch, err := os.MkdirTemp(destDir, "dxsdk-unpack-")
if err != nil {
return "", err
}
defer os.RemoveAll(scratch)
if err := runCabextract(cacheFile, scratch, "DXSDK/Include/*", "DXSDK/Lib/*"); err != nil {
return "", fmt.Errorf("extracting DirectX SDK: %w", err)
}
dxsdkDir := filepath.Join(destDir, "DXSDK")
if err := combineDirTrees(filepath.Join(scratch, "DXSDK"), dxsdkDir); err != nil {
return "", fmt.Errorf("moving DirectX SDK content into place: %w", err)
}
return dxsdkDir, nil
}
// runCabextract extracts srcFile into destDir, restricted to entries
// matching any of patterns (cabextract's -F, repeatable, glob-matched
// against the full in-archive path).
func runCabextract(srcFile, destDir string, patterns ...string) error {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return err
}
args := []string{"-q", "-d", destDir}
for _, p := range patterns {
args = append(args, "-F", p)
}
args = append(args, srcFile)
cmd := exec.Command("cabextract", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+111
View File
@@ -0,0 +1,111 @@
package download
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// withFakeCabextract prepends a directory containing a fake "cabextract"
// script to PATH, so tests can exercise runCabextract/DownloadDXSDK without
// the real tool (or a real DXSDK installer) present. The fake script
// records the arguments it was invoked with to argsFile and creates an
// empty DXSDK/Include and DXSDK/Lib under whatever -d directory it was
// given, mimicking a successful (if empty) extraction.
func withFakeCabextract(t *testing.T, argsFile string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("fake cabextract script requires a POSIX shell")
}
bin := t.TempDir()
script := `#!/bin/sh
echo "$@" > "` + argsFile + `"
dest=""
prev=""
for a in "$@"; do
if [ "$prev" = "-d" ]; then
dest="$a"
fi
prev="$a"
done
mkdir -p "$dest/DXSDK/Include" "$dest/DXSDK/Lib"
`
path := filepath.Join(bin, "cabextract")
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+":"+os.Getenv("PATH"))
}
func TestRunCabextractPassesFilterArgs(t *testing.T) {
dest := t.TempDir()
argsFile := filepath.Join(t.TempDir(), "args")
withFakeCabextract(t, argsFile)
src := filepath.Join(t.TempDir(), "installer.exe")
writeFile(t, src, "fake-installer-bytes")
if err := runCabextract(src, dest, "DXSDK/Include/*", "DXSDK/Lib/*"); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("expected the fake cabextract to have run: %v", err)
}
want := "-q -d " + dest + " -F DXSDK/Include/* -F DXSDK/Lib/* " + src + "\n"
if string(got) != want {
t.Errorf("cabextract args = %q, want %q", got, want)
}
}
func TestDownloadDXSDKReusesCachedFileAndRejectsHashMismatch(t *testing.T) {
argsFile := filepath.Join(t.TempDir(), "args")
withFakeCabextract(t, argsFile)
cacheDir := t.TempDir()
destDir := t.TempDir()
writeFile(t, filepath.Join(cacheDir, "DXSDK_Jun10.exe"), "not the real installer")
_, err := DownloadDXSDK(cacheDir, destDir)
if err == nil {
t.Fatal("expected an error for a cached file that doesn't match dxsdkSHA256")
}
if _, statErr := os.Stat(argsFile); statErr == nil {
t.Error("cabextract should not have run before the hash was verified")
}
}
func TestDownloadDXSDKMovesExtractedContentIntoPlace(t *testing.T) {
argsFile := filepath.Join(t.TempDir(), "args")
withFakeCabextract(t, argsFile)
cacheDir := t.TempDir()
destDir := t.TempDir()
cacheFile := filepath.Join(cacheDir, "DXSDK_Jun10.exe")
writeFile(t, cacheFile, "fake-installer-bytes-for-hash-test")
// Patch the expected hash to match our fake cached file, since we can't
// (and shouldn't) fetch or embed the real 600MB installer in a test.
sum, err := sha256File(cacheFile)
if err != nil {
t.Fatal(err)
}
restore := dxsdkSHA256
dxsdkSHA256 = sum
defer func() { dxsdkSHA256 = restore }()
dxsdkDir, err := DownloadDXSDK(cacheDir, destDir)
if err != nil {
t.Fatal(err)
}
if dxsdkDir != filepath.Join(destDir, "DXSDK") {
t.Errorf("DownloadDXSDK returned %q, want %q", dxsdkDir, filepath.Join(destDir, "DXSDK"))
}
for _, sub := range []string{"Include", "Lib"} {
if !isDir(filepath.Join(dxsdkDir, sub)) {
t.Errorf("expected %s/%s to exist after extraction", dxsdkDir, sub)
}
}
}
+152
View File
@@ -0,0 +1,152 @@
package download
import (
"os"
"path/filepath"
"testing"
)
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestCombineDirTreesNonexistentSrcIsNoop(t *testing.T) {
dest := t.TempDir()
if err := combineDirTrees(filepath.Join(t.TempDir(), "does-not-exist"), dest); err != nil {
t.Fatalf("combineDirTrees with a nonexistent src returned an error: %v", err)
}
}
func TestCombineDirTreesRenamesWholesaleWhenDestMissing(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "src")
dest := filepath.Join(root, "nested", "dest")
writeFile(t, filepath.Join(src, "file.txt"), "hello")
if err := combineDirTrees(src, dest); err != nil {
t.Fatal(err)
}
if _, err := os.ReadFile(filepath.Join(dest, "file.txt")); err != nil {
t.Errorf("expected %s/file.txt to exist after combine: %v", dest, err)
}
if isDir(src) {
t.Error("src should have been moved (renamed), not copied")
}
}
func TestCombineDirTreesMergesNewSubdir(t *testing.T) {
root := t.TempDir()
src, dest := filepath.Join(root, "src"), filepath.Join(root, "dest")
writeFile(t, filepath.Join(src, "NewDir", "a.txt"), "a")
writeFile(t, filepath.Join(dest, "Existing.txt"), "keep me")
if err := combineDirTrees(src, dest); err != nil {
t.Fatal(err)
}
if _, err := os.ReadFile(filepath.Join(dest, "NewDir", "a.txt")); err != nil {
t.Errorf("expected merged NewDir/a.txt: %v", err)
}
if _, err := os.ReadFile(filepath.Join(dest, "Existing.txt")); err != nil {
t.Errorf("pre-existing dest file was lost: %v", err)
}
}
func TestCombineDirTreesMergesCaseInsensitiveCollision(t *testing.T) {
root := t.TempDir()
src, dest := filepath.Join(root, "src"), filepath.Join(root, "dest")
// src has "Include" (capital I), dest already has "include" (lowercase) -
// this is exactly the MSVC/WinSDK casing-inconsistency scenario the
// function's doc comment describes.
writeFile(t, filepath.Join(src, "Include", "new.h"), "new")
writeFile(t, filepath.Join(dest, "include", "old.h"), "old")
if err := combineDirTrees(src, dest); err != nil {
t.Fatal(err)
}
if _, err := os.ReadFile(filepath.Join(dest, "include", "new.h")); err != nil {
t.Errorf("new.h should have merged into the existing lowercase 'include' dir: %v", err)
}
if _, err := os.ReadFile(filepath.Join(dest, "include", "old.h")); err != nil {
t.Errorf("old.h should still be there: %v", err)
}
if isDir(filepath.Join(dest, "Include")) {
t.Error("a separate capital-I 'Include' dir should not have been created")
}
}
func TestCombineDirTreesRecursesIntoExactNameMatch(t *testing.T) {
root := t.TempDir()
src, dest := filepath.Join(root, "src"), filepath.Join(root, "dest")
writeFile(t, filepath.Join(src, "lib", "x64", "new.lib"), "new")
writeFile(t, filepath.Join(dest, "lib", "x64", "old.lib"), "old")
if err := combineDirTrees(src, dest); err != nil {
t.Fatal(err)
}
for _, f := range []string{"new.lib", "old.lib"} {
if _, err := os.ReadFile(filepath.Join(dest, "lib", "x64", f)); err != nil {
t.Errorf("expected lib/x64/%s to survive the merge: %v", f, err)
}
}
}
func TestCopyRedirectedAssembliesNoConfigIsNoop(t *testing.T) {
dir := t.TempDir()
app := filepath.Join(dir, "MSBuild.exe")
if err := CopyRedirectedAssemblies(app); err != nil {
t.Fatalf("with no .config file present, expected no error, got: %v", err)
}
}
func TestCopyRedirectedAssembliesCopiesReferencedDLL(t *testing.T) {
dir := t.TempDir()
app := filepath.Join(dir, "MSBuild.exe")
writeFile(t, app+".config", `<?xml version="1.0"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<codeBase version="1.0.0.0" href="amd64\Some.Assembly.dll"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>`)
writeFile(t, filepath.Join(dir, "amd64", "Some.Assembly.dll"), "binary-content")
if err := CopyRedirectedAssemblies(app); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(dir, "Some.Assembly.dll"))
if err != nil {
t.Fatalf("expected Some.Assembly.dll copied next to MSBuild.exe: %v", err)
}
if string(got) != "binary-content" {
t.Errorf("copied file content = %q, want %q", got, "binary-content")
}
}
func TestCopyRedirectedAssembliesSkipsMissingTarget(t *testing.T) {
dir := t.TempDir()
app := filepath.Join(dir, "MSBuild.exe")
writeFile(t, app+".config", `<?xml version="1.0"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<codeBase href="nowhere\Missing.dll"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>`)
if err := CopyRedirectedAssemblies(app); err != nil {
t.Fatalf("a redirect pointing at a nonexistent file should be silently skipped, got: %v", err)
}
}
+56 -6
View File
@@ -84,6 +84,9 @@ func FetchPayloads(selected []*Package, cacheDir string, allowHashMismatch bool)
func fetchOnePayloadWithRetries(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) { func fetchOnePayloadWithRetries(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) {
var lastErr error var lastErr error
for attempt := 0; attempt < maxDownloadAttempts; attempt++ { for attempt := 0; attempt < maxDownloadAttempts; attempt++ {
if attempt > 0 {
time.Sleep(retryBackoff(attempt))
}
n, err := tryDownloadPayload(payload, dest, fileID, allowHashMismatch) n, err := tryDownloadPayload(payload, dest, fileID, allowHashMismatch)
if err == nil { if err == nil {
return n, nil return n, nil
@@ -94,6 +97,17 @@ func fetchOnePayloadWithRetries(payload Payload, dest, fileID string, allowHashM
return 0, fmt.Errorf("giving up on %s after %d attempts: %w", fileID, maxDownloadAttempts, lastErr) return 0, fmt.Errorf("giving up on %s after %d attempts: %w", fileID, maxDownloadAttempts, lastErr)
} }
// retryBackoff gives a transient failure (network blip, momentary rate
// limiting) a little room to clear before hammering the same URL again:
// 1s, 2s, 4s, 8s, capped at 10s.
func retryBackoff(attempt int) time.Duration {
d := time.Second << uint(attempt-1)
if d > 10*time.Second {
d = 10 * time.Second
}
return d
}
func tryDownloadPayload(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) { func tryDownloadPayload(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) {
if fi, err := os.Stat(dest); err == nil && fi.Mode().IsRegular() { if fi, err := os.Stat(dest); err == nil && fi.Mode().IsRegular() {
if payload.SHA256 != "" { if payload.SHA256 != "" {
@@ -135,27 +149,63 @@ func tryDownloadPayload(payload Payload, dest, fileID string, allowHashMismatch
var downloadHTTPClient = &http.Client{Timeout: 30 * time.Minute} var downloadHTTPClient = &http.Client{Timeout: 30 * time.Minute}
// httpDownloadFile downloads url to dest via a dest+".part" temp file,
// resuming from wherever a previous attempt left off if one exists - MSVC/
// WinSDK/WDK/DXSDK payloads run into the hundreds of MB to multiple GB, so
// restarting an interrupted download from byte 0 (a dropped connection, a
// retry after this same function returned an error) wastes real time and
// bandwidth on a flaky connection. Requests a byte Range starting at the
// existing .part file's size, if any; a server that doesn't honor Range
// (responds 200 instead of 206) gets treated as sending the whole file
// again from byte 0, so the .part is truncated and started over rather
// than getting byte-0 content appended onto existing bytes.
func httpDownloadFile(url, dest string) error { func httpDownloadFile(url, dest string) error {
resp, err := downloadHTTPClient.Get(url) tmp := dest + ".part"
var offset int64
if fi, err := os.Stat(tmp); err == nil {
offset = fi.Size()
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
if offset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
}
resp, err := downloadHTTPClient.Do(req)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var out *os.File
switch resp.StatusCode {
case http.StatusOK:
// No partial-content support (or nothing to resume from): the body
// is the whole file from byte 0.
out, err = os.Create(tmp)
case http.StatusPartialContent:
out, err = os.OpenFile(tmp, os.O_WRONLY|os.O_APPEND, 0o644)
case http.StatusRequestedRangeNotSatisfiable:
// Our .part is already >= the real file size - stale or corrupt.
// Discard it; the next retry starts clean with no Range header.
os.Remove(tmp)
return fmt.Errorf("GET %s: range not satisfiable, discarding partial download and retrying from scratch", url)
default:
return fmt.Errorf("GET %s: %s", url, resp.Status) return fmt.Errorf("GET %s: %s", url, resp.Status)
} }
tmp := dest + ".part"
out, err := os.Create(tmp)
if err != nil { if err != nil {
return err return err
} }
if _, err := io.Copy(out, resp.Body); err != nil { if _, err := io.Copy(out, resp.Body); err != nil {
out.Close() out.Close()
os.Remove(tmp) // Deliberately not removing tmp here: whatever bytes made it to
// disk are exactly what the next attempt should resume from.
return err return err
} }
if err := out.Close(); err != nil { if err := out.Close(); err != nil {
os.Remove(tmp)
return err return err
} }
return os.Rename(tmp, dest) return os.Rename(tmp, dest)
+173
View File
@@ -0,0 +1,173 @@
package download
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestHTTPDownloadFileFullDownload(t *testing.T) {
const body = "the quick brown fox jumps over the lazy dog"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, body)
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("downloaded content = %q, want %q", got, body)
}
}
// rangeServer serves a fixed body and honors byte-range requests, exactly
// like a real payload host (GitHub Releases, nuget.org, etc.) would.
func rangeServer(body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rng := r.Header.Get("Range")
if rng == "" {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, body)
return
}
var start int
if _, err := fmt.Sscanf(rng, "bytes=%d-", &start); err != nil || start < 0 || start > len(body) {
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
return
}
w.Header().Set("Content-Range", "bytes "+strconv.Itoa(start)+"-"+strconv.Itoa(len(body)-1)+"/"+strconv.Itoa(len(body)))
w.WriteHeader(http.StatusPartialContent)
fmt.Fprint(w, body[start:])
}))
}
func TestHTTPDownloadFileResumesFromExistingPart(t *testing.T) {
const body = "the quick brown fox jumps over the lazy dog"
srv := rangeServer(body)
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
partial := body[:10]
if err := os.WriteFile(dest+".part", []byte(partial), 0o644); err != nil {
t.Fatal(err)
}
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("resumed download content = %q, want %q (partial %q should have been continued, not duplicated or lost)", got, body, partial)
}
}
func TestHTTPDownloadFileRestartsWhenServerIgnoresRange(t *testing.T) {
const body = "the quick brown fox jumps over the lazy dog"
// Always answers 200 with the full body, regardless of Range - some
// servers/CDNs genuinely don't support partial content.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, body)
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
// A stale/bogus .part that must NOT end up prepended to the real
// content - if httpDownloadFile appended instead of truncating here,
// the result would start with this garbage.
if err := os.WriteFile(dest+".part", []byte("GARBAGE-FROM-A-STALE-ATTEMPT"), 0o644); err != nil {
t.Fatal(err)
}
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("content = %q, want exactly %q (no leftover garbage prepended)", got, body)
}
}
func TestHTTPDownloadFileKeepsPartOnMidTransferFailure(t *testing.T) {
const fullBody = "0123456789"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, fullBody[:5])
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Simulate a dropped connection partway through by closing the
// underlying connection abruptly instead of finishing the body.
hj, ok := w.(http.Hijacker)
if !ok {
return
}
conn, _, err := hj.Hijack()
if err == nil {
conn.Close()
}
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
err := httpDownloadFile(srv.URL, dest)
if err == nil {
t.Fatal("expected an error from the truncated connection")
}
partial, err := os.ReadFile(dest + ".part")
if err != nil {
t.Fatalf("expected the .part file with the bytes received so far to survive a failed download: %v", err)
}
if !strings.HasPrefix(fullBody, string(partial)) || len(partial) == 0 {
t.Errorf(".part content = %q, want a non-empty prefix of %q", partial, fullBody)
}
}
func TestHTTPDownloadFileRangeNotSatisfiableDiscardsPart(t *testing.T) {
const body = "short"
srv := rangeServer(body)
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
// .part is already longer than the real file - triggers 416 from
// rangeServer's own bounds check.
if err := os.WriteFile(dest+".part", []byte("this partial file is way too long"), 0o644); err != nil {
t.Fatal(err)
}
if err := httpDownloadFile(srv.URL, dest); err == nil {
t.Fatal("expected an error on the first (416) attempt")
}
if _, err := os.Stat(dest + ".part"); !os.IsNotExist(err) {
t.Error("expected the stale .part to be discarded after a 416 response")
}
// The retry (a fresh caller, no Range header since .part is gone) should
// now succeed cleanly.
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatalf("retry after discarding the stale .part failed: %v", err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("content = %q, want %q", got, body)
}
}
+59
View File
@@ -0,0 +1,59 @@
package download
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestRetryBackoff(t *testing.T) {
for _, tc := range []struct {
attempt int
want time.Duration
}{
{1, 1 * time.Second},
{2, 2 * time.Second},
{3, 4 * time.Second},
{4, 8 * time.Second},
{5, 10 * time.Second}, // capped
{10, 10 * time.Second},
} {
if got := retryBackoff(tc.attempt); got != tc.want {
t.Errorf("retryBackoff(%d) = %v, want %v", tc.attempt, got, tc.want)
}
}
}
func TestSHA256File(t *testing.T) {
path := filepath.Join(t.TempDir(), "f")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
got, err := sha256File(path)
if err != nil {
t.Fatal(err)
}
// echo -n hello | sha256sum
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
if got != want {
t.Errorf("sha256File(hello) = %q, want %q", got, want)
}
}
func TestEqualFoldHex(t *testing.T) {
for _, tc := range []struct {
a, b string
want bool
}{
{"ABCDEF", "abcdef", true},
{"abc123", "ABC123", true},
{"abc123", "abc124", false},
{"abc", "abcd", false},
{"", "", true},
} {
if got := equalFoldHex(tc.a, tc.b); got != tc.want {
t.Errorf("equalFoldHex(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
}
}
}
+18
View File
@@ -0,0 +1,18 @@
package download
import "sort"
// PackagesByType returns the best (arch/language-matching) variant of every
// package in idx whose Type equals kind (e.g. "Workload" or "Component"),
// sorted by ID.
func PackagesByType(idx Index, kind string) []*Package {
var ret []*Package
for _, variants := range idx {
p := variants[0]
if p.Type == kind {
ret = append(ret, p)
}
}
sort.Slice(ret, func(i, j int) bool { return ret[i].ID < ret[j].ID })
return ret
}
+51 -2
View File
@@ -40,10 +40,14 @@ type Dependency struct {
Type string // "", "Optional" or "Recommended" Type string // "", "Optional" or "Recommended"
} }
// LocalizedResource carries the license URL shown before accepting a // LocalizedResource carries a package's human-readable title/description
// package's terms. // (shown by --list-workloads/--list-components) and the license URL shown
// before accepting a package's terms.
type LocalizedResource struct { type LocalizedResource struct {
Language string `json:"language"` Language string `json:"language"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
License string `json:"license"` License string `json:"license"`
} }
@@ -108,6 +112,39 @@ func (p *Package) Key() string {
return key return key
} }
// Localized returns p's LocalizedResource best matching language ("" means
// "en"), preferring an exact match, then any en-* entry, then whatever's
// first. Returns nil if p has no localized resources at all.
func (p *Package) Localized(language string) *LocalizedResource {
if len(p.LocalizedResources) == 0 {
return nil
}
if language == "" {
language = "en"
}
language = strings.ToLower(language)
best := &p.LocalizedResources[0]
bestScore := -1
for i := range p.LocalizedResources {
lr := &p.LocalizedResources[i]
lang := strings.ToLower(lr.Language)
score := 0
switch {
case lang == language:
score = 3
case strings.HasPrefix(lang, language+"-"):
score = 2
case strings.HasPrefix(lang, "en"):
score = 1
}
if score > bestScore {
bestScore = score
best = lr
}
}
return best
}
func (p *Package) InstalledSize() int64 { func (p *Package) InstalledSize() int64 {
var sum int64 var sum int64
for _, v := range p.InstallSizes { for _, v := range p.InstallSizes {
@@ -147,11 +184,23 @@ type Manifest struct {
// restart of `download`. // restart of `download`.
var httpClient = &http.Client{Timeout: 5 * time.Minute} var httpClient = &http.Client{Timeout: 5 * time.Minute}
func init() {
// --manifest points at a local file, fetched through this same client
// via a "file:" URL (see cmd/vintner's runDownload) - so it needs a
// registered "file" handler alongside the default http/https transport.
t := http.DefaultTransport.(*http.Transport).Clone()
t.RegisterProtocol("file", http.NewFileTransport(http.Dir("/")))
httpClient.Transport = t
}
const maxManifestAttempts = 5 const maxManifestAttempts = 5
func httpGet(url string) ([]byte, error) { func httpGet(url string) ([]byte, error) {
var lastErr error var lastErr error
for attempt := 0; attempt < maxManifestAttempts; attempt++ { for attempt := 0; attempt < maxManifestAttempts; attempt++ {
if attempt > 0 {
time.Sleep(retryBackoff(attempt))
}
data, err := tryHTTPGet(url) data, err := tryHTTPGet(url)
if err == nil { if err == nil {
return data, nil return data, nil
+134
View File
@@ -0,0 +1,134 @@
package download
import (
"encoding/json"
"testing"
)
func TestPayloadName(t *testing.T) {
for _, tc := range []struct {
fileName string
want string
}{
{"payload.msi", "payload.msi"},
{"folder/payload.msi", "payload.msi"},
{`folder\payload.msi`, "payload.msi"},
{`a\b/c\payload.msi`, "payload.msi"},
{"", ""},
} {
p := Payload{FileName: tc.fileName}
if got := p.Name(); got != tc.want {
t.Errorf("Payload{FileName: %q}.Name() = %q, want %q", tc.fileName, got, tc.want)
}
}
}
func TestPackageKey(t *testing.T) {
for _, tc := range []struct {
name string
p Package
want string
}{
{"id only", Package{ID: "Foo"}, "Foo"},
{"id+version", Package{ID: "Foo", Version: "1.0"}, "Foo-1.0"},
{
"id+version+all arches",
Package{ID: "Foo", Version: "1.0", Chip: "x64", MachineArch: "x86", ProductArch: "neutral"},
"Foo-1.0-chip.x64-machineArch.x86-productArch.neutral",
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := tc.p.Key(); got != tc.want {
t.Errorf("Key() = %q, want %q", got, tc.want)
}
})
}
}
func TestPackageLocalized(t *testing.T) {
noResources := Package{}
if got := noResources.Localized("en"); got != nil {
t.Errorf("Localized() on a package with no LocalizedResources = %v, want nil", got)
}
p := Package{LocalizedResources: []LocalizedResource{
{Language: "de-DE", Title: "Deutsch"},
{Language: "en-US", Title: "English"},
{Language: "ru-RU", Title: "Русский"},
}}
for _, tc := range []struct {
lang string
wantTitle string
}{
{"ru", "Русский"},
{"ru-RU", "Русский"},
{"en", "English"},
{"", "English"}, // "" defaults to "en"
{"fr", "English"}, // no fr variant, falls back to the en-* one
} {
t.Run("lang="+tc.lang, func(t *testing.T) {
got := p.Localized(tc.lang)
if got == nil {
t.Fatalf("Localized(%q) = nil", tc.lang)
}
if got.Title != tc.wantTitle {
t.Errorf("Localized(%q).Title = %q, want %q", tc.lang, got.Title, tc.wantTitle)
}
})
}
}
func TestPackageSizes(t *testing.T) {
p := Package{
InstallSizes: map[string]int64{"x86": 100, "x64": 200},
Payloads: []Payload{{Size: 10}, {Size: 20}, {Size: 30}},
}
if got := p.InstalledSize(); got != 300 {
t.Errorf("InstalledSize() = %d, want 300", got)
}
if got := p.DownloadSize(); got != 60 {
t.Errorf("DownloadSize() = %d, want 60", got)
}
}
func TestPackageDependenciesNormalizesBothShapes(t *testing.T) {
p := Package{DependenciesRaw: map[string]json.RawMessage{
"Bare.Version": json.RawMessage(`"1.0"`),
"Full.Object": json.RawMessage(`{"version":"2.0","type":"Optional","id":"Real.Target"}`),
"Recommended.Dep": json.RawMessage(`{"version":"3.0","type":"Recommended"}`),
}}
deps := p.Dependencies()
if d := deps["Bare.Version"]; d.Version != "1.0" || d.TargetID != "" || d.Type != "" {
t.Errorf("Bare.Version = %+v, want Version=1.0 TargetID='' Type=''", d)
}
if d := deps["Full.Object"]; d.Version != "2.0" || d.TargetID != "Real.Target" || d.Type != "Optional" {
t.Errorf("Full.Object = %+v, want Version=2.0 TargetID=Real.Target Type=Optional", d)
}
if d := deps["Recommended.Dep"]; d.Version != "3.0" || d.Type != "Recommended" {
t.Errorf("Recommended.Dep = %+v, want Version=3.0 Type=Recommended", d)
}
// Calling Dependencies() again must return the same cached map, not
// re-parse (and must not panic on the second call).
if d2 := p.Dependencies(); len(d2) != len(deps) {
t.Errorf("second Dependencies() call returned a different map: %v vs %v", d2, deps)
}
}
func TestHumanizeBytes(t *testing.T) {
for _, tc := range []struct {
size int64
want string
}{
{500, "500 bytes"},
{2048, "2.0 KB"},
{5 * 1024 * 1024, "5.0 MB"},
{2 * 1024 * 1024 * 1024, "2.0 GB"},
} {
if got := HumanizeBytes(tc.size); got != tc.want {
t.Errorf("HumanizeBytes(%d) = %q, want %q", tc.size, got, tc.want)
}
}
}
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/Cheviiot/msvc-go-wine/assets" "github.com/Cheviiot/vintner/assets"
) )
// ApplyCompatibilityFixes applies the embedded Wine compatibility patches // ApplyCompatibilityFixes applies the embedded Wine compatibility patches
@@ -63,7 +63,7 @@ func applyGitPatch(dest, embeddedPath, target string) error {
return err return err
} }
tmp, err := os.CreateTemp("", "msvc-go-wine-*.patch") tmp, err := os.CreateTemp("", "vintner-*.patch")
if err != nil { if err != nil {
return err return err
} }
+91 -4
View File
@@ -2,7 +2,9 @@ package download
import ( import (
"fmt" "fmt"
"io"
"regexp" "regexp"
"sort"
"strings" "strings"
) )
@@ -14,7 +16,6 @@ var reSDKVersion = regexp.MustCompile(`^\d+\.\d+\.\d+`)
type TriState = *bool type TriState = *bool
func on() TriState { v := true; return &v } func on() TriState { v := true; return &v }
func off() TriState { v := false; return &v }
// Options holds every flag that feeds package selection and download. // Options holds every flag that feeds package selection and download.
type Options struct { type Options struct {
@@ -41,7 +42,15 @@ type Options struct {
SkipRecommended bool SkipRecommended bool
Language string Language string
WithWDKInstallers string // WithWDK selects the PlatformToolset registration
// (Component.Microsoft.Windows.DriverKit.BuildTools) that lets MSBuild
// recognize WindowsKernelModeDriver10.0/WindowsUserModeDriver10.0
// PlatformToolsets. It's not part of the manifest-driven default
// selection - unlike everything else it depends on, it's opt-in via
// --with-wdk, since the actual driver headers/libs come from a separate
// NuGet package download handled outside ExpandSelection entirely (see
// wdk.go and cmd/vintner's runDownload).
WithWDK bool
} }
func addIfWanted(opts *Options, flag TriState, pkg string) { func addIfWanted(opts *Options, flag TriState, pkg string) {
@@ -240,7 +249,7 @@ func ResolveSelection(opts *Options, idx Index) error {
addIfWanted(opts, opts.WithDevCmd, "Microsoft.VisualStudio.VC.vcvars") addIfWanted(opts, opts.WithDevCmd, "Microsoft.VisualStudio.VC.vcvars")
addIfWanted(opts, opts.WithDevCmd, "Microsoft.VisualStudio.PackageGroup.VsDevCmd") addIfWanted(opts, opts.WithDevCmd, "Microsoft.VisualStudio.PackageGroup.VsDevCmd")
if opts.WithWDKInstallers != "" { if opts.WithWDK {
opts.Package = append(opts.Package, "Component.Microsoft.Windows.DriverKit.BuildTools") opts.Package = append(opts.Package, "Component.Microsoft.Windows.DriverKit.BuildTools")
} }
@@ -293,6 +302,7 @@ func selectSDK(opts *Options, idx Index) error {
} }
} }
if !found { if !found {
sort.Strings(versions)
return fmt.Errorf("WinSDK version %s not found (available: %s)", opts.SDKVersion, strings.Join(versions, ", ")) return fmt.Errorf("WinSDK version %s not found (available: %s)", opts.SDKVersion, strings.Join(versions, ", "))
} }
} }
@@ -337,7 +347,14 @@ func collectDependencyClosure(idx Index, included map[string]bool, target string
included[key] = true included[key] = true
ret := []*Package{p} ret := []*Package{p}
for target, dep := range p.Dependencies() { deps := p.Dependencies()
targets := make([]string, 0, len(deps))
for target := range deps {
targets = append(targets, target)
}
sort.Strings(targets)
for _, target := range targets {
dep := deps[target]
id := target id := target
if dep.TargetID != "" { if dep.TargetID != "" {
id = dep.TargetID id = dep.TargetID
@@ -367,3 +384,73 @@ func ExpandSelection(idx Index, opts *Options) ([]*Package, error) {
} }
return ret, nil return ret, nil
} }
// PrintDependencyTree writes an indented tree of opts.Package and everything
// they transitively depend on to w, applying the exact same
// arch/--ignore/Optional/Recommended filtering collectDependencyClosure
// (used by ExpandSelection) does, so what's printed matches what an actual
// download would select. A package already printed once elsewhere in the
// tree is shown again as a leaf ("(see above)") rather than re-expanded, to
// keep the output finite for packages multiple components depend on.
func PrintDependencyTree(w io.Writer, idx Index, opts *Options) {
printed := map[string]bool{}
for _, id := range opts.Package {
printDepNode(w, idx, id, nil, "", opts, 0, printed)
}
}
func printDepNode(w io.Writer, idx Index, target string, constraints map[string]string, depType string, opts *Options, depth int, printed map[string]bool) {
if contains(opts.Ignore, strings.ToLower(target)) {
return
}
indent := strings.Repeat(" ", depth)
annotation := ""
if depType != "" {
annotation = " [" + depType + "]"
}
p := idx.Find(target, constraints)
if p == nil {
fmt.Fprintf(w, "%s%s (not found)%s\n", indent, target, annotation)
return
}
if opts.OnlyHost && !HostArchCompatible(p, opts.HostArch) {
return
}
if !TargetArchCompatible(p, opts.Architecture) {
return
}
key := p.Key()
if printed[key] {
fmt.Fprintf(w, "%s%s%s (see above)\n", indent, p.ID, annotation)
return
}
printed[key] = true
fmt.Fprintf(w, "%s%s@%s%s\n", indent, p.ID, p.Version, annotation)
deps := p.Dependencies()
targets := make([]string, 0, len(deps))
for t := range deps {
targets = append(targets, t)
}
sort.Strings(targets)
for _, depTarget := range targets {
dep := deps[depTarget]
id := depTarget
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
}
printDepNode(w, idx, id, c, dep.Type, opts, depth+1, printed)
}
}
+34
View File
@@ -193,6 +193,40 @@ func TestAggregateDependsHostArchMismatchExcludes(t *testing.T) {
} }
} }
// TestExpandSelectionDeterministic guards against collectDependencyClosure
// iterating a package's dependency map directly (Go map iteration order is
// randomized per range statement, so a regression here wouldn't necessarily
// show up on the first run - looping catches it reliably in practice).
func TestExpandSelectionDeterministic(t *testing.T) {
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VisualStudio.Workload.VCTools"},
HostArch: "x86",
OnlyHost: true,
IncludeOptional: true,
}
first, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
want := idsOf(first)
for i := 0; i < 50; i++ {
got, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
gotIDs := idsOf(got)
if len(gotIDs) != len(want) {
t.Fatalf("run %d: got %v, want %v", i, gotIDs, want)
}
for j := range want {
if gotIDs[j] != want[j] {
t.Fatalf("run %d: order changed: got %v, want %v", i, gotIDs, want)
}
}
}
}
func idsOf(pkgs []*Package) []string { func idsOf(pkgs []*Package) []string {
var ids []string var ids []string
for _, p := range pkgs { for _, p := range pkgs {
+264
View File
@@ -0,0 +1,264 @@
package download
import (
"archive/zip"
"encoding/json"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
)
// The actual driver headers/libs (ntddk.h, wdf, km/um import libs) aren't
// part of the VS installer manifest at all - Component.Microsoft.Windows.
// DriverKit.BuildTools (see select.go) only registers the
// WindowsKernelModeDriver10.0/WindowsUserModeDriver10.0 PlatformToolsets in
// the MSBuild tree. The content those toolsets actually need ships as a
// separate per-architecture NuGet package, independently of the vsman
// pipeline: https://www.nuget.org/packages/Microsoft.Windows.WDK.x64 (and
// .ARM64). This file fetches and unpacks that package directly through the
// NuGet v3 flat-container API, without needing nuget.exe or a project
// restore.
const nugetFlatContainer = "https://api.nuget.org/v3-flatcontainer"
// WDKNuGetID returns the nuget.org package id providing WDK content for
// arch ("x86"/"x64" both use the x64 package - there's no 32-bit WDK
// package - "arm64" uses the ARM64 one).
func WDKNuGetID(arch string) string {
if arch == "arm64" {
return "Microsoft.Windows.WDK.ARM64"
}
return "Microsoft.Windows.WDK.x64"
}
// nugetVersionIndex is the "versions" list nuget.org's flat-container index
// returns, oldest first.
type nugetVersionIndex struct {
Versions []string `json:"versions"`
}
var reNuGetPrerelease = regexp.MustCompile(`-`)
// FetchLatestWDKVersion returns the newest stable (non-prerelease) version
// of arch's WDK NuGet package, preferring one whose version starts with
// sdkBuild (e.g. "10.0.26100", to match an already-selected Windows SDK) if
// any such version exists; otherwise it returns the newest stable version
// overall.
func FetchLatestWDKVersion(arch, sdkBuild string) (string, error) {
id := strings.ToLower(WDKNuGetID(arch))
data, err := httpGet(nugetFlatContainer + "/" + id + "/index.json")
if err != nil {
return "", fmt.Errorf("listing %s versions: %w", WDKNuGetID(arch), err)
}
var idx nugetVersionIndex
if err := json.Unmarshal(data, &idx); err != nil {
return "", fmt.Errorf("parsing %s version index: %w", WDKNuGetID(arch), err)
}
var latestMatching, latestAny string
for _, v := range idx.Versions {
if reNuGetPrerelease.MatchString(v) {
continue
}
latestAny = v
if sdkBuild != "" && strings.HasPrefix(v, sdkBuild) {
latestMatching = v
}
}
if latestMatching != "" {
return latestMatching, nil
}
if latestAny != "" {
return latestAny, nil
}
return "", fmt.Errorf("no stable release of %s found", WDKNuGetID(arch))
}
// SDKBuildPrefix extracts the "10.0.26100"-style build prefix from a
// selected Win10SDK/Win11SDK package's version (e.g. "10.0.26100.1742" ->
// "10.0.26100"), used to pick a matching WDK NuGet version. Returns "" if
// selected contains no such package.
func SDKBuildPrefix(selected []*Package) string {
for _, p := range selected {
id := strings.ToLower(p.ID)
if strings.HasPrefix(id, "win10sdk") || strings.HasPrefix(id, "win11sdk") {
parts := strings.SplitN(p.Version, ".", 4)
if len(parts) >= 3 {
return strings.Join(parts[:3], ".")
}
}
}
return ""
}
// DownloadWDK fetches (or reuses a cached copy of) arch's WDK NuGet package
// at version, then unpacks its "c" directory - headers, libs, and the
// per-SDK-build MSBuild props/targets the DriverKit.BuildTools PlatformToolset
// registration imports via $(WDKContentRoot) - into destDir/wdk/<arch>/c.
// Kept as its own self-contained tree rather than merged into the regular
// SDK dirs, matching how $(WDKContentRoot) is meant to be used.
//
// vsVersion is the installed MSBuild's own $(VisualStudioVersion) (e.g.
// "18.0"): the package's WindowsDriver.common.targets loads its custom
// build tasks from an assembly named for that property
// (Microsoft.DriverKit.Build.Tasks.$(VisualStudioVersion).dll), but the
// package only ships one built for whatever (older) VS generation it
// targeted - see duplicateVersionedBuildTaskAssemblies.
func DownloadWDK(arch, version, cacheDir, destDir, vsVersion string) (string, error) {
id := WDKNuGetID(arch)
idLower := strings.ToLower(id)
nupkgURL := fmt.Sprintf("%s/%s/%s/%s.%s.nupkg", nugetFlatContainer, idLower, version, idLower, version)
cacheFile := filepath.Join(cacheDir, fmt.Sprintf("%s-%s.nupkg", idLower, version))
if !isFile(cacheFile) {
fmt.Printf("Downloading %s %s\n", id, version)
if err := httpDownloadFile(nupkgURL, cacheFile); err != nil {
return "", fmt.Errorf("downloading %s %s: %w", id, version, err)
}
} else {
fmt.Printf("Using existing file %s\n", filepath.Base(cacheFile))
}
wdkDir := filepath.Join(destDir, "wdk", arch)
cDir := filepath.Join(wdkDir, "c")
if err := extractNuGetPackageDir(cacheFile, "c/", cDir); err != nil {
return "", fmt.Errorf("unpacking %s %s: %w", id, version, err)
}
if err := duplicateVersionedBuildTaskAssemblies(cDir, vsVersion); err != nil {
return "", fmt.Errorf("adapting %s %s to VisualStudioVersion %s: %w", id, version, vsVersion, err)
}
if err := fillMissingHostTools(cDir); err != nil {
return "", fmt.Errorf("filling in missing x86 host tools for %s %s: %w", id, version, err)
}
return wdkDir, nil
}
// fillMissingHostTools copies every file from each cDir/bin/<sdkver>/x64
// directory into its sibling .../x86 directory, adding whatever's missing
// without overwriting anything already there. The WDK NuGet package's x86
// host-tool directory only ships a handful of genuinely x86-specific files
// (Inf2Cat.exe among them); most driver build tools (stampinf.exe included)
// only ship as x64 binaries, but WindowsDriver.Common.targets hardcodes an
// x86 tool path (WDKBinRoot_x86) with no fallback. This isn't "faking" an
// x86 binary - it's just running the real x64 PE binaries from a directory
// MSBuild happens to call "x86"; Wine doesn't care what the folder is
// named, only the PE header when it execs the file, and we're always on an
// x64 host/Wine setup here.
func fillMissingHostTools(cDir string) error {
binDir := filepath.Join(cDir, "bin")
entries, err := os.ReadDir(binDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, e := range entries {
if !e.IsDir() {
continue
}
x64Dir := filepath.Join(binDir, e.Name(), "x64")
if !isDir(x64Dir) {
continue
}
x86Dir := filepath.Join(binDir, e.Name(), "x86")
if err := os.MkdirAll(x86Dir, 0o755); err != nil {
return err
}
files, err := os.ReadDir(x64Dir)
if err != nil {
return err
}
for _, f := range files {
if f.IsDir() {
continue
}
dst := filepath.Join(x86Dir, f.Name())
if isFile(dst) {
continue
}
if err := copyFile(filepath.Join(x64Dir, f.Name()), dst, 0o755); err != nil {
return err
}
}
}
return nil
}
var reVersionedDLL = regexp.MustCompile(`^(.*)\.\d+\.\d+\.dll$`)
// duplicateVersionedBuildTaskAssemblies copies every "*.<oldver>.dll" file
// under cDir/build (the driver build task assemblies, e.g.
// Microsoft.DriverKit.Build.Tasks.17.0.dll) to a sibling
// "*.<targetVSVersion>.dll" when one doesn't already exist. The WDK NuGet
// package bundles these named for whatever VS generation it was built
// against; our installed MSBuild may be newer and looks them up by its own
// $(VisualStudioVersion). The assemblies aren't VS-version-specific in
// behavior - only the file name encodes an expected caller - so serving the
// same bytes under the new name is safe.
func duplicateVersionedBuildTaskAssemblies(cDir, targetVSVersion string) error {
buildDir := filepath.Join(cDir, "build")
if !isDir(buildDir) {
return nil
}
return filepath.WalkDir(buildDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
m := reVersionedDLL.FindStringSubmatch(d.Name())
if m == nil || strings.HasSuffix(d.Name(), "."+targetVSVersion+".dll") {
return nil
}
target := filepath.Join(filepath.Dir(path), m[1]+"."+targetVSVersion+".dll")
if isFile(target) {
return nil
}
return copyFile(path, target, 0o644)
})
}
// extractNuGetPackageDir extracts every entry of nupkgFile whose name starts
// with prefix into dest, stripping prefix from each resulting path.
func extractNuGetPackageDir(nupkgFile, prefix, dest string) error {
r, err := zip.OpenReader(nupkgFile)
if err != nil {
return err
}
defer r.Close()
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
for _, f := range r.File {
name, err := url.PathUnescape(f.Name)
if err != nil {
name = f.Name
}
name = strings.ReplaceAll(name, `\`, "/")
if !strings.HasPrefix(name, prefix) {
continue
}
rel := strings.TrimPrefix(name, prefix)
if rel == "" {
continue
}
target := filepath.Join(dest, rel)
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
}
}
return nil
}
+118
View File
@@ -0,0 +1,118 @@
package download
import (
"os"
"path/filepath"
"testing"
)
func TestWDKNuGetID(t *testing.T) {
for _, tc := range []struct{ arch, want string }{
{"x64", "Microsoft.Windows.WDK.x64"},
{"x86", "Microsoft.Windows.WDK.x64"}, // no 32-bit package exists
{"arm64", "Microsoft.Windows.WDK.ARM64"},
} {
if got := WDKNuGetID(tc.arch); got != tc.want {
t.Errorf("WDKNuGetID(%q) = %q, want %q", tc.arch, got, tc.want)
}
}
}
func TestSDKBuildPrefix(t *testing.T) {
for _, tc := range []struct {
name string
selected []*Package
want string
}{
{"no SDK package", []*Package{{ID: "Something.Else"}}, ""},
{"win10sdk", []*Package{{ID: "Win10SDK_10.0.26100", Version: "10.0.26100.1742"}}, "10.0.26100"},
{"win11sdk case-insensitive id", []*Package{{ID: "WIN11SDK_10.0.22621", Version: "10.0.22621.5"}}, "10.0.22621"},
{"short version", []*Package{{ID: "Win10SDK_x", Version: "10.0"}}, ""},
} {
t.Run(tc.name, func(t *testing.T) {
if got := SDKBuildPrefix(tc.selected); got != tc.want {
t.Errorf("SDKBuildPrefix(...) = %q, want %q", got, tc.want)
}
})
}
}
func TestFillMissingHostToolsCopiesWithoutOverwriting(t *testing.T) {
cDir := t.TempDir()
writeFile(t, filepath.Join(cDir, "bin", "10.0.26100.0", "x64", "stampinf.exe"), "x64-stampinf")
writeFile(t, filepath.Join(cDir, "bin", "10.0.26100.0", "x64", "inf2cat.exe"), "x64-inf2cat")
// x86 already ships its own real inf2cat.exe - must not be clobbered.
writeFile(t, filepath.Join(cDir, "bin", "10.0.26100.0", "x86", "inf2cat.exe"), "real-x86-inf2cat")
if err := fillMissingHostTools(cDir); err != nil {
t.Fatal(err)
}
x86Dir := filepath.Join(cDir, "bin", "10.0.26100.0", "x86")
stampinf, err := os.ReadFile(filepath.Join(x86Dir, "stampinf.exe"))
if err != nil {
t.Fatalf("expected stampinf.exe to be copied into x86: %v", err)
}
if string(stampinf) != "x64-stampinf" {
t.Errorf("copied stampinf.exe content = %q, want the x64 copy's content", stampinf)
}
inf2cat, err := os.ReadFile(filepath.Join(x86Dir, "inf2cat.exe"))
if err != nil {
t.Fatal(err)
}
if string(inf2cat) != "real-x86-inf2cat" {
t.Errorf("inf2cat.exe = %q, want the original x86 file preserved (not overwritten by the x64 copy)", inf2cat)
}
}
func TestFillMissingHostToolsNoBinDirIsNoop(t *testing.T) {
if err := fillMissingHostTools(t.TempDir()); err != nil {
t.Fatalf("missing bin/ dir should be a no-op, got: %v", err)
}
}
func TestDuplicateVersionedBuildTaskAssemblies(t *testing.T) {
cDir := t.TempDir()
buildDir := filepath.Join(cDir, "build")
writeFile(t, filepath.Join(buildDir, "Microsoft.DriverKit.Build.Tasks.17.0.dll"), "task-dll-bytes")
writeFile(t, filepath.Join(buildDir, "Microsoft.DriverKit.Build.Tasks.18.0.dll"), "already-there")
writeFile(t, filepath.Join(buildDir, "unrelated.dll"), "unrelated")
if err := duplicateVersionedBuildTaskAssemblies(cDir, "18.0"); err != nil {
t.Fatal(err)
}
// Already had an 18.0 copy - must not have been overwritten.
got, err := os.ReadFile(filepath.Join(buildDir, "Microsoft.DriverKit.Build.Tasks.18.0.dll"))
if err != nil {
t.Fatal(err)
}
if string(got) != "already-there" {
t.Errorf("pre-existing 18.0 dll was overwritten: got %q", got)
}
}
func TestDuplicateVersionedBuildTaskAssembliesCreatesMissingCopy(t *testing.T) {
cDir := t.TempDir()
buildDir := filepath.Join(cDir, "build")
writeFile(t, filepath.Join(buildDir, "sub", "Foo.Bar.17.0.dll"), "bytes")
if err := duplicateVersionedBuildTaskAssemblies(cDir, "18.0"); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(buildDir, "sub", "Foo.Bar.18.0.dll"))
if err != nil {
t.Fatalf("expected a Foo.Bar.18.0.dll duplicate: %v", err)
}
if string(got) != "bytes" {
t.Errorf("duplicated dll content = %q, want %q", got, "bytes")
}
}
func TestDuplicateVersionedBuildTaskAssembliesNoBuildDirIsNoop(t *testing.T) {
if err := duplicateVersionedBuildTaskAssemblies(t.TempDir(), "18.0"); err != nil {
t.Fatalf("missing build/ dir should be a no-op, got: %v", err)
}
}
+230
View File
@@ -0,0 +1,230 @@
// Package i18n provides minimal message localization for vintner's own
// CLI chrome (usage text, progress lines, prompts). It does not localize
// error messages bubbled up from deeper packages - those stay in English,
// matching how most cross-platform dev tools keep diagnostic text technical
// regardless of UI language.
package i18n
import (
"fmt"
"os"
"strings"
)
type Lang string
const (
EN Lang = "en"
RU Lang = "ru"
)
var current = detect()
// detect picks the active language from VINTNER_LANG (checked first, so
// it always overrides the locale), falling back to the POSIX locale
// variables in their usual priority order (LC_ALL, LC_MESSAGES, LANG). Any
// value starting with "ru" (case-insensitive) selects Russian; anything else
// falls back to English.
func detect() Lang {
for _, key := range []string{"VINTNER_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
v := os.Getenv(key)
if v == "" {
continue
}
if strings.HasPrefix(strings.ToLower(v), "ru") {
return RU
}
return EN
}
return EN
}
// Current returns the active language, for callers that need to branch
// beyond a simple T() lookup.
func Current() Lang { return current }
// T looks up key in the message catalog and formats it (via fmt.Sprintf)
// with args, if any. Keys with no translation for the current language fall
// back to English; keys missing from the catalog entirely are returned
// as-is, so a missing translation degrades to a visible-but-harmless string
// rather than a panic.
func T(key string, args ...any) string {
msg := key
if entry, ok := catalog[key]; ok {
if m, ok := entry[current]; ok {
msg = m
} else {
msg = entry[EN]
}
}
if len(args) == 0 {
return msg
}
return fmt.Sprintf(msg, args...)
}
var catalog = map[string]map[Lang]string{
"main.usage": {
EN: `vintner - cross compile with MSVC on Linux via Wine
Usage:
vintner download (dl) --accept-license [--dest <dir>] [options]
fetch and unpack MSVC/WinSDK/WDK
vintner install (i) [dir] wire up wrappers for a downloaded MSVC
vintner env (e) --bin <dir/bin/arch> print INCLUDE/LIB for native clang-cl/lld-link use
vintner version (v) print the version
vintner doctor check wine/toolchain setup
vintner help (h) show this message
vintner completion bash|zsh print a shell completion script
Run "vintner <command> --help" for that command's own options - download
has many, including --with-wdk, --with-dxsdk, --list-workloads,
--list-components and --print-deps-tree.
--dest/[dir] default to ~/.vintner if omitted.
Language: set VINTNER_LANG=ru (or LANG=ru_RU...) for Russian output.
Completion: source <(vintner completion bash) # or zsh
Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly,
or skip PATH and run them through vintner itself - "vintner cl ...",
"vintner msbuild ...", etc. (set VINTNER_BIN to a <dir>/bin/<arch> if it's
not the default ~/.vintner/bin/<host-arch>):
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
`,
RU: `vintner — кросс-компиляция настоящим MSVC на Linux через Wine
Использование:
vintner download (dl) --accept-license [--dest <каталог>] [опции]
скачать и распаковать MSVC/WinSDK/WDK
vintner install (i) [каталог] настроить обёртки для скачанного MSVC
vintner env (e) --bin <dir/bin/arch> вывести INCLUDE/LIB для clang-cl/lld-link напрямую
vintner version (v) показать версию
vintner doctor проверить настройку wine/toolchain
vintner help (h) показать эту справку
vintner completion bash|zsh вывести скрипт автодополнения для оболочки
Запустите «vintner <команда> --help» для параметров конкретной команды —
у download их много, включая --with-wdk, --with-dxsdk, --list-workloads,
--list-components и --print-deps-tree.
--dest/[каталог] по умолчанию — ~/.vintner.
Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском.
Можно не трогать PATH: "vintner cl ...", "vintner msbuild ..." и т.д. работают
напрямую (VINTNER_BIN — если каталог не стандартный ~/.vintner/bin/<hostarch>).
Автодополнение: source <(vintner completion bash) # или zsh
После установки добавьте <dir>/bin/<arch> в PATH и вызывайте инструменты
напрямую, либо не трогая PATH — «vintner cl ...», «vintner msbuild ...»
и т.д.:
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
`,
},
"main.unknown_subcommand": {
EN: "vintner: unknown subcommand %q\n\n",
RU: "vintner: неизвестная подкоманда %q\n\n",
},
"install.usage": {
EN: "usage: vintner install (i) [dest] (default: ~/.vintner)",
RU: "использование: vintner install (i) [каталог] (по умолчанию: ~/.vintner)",
},
"install.default_dir": {
EN: "No directory given, using default: %s",
RU: "Каталог не указан, используется значение по умолчанию: %s",
},
"install.done": {
EN: "Done. Add %s to PATH to use cl, link, lib, ... directly, or run them as \"vintner cl\", \"vintner link\", etc. without touching PATH.",
RU: "Готово. Добавьте %s в PATH, чтобы использовать cl, link, lib и т.д. напрямую, либо запускайте их как «vintner cl», «vintner link» и т.д., не трогая PATH.",
},
"env.usage": {
EN: "usage: vintner env (e) --bin <dest>/bin/<arch>",
RU: "использование: vintner env (e) --bin <dest>/bin/<arch>",
},
"env.unknown_arch": {
EN: "vintner env: unknown arch %q\n",
RU: "vintner env: неизвестная архитектура %q\n",
},
"download.host_arch": {
EN: "Install packages for %s host architecture",
RU: "Установка пакетов для архитектуры хоста %s",
},
"download.selected": {
EN: "Selected %d packages, for a total download size of %s, install size of %s\n",
RU: "Выбрано пакетов: %d, общий размер загрузки %s, размер после установки %s\n",
},
"download.default_dest": {
EN: "--dest not set, using default: %s",
RU: "--dest не указан, используется значение по умолчанию: %s",
},
"download.done": {
EN: "Done. Next: vintner install %s",
RU: "Готово. Далее: vintner install %s",
},
"download.wdk_skip": {
EN: "--with-wdk: no x64/arm64 target architecture selected, skipping (no WDK package exists for x86/arm)",
RU: "--with-wdk: не выбрана целевая архитектура x64/arm64, пропускаем (для x86/arm пакета WDK не существует)",
},
"download.wdk_installed": {
EN: "Installed WDK (%s) %s at %s\n",
RU: "WDK (%s) %s установлен в %s\n",
},
"download.dxsdk_installed": {
EN: "Installed DirectX SDK (June 2010) at %s\n",
RU: "DirectX SDK (июнь 2010) установлен в %s\n",
},
"download.workloads_header": {
EN: "Available Workloads (%d):\n",
RU: "Доступные рабочие нагрузки (Workload) (%d):\n",
},
"download.components_header": {
EN: "Available Components (%d):\n",
RU: "Доступные компоненты (Component) (%d):\n",
},
"download.license_prompt": {
EN: "Do you accept the license at %s (yes/no)? ",
RU: "Вы принимаете лицензию по адресу %s (yes/no)? ",
},
"download.license_reprompt": {
EN: "Do you accept the license? Answer \"yes\" or \"no\": ",
RU: "Вы принимаете лицензию? Ответьте «yes» или «no»: ",
},
"doctor.usage": {
EN: "usage: vintner doctor",
RU: "использование: vintner doctor",
},
"doctor.section_wine": {
EN: "Wine:",
RU: "Wine:",
},
"doctor.section_extract": {
EN: "Extraction tools:",
RU: "Инструменты распаковки:",
},
"doctor.section_toolchain": {
EN: "Installed toolchain:",
RU: "Установленный набор инструментов:",
},
"doctor.msitools_missing": {
EN: "msitools: not found (install the msitools package - needed by `vintner download`)",
RU: "msitools: не найден (установите пакет msitools — нужен для `vintner download`)",
},
"doctor.cabextract_missing": {
EN: "cabextract: not found (install the cabextract package - only needed for --with-wdk/--with-dxsdk)",
RU: "cabextract: не найден (установите пакет cabextract — нужен только для --with-wdk/--with-dxsdk)",
},
"doctor.no_toolchain": {
EN: "no installed toolchain found under %s (run `vintner download --accept-license && vintner install` first, or set VINTNER_BIN)",
RU: "установленный набор инструментов не найден в %s (сначала выполните `vintner download --accept-license && vintner install`, либо задайте VINTNER_BIN)",
},
"doctor.summary_ok": {
EN: "\nAll checks passed.",
RU: "\nВсе проверки пройдены.",
},
"doctor.summary_fail": {
EN: "\nSome checks failed - see [FAIL] lines above.",
RU: "\nНекоторые проверки не пройдены — см. строки [FAIL] выше.",
},
}
+93
View File
@@ -0,0 +1,93 @@
package i18n
import (
"strings"
"testing"
)
func TestDetect(t *testing.T) {
envKeys := []string{"VINTNER_LANG", "LC_ALL", "LC_MESSAGES", "LANG"}
clear := func() {
for _, k := range envKeys {
t.Setenv(k, "")
// t.Setenv("", "") leaves the var set-but-empty, which detect()
// already treats as "unset" (its loop skips v == "") - matches
// how a genuinely-unset env var behaves for this function.
}
}
for _, tc := range []struct {
name string
env map[string]string
want Lang
}{
{"nothing set defaults to English", nil, EN},
{"VINTNER_LANG=ru", map[string]string{"VINTNER_LANG": "ru"}, RU},
{"VINTNER_LANG=en", map[string]string{"VINTNER_LANG": "en"}, EN},
{"VINTNER_LANG wins over a Russian LANG", map[string]string{"VINTNER_LANG": "en", "LANG": "ru_RU.UTF-8"}, EN},
{"LC_ALL wins over LANG", map[string]string{"LC_ALL": "ru_RU.UTF-8", "LANG": "en_US.UTF-8"}, RU},
{"LANG=ru_RU.UTF-8 alone", map[string]string{"LANG": "ru_RU.UTF-8"}, RU},
{"LANG=en_US.UTF-8 alone", map[string]string{"LANG": "en_US.UTF-8"}, EN},
{"unrelated locale defaults to English", map[string]string{"LANG": "de_DE.UTF-8"}, EN},
{"case-insensitive RU prefix", map[string]string{"VINTNER_LANG": "RU"}, RU},
} {
t.Run(tc.name, func(t *testing.T) {
clear()
for k, v := range tc.env {
t.Setenv(k, v)
}
if got := detect(); got != tc.want {
t.Errorf("detect() = %q, want %q", got, tc.want)
}
})
}
}
// TestCatalogCompleteness guards against adding an EN string without its RU
// counterpart (or vice versa) - a silent gap here degrades to showing the
// wrong language's text via T()'s EN-fallback rather than failing loudly.
func TestCatalogCompleteness(t *testing.T) {
for key, entry := range catalog {
en, hasEN := entry[EN]
if !hasEN || strings.TrimSpace(en) == "" {
t.Errorf("catalog[%q] has no (non-empty) English translation", key)
}
ru, hasRU := entry[RU]
if !hasRU || strings.TrimSpace(ru) == "" {
t.Errorf("catalog[%q] has no (non-empty) Russian translation", key)
}
}
}
func TestTMissingKeyReturnsKeyItself(t *testing.T) {
got := T("no.such.key")
if got != "no.such.key" {
t.Errorf("T(unknown key) = %q, want the key itself", got)
}
}
func TestTFallsBackToEnglish(t *testing.T) {
const testKey = "test.fallback.only.en"
catalog[testKey] = map[Lang]string{EN: "hello %s"}
defer delete(catalog, testKey)
saved := current
current = RU
defer func() { current = saved }()
if got := T(testKey, "world"); got != "hello world" {
t.Errorf("T(%q) with no RU entry = %q, want %q", testKey, got, "hello world")
}
}
func TestTFormatsArgs(t *testing.T) {
saved := current
current = EN
defer func() { current = saved }()
got := T("download.wdk_installed", "x64", "10.0.26100.1", "/dest")
want := "Installed WDK (x64) 10.0.26100.1 at /dest\n"
if got != want {
t.Errorf("T(download.wdk_installed, ...) = %q, want %q", got, want)
}
}
+24 -13
View File
@@ -1,4 +1,4 @@
// Package install wires up a downloaded MSVC/WinSDK tree so the msvc-go-wine // Package install wires up a downloaded MSVC/WinSDK tree so the vintner
// wrapper commands can find it: locating the installed toolchain/SDK // wrapper commands can find it: locating the installed toolchain/SDK
// versions, fixing up header/library name casing, laying out the // versions, fixing up header/library name casing, laying out the
// per-architecture tool symlinks, and building the toolrelay helper. // per-architecture tool symlinks, and building the toolrelay helper.
@@ -14,16 +14,17 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/Cheviiot/msvc-go-wine/assets" "github.com/Cheviiot/vintner/assets"
"github.com/Cheviiot/msvc-go-wine/internal/wineenv" "github.com/Cheviiot/vintner/internal/lock"
"github.com/Cheviiot/vintner/internal/wineenv"
) )
var archs = []string{"x86", "x64", "arm", "arm64"} var archs = []string{"x86", "x64", "arm", "arm64"}
// Install wires up dest (a directory previously populated by // Install wires up dest (a directory previously populated by
// `msvc-go-wine download --dest dest`) with the tool wrapper symlinks and // `vintner download --dest dest`) with the tool wrapper symlinks and
// env.json config the wrapper runtime expects. selfBinary is the path to // 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 currently running vintner executable, copied into dest/bin so
// the arch-specific tool symlinks have something to point at. // the arch-specific tool symlinks have something to point at.
func Install(dest, selfBinary string) error { func Install(dest, selfBinary string) error {
dest, err := filepath.Abs(dest) dest, err := filepath.Abs(dest)
@@ -34,6 +35,12 @@ func Install(dest, selfBinary string) error {
return fmt.Errorf("destination %q is not a directory", dest) return fmt.Errorf("destination %q is not a directory", dest)
} }
unlock, err := lock.Acquire(dest)
if err != nil {
return err
}
defer unlock()
// Targets are relative so the whole installed tree stays relocatable - // Targets are relative so the whole installed tree stays relocatable -
// moving or renaming dest doesn't break these symlinks the way an // moving or renaming dest doesn't break these symlinks the way an
// absolute target baked in at install time would. // absolute target baked in at install time would.
@@ -62,6 +69,10 @@ func Install(dest, selfBinary string) error {
return err return err
} }
if err := aliasPlatformToolsets(dest); err != nil {
return err
}
includeDir := filepath.Join(msvcDir, "include") includeDir := filepath.Join(msvcDir, "include")
if err := Lowercase(includeDir, LowercaseOptions{Symlink: true}); err != nil { if err := Lowercase(includeDir, LowercaseOptions{Symlink: true}); err != nil {
return fmt.Errorf("lowercasing %s: %w", includeDir, err) return fmt.Errorf("lowercasing %s: %w", includeDir, err)
@@ -86,7 +97,7 @@ func Install(dest, selfBinary string) error {
kits10 := filepath.Join(dest, "kits", "10") kits10 := filepath.Join(dest, "kits", "10")
if !isDir(kits10) { if !isDir(kits10) {
return fmt.Errorf("%s not found - expected a Windows SDK already unpacked by `msvc-go-wine download`", kits10) return fmt.Errorf("%s not found - expected a Windows SDK already unpacked by `vintner download`", kits10)
} }
if err := lnS("Lib", filepath.Join(kits10, "lib")); err != nil { if err := lnS("Lib", filepath.Join(kits10, "lib")); err != nil {
return err return err
@@ -148,7 +159,7 @@ func Install(dest, selfBinary string) error {
if err := os.MkdirAll(destBin, 0o755); err != nil { if err := os.MkdirAll(destBin, 0o755); err != nil {
return err return err
} }
sharedBinary := filepath.Join(destBin, "msvc-go-wine") sharedBinary := filepath.Join(destBin, "vintner")
if err := copyFile(selfBinary, sharedBinary, 0o755); err != nil { if err := copyFile(selfBinary, sharedBinary, 0o755); err != nil {
return fmt.Errorf("installing shared binary: %w", err) return fmt.Errorf("installing shared binary: %w", err)
} }
@@ -288,7 +299,7 @@ func renameHostDirs(binDir string) error {
} }
// setupWrapperDir creates <destBin>/<arch> with its own local copy of the // 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 // vintner binary (not a symlink to the shared one in destBin), and
// symlinks every tool name to that LOCAL copy. // symlinks every tool name to that LOCAL copy.
// //
// This matters: the wrapper runtime locates its own install root via // This matters: the wrapper runtime locates its own install root via
@@ -297,23 +308,23 @@ func renameHostDirs(binDir string) error {
// PATH-resolved absolute path as argv[0] (some just pass the bare command // 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 // 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 // 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 // symlink (cl -> vintner) resolves to a binary that's still in the
// right arch dir; a symlink to a binary one level up (cl -> ../msvc-go-wine) // right arch dir; a symlink to a binary one level up (cl -> ../vintner)
// would not be. // would not be.
func setupWrapperDir(destBin, selfBinary, arch, host, dotnetHost, msvcVer, sdkVer string) error { func setupWrapperDir(destBin, selfBinary, arch, host, dotnetHost, msvcVer, sdkVer string) error {
archDir := filepath.Join(destBin, arch) archDir := filepath.Join(destBin, arch)
if err := os.MkdirAll(archDir, 0o755); err != nil { if err := os.MkdirAll(archDir, 0o755); err != nil {
return err return err
} }
localBinary := filepath.Join(archDir, "msvc-go-wine") localBinary := filepath.Join(archDir, "vintner")
if err := copyFile(selfBinary, localBinary, 0o755); err != nil { if err := copyFile(selfBinary, localBinary, 0o755); err != nil {
return fmt.Errorf("installing per-arch binary: %w", err) return fmt.Errorf("installing per-arch binary: %w", err)
} }
for name := range toolNames { for name := range toolNames {
if err := lnS("msvc-go-wine", filepath.Join(archDir, name)); err != nil { if err := lnS("vintner", filepath.Join(archDir, name)); err != nil {
return err return err
} }
if err := lnS("msvc-go-wine", filepath.Join(archDir, name+".exe")); err != nil { if err := lnS("vintner", filepath.Join(archDir, name+".exe")); err != nil {
return err return err
} }
} }
+92
View File
@@ -0,0 +1,92 @@
package install
import (
"os"
"path/filepath"
"regexp"
"github.com/Cheviiot/vintner/internal/wineenv"
)
var rePlatformToolsetDir = regexp.MustCompile(`^v(\d+)$`)
// aliasPlatformToolsets makes every historical PlatformToolset name in
// wineenv.KnownPlatformToolsets resolve to the one compiler `download`
// actually fetched.
//
// MSBuild decides whether a PlatformToolset is "installed" at all - the
// check behind MSB8020 - by testing whether
// MSBuild/Microsoft/VC/v<schema>/Platforms/<arch>/PlatformToolsets/<toolset>/
// exists on disk (Microsoft.Cpp.props, via
// ToolLocationHelper.FindRootFolderWhereAllFilesExist). That's a plain file
// lookup, not influenced by any environment variable - unlike the later,
// env-var-driven VCInstallDir_<N> checks internal/wrapper's msbuildEnv
// covers, this one needs the actual directory to exist under dest.
// Microsoft's own downloaded MSBuild package only ships a PlatformToolsets
// entry for the exact generation matching the fetched compiler, so any
// project pinned to an older PlatformToolset (v142 for a project last saved
// under VS2019, say) fails this check outright even though the one real
// toolchain installed could easily build it.
//
// Toolset.props/Toolset.targets don't hardcode a version number (they just
// import version-agnostic files like Microsoft.Cpp.MSVC.Toolset.<arch>.props),
// so a symlink under any other historical name is a correct, transparent
// alias rather than a divergent copy.
func aliasPlatformToolsets(dest string) error {
schemaDirs, err := filepath.Glob(filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v*"))
if err != nil {
return err
}
for _, schemaDir := range schemaDirs {
archDirs, err := filepath.Glob(filepath.Join(schemaDir, "Platforms", "*", "PlatformToolsets"))
if err != nil {
return err
}
for _, toolsetsDir := range archDirs {
if err := aliasOneDir(toolsetsDir); err != nil {
return err
}
}
}
return nil
}
// aliasOneDir symlinks every name in wineenv.KnownPlatformToolsets that
// doesn't already exist in toolsetsDir onto whichever real v<N> toolset
// subdirectory is actually present there.
func aliasOneDir(toolsetsDir string) error {
entries, err := os.ReadDir(toolsetsDir)
if err != nil {
return err
}
var real string
for _, e := range entries {
if !e.IsDir() {
continue
}
if rePlatformToolsetDir.MatchString(e.Name()) {
real = e.Name()
break
}
}
if real == "" {
// Nothing numeric here (e.g. only the WindowsKernelModeDriver10.0-style
// WDK toolsets) - nothing to alias.
return nil
}
for _, n := range wineenv.KnownPlatformToolsets {
alias := "v" + n
if alias == real {
continue
}
aliasPath := filepath.Join(toolsetsDir, alias)
if exists(aliasPath) {
continue
}
if err := os.Symlink(real, aliasPath); err != nil {
return err
}
}
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package install
import (
"os"
"path/filepath"
"testing"
"github.com/Cheviiot/vintner/internal/wineenv"
)
func TestAliasPlatformToolsetsAliasesRealToolset(t *testing.T) {
dest := t.TempDir()
toolsetsDir := filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v180", "Platforms", "x64", "PlatformToolsets")
realDir := filepath.Join(toolsetsDir, "v145")
if err := os.MkdirAll(realDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(realDir, "Toolset.props"), []byte("<Project/>"), 0o644); err != nil {
t.Fatal(err)
}
// A WDK toolset entry alongside it - must not be mistaken for the real
// numeric toolset or itself get aliased over.
if err := os.MkdirAll(filepath.Join(toolsetsDir, "WindowsKernelModeDriver10.0"), 0o755); err != nil {
t.Fatal(err)
}
if err := aliasPlatformToolsets(dest); err != nil {
t.Fatalf("aliasPlatformToolsets: %v", err)
}
for _, n := range wineenv.KnownPlatformToolsets {
alias := filepath.Join(toolsetsDir, "v"+n)
fi, err := os.Lstat(alias)
if err != nil {
t.Errorf("expected v%s alias to exist: %v", n, err)
continue
}
if fi.Mode()&os.ModeSymlink == 0 {
t.Errorf("v%s should be a symlink, got mode %v", n, fi.Mode())
continue
}
target, err := os.Readlink(alias)
if err != nil {
t.Fatal(err)
}
if target != "v145" {
t.Errorf("v%s symlink target = %q, want \"v145\"", n, target)
}
// Follow the alias and confirm it actually reaches the real content.
if !isFile(filepath.Join(toolsetsDir, "v"+n, "Toolset.props")) {
t.Errorf("v%s/Toolset.props not reachable through the alias", n)
}
}
if exists(filepath.Join(toolsetsDir, "WindowsKernelModeDriver10.0", "v"+wineenv.KnownPlatformToolsets[0])) {
t.Error("WDK toolset directory should not have been touched")
}
}
func TestAliasPlatformToolsetsDoesNotOverwriteExisting(t *testing.T) {
dest := t.TempDir()
toolsetsDir := filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v180", "Platforms", "x64", "PlatformToolsets")
if err := os.MkdirAll(filepath.Join(toolsetsDir, "v145"), 0o755); err != nil {
t.Fatal(err)
}
// v142 already genuinely installed (e.g. a real VS install with several
// side-by-side toolsets) - must be left alone, not replaced with an alias.
real142 := filepath.Join(toolsetsDir, "v142")
if err := os.MkdirAll(real142, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(real142, "Toolset.props"), []byte("<!-- real v142 -->"), 0o644); err != nil {
t.Fatal(err)
}
if err := aliasPlatformToolsets(dest); err != nil {
t.Fatalf("aliasPlatformToolsets: %v", err)
}
fi, err := os.Lstat(real142)
if err != nil {
t.Fatal(err)
}
if fi.Mode()&os.ModeSymlink != 0 {
t.Error("pre-existing v142 directory should not have been replaced with a symlink")
}
}
func TestAliasPlatformToolsetsNoNumericToolset(t *testing.T) {
dest := t.TempDir()
// Only a WDK-style toolset present, nothing numeric to alias from.
toolsetsDir := filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v180", "Platforms", "x64", "PlatformToolsets")
if err := os.MkdirAll(filepath.Join(toolsetsDir, "WindowsUserModeDriver10.0"), 0o755); err != nil {
t.Fatal(err)
}
if err := aliasPlatformToolsets(dest); err != nil {
t.Fatalf("aliasPlatformToolsets: %v", err)
}
for _, n := range wineenv.KnownPlatformToolsets {
if exists(filepath.Join(toolsetsDir, "v"+n)) {
t.Errorf("v%s should not have been created with no real numeric toolset present", n)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
// Package lock guards a destination directory against two `vintner
// download`/`install` runs mutating it at the same time.
package lock
import (
"fmt"
"os"
"path/filepath"
"syscall"
)
// FileName is the advisory lock file `download` and `install` both take
// out against their (usually shared) destination directory before
// touching anything in it - concurrent download+install, or two
// downloads, against the same dest could otherwise interleave badly:
// combineDirTrees' merge logic assumes it's the only thing moving files
// into a given target at a time, and two `os.Rename` calls racing for the
// same destination path is exactly the kind of thing that corrupts a tree
// instead of erroring cleanly.
const FileName = ".vintner.lock"
// Acquire takes an exclusive, non-blocking lock on dest (creating dest if
// it doesn't exist yet) and returns a func to release it, which the caller
// must defer. If another vintner process already holds the lock, returns
// an error immediately instead of blocking - there's no reason a second
// invocation should silently queue up and wait for the first to finish
// touching the same directory; the caller should simply not have started
// it yet. Uses flock(2), so a crashed holder's lock is released
// automatically by the kernel when its file descriptor closes - never
// needs manual cleanup, unlike a plain "does a file exist" lock
// convention would.
func Acquire(dest string) (unlock func(), err error) {
if err := os.MkdirAll(dest, 0o755); err != nil {
return nil, err
}
path := filepath.Join(dest, FileName)
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
return nil, fmt.Errorf("another vintner download/install is already running against %s", dest)
}
return func() {
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}
+124
View File
@@ -0,0 +1,124 @@
package lock
import (
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestAcquireAndRelease(t *testing.T) {
dest := t.TempDir()
unlock, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dest, FileName)); err != nil {
t.Errorf("expected the lock file to exist while held: %v", err)
}
unlock()
// Released - a second Acquire against the same dest must now succeed.
unlock2, err := Acquire(dest)
if err != nil {
t.Fatalf("Acquire after release failed: %v", err)
}
unlock2()
}
func TestAcquireCreatesDestIfMissing(t *testing.T) {
dest := filepath.Join(t.TempDir(), "does", "not", "exist", "yet")
unlock, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
defer unlock()
if fi, err := os.Stat(dest); err != nil || !fi.IsDir() {
t.Errorf("expected Acquire to create %s, stat err: %v", dest, err)
}
}
func TestAcquireFailsWhileAlreadyHeld(t *testing.T) {
dest := t.TempDir()
unlock, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
defer unlock()
if _, err := Acquire(dest); err == nil {
t.Fatal("expected a second Acquire against the same dest, while the first is still held, to fail")
}
}
func TestAcquireSucceedsAfterHolderReleases(t *testing.T) {
dest := t.TempDir()
unlock1, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
unlock1()
unlock2, err := Acquire(dest)
if err != nil {
t.Fatalf("Acquire should succeed once the first holder released: %v", err)
}
unlock2()
}
// TestAcquireFailsAcrossRealProcesses is the real end-to-end check: flock
// is per-open-file-description, not per-process or per-thread, so a lock
// held by *this* test process via one fd could in principle still be
// re-acquirable by another fd in the same process depending on the
// platform's exact semantics. Spawning this test binary as a genuinely
// separate child process (via the standard TestMain re-exec trick) and
// having it hold the lock while the parent tries to acquire it is what
// actually proves two independent `vintner download`/`install` processes
// contend correctly, not just two Go-level calls in one process.
func TestAcquireFailsAcrossRealProcesses(t *testing.T) {
if os.Getenv("VINTNER_LOCK_TEST_HOLD") != "" {
unlock, err := Acquire(os.Getenv("VINTNER_LOCK_TEST_HOLD"))
if err != nil {
os.Exit(2)
}
defer unlock()
// Signal readiness, then wait to be killed by the parent. A plain
// `select {}` here would have zero other goroutines able to ever
// wake it, which Go's runtime provably detects as a deadlock and
// crashes on ("fatal error: all goroutines are asleep") - a real
// timer avoids that.
os.Stdout.WriteString("locked\n")
time.Sleep(time.Minute)
}
dest := t.TempDir()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(exe, "-test.run=TestAcquireFailsAcrossRealProcesses")
cmd.Env = append(os.Environ(), "VINTNER_LOCK_TEST_HOLD="+dest)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer cmd.Process.Kill()
buf := make([]byte, len("locked\n"))
if _, err := io.ReadFull(stdout, buf); err != nil || string(buf) != "locked\n" {
t.Fatalf("child process didn't report holding the lock: %v (%q)", err, buf)
}
if _, err := Acquire(dest); err == nil {
t.Fatal("expected Acquire to fail while a separate process holds the lock")
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
) )
// ConfigFileName is the per-architecture config dropped next to the tool // ConfigFileName is the per-architecture config dropped next to the tool
// symlinks by `msvc-go-wine install`. // symlinks by `vintner install`.
const ConfigFileName = "env.json" const ConfigFileName = "env.json"
// Config is the per-architecture info generated at install time. // Config is the per-architecture info generated at install time.
+15
View File
@@ -0,0 +1,15 @@
package wineenv
// KnownPlatformToolsets are every numeric PlatformToolset short name
// Microsoft.Cpp.Default.props has ever defined a
// _PlatformToolsetShortNameFor_v<N> entry for (VS2013 through the VS2022
// initial release; excludes the _xp/_wp80/_wp81 variants, which aren't
// purely numeric). vintner only ever installs one compiler generation, but
// real .vcxproj files in the wild are pinned to whichever generation they
// were last edited under - v142 (VS2019) for anything not yet retargeted is
// extremely common. Shared between internal/install (which symlinks these
// names onto the one real MSBuild PlatformToolsets directory) and
// internal/wrapper (which mirrors the same names onto VCInstallDir_<N>/
// VCToolsInstallDir_<N> for the older, environment-variable-driven toolset
// redirect chain) - see doc comments there for why both are needed.
var KnownPlatformToolsets = []string{"90", "100", "110", "120", "140", "141", "142", "143"}
+1 -1
View File
@@ -14,5 +14,5 @@ func FindWine() (string, error) {
if p, err := exec.LookPath("wine"); err == nil { if p, err := exec.LookPath("wine"); err == nil {
return p, nil return p, nil
} }
return "", fmt.Errorf("neither wine64 nor wine found in PATH") return "", fmt.Errorf("neither wine64 nor wine found in PATH (install the wine package)")
} }
+60
View File
@@ -0,0 +1,60 @@
package wineenv
import (
"os"
"path/filepath"
"strings"
"testing"
)
func fakeBinary(t *testing.T, name string) {
t.Helper()
bin := t.TempDir()
path := filepath.Join(bin, name)
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
}
func TestFindWinePrefersWine64(t *testing.T) {
bin := t.TempDir()
for _, name := range []string{"wine64", "wine"} {
if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", bin)
got, err := FindWine()
if err != nil {
t.Fatal(err)
}
if filepath.Base(got) != "wine64" {
t.Errorf("FindWine() = %q, want wine64 to be preferred over wine", got)
}
}
func TestFindWineFallsBackToWine(t *testing.T) {
fakeBinary(t, "wine")
got, err := FindWine()
if err != nil {
t.Fatal(err)
}
if filepath.Base(got) != "wine" {
t.Errorf("FindWine() = %q, want wine", got)
}
}
func TestFindWineErrorIsActionable(t *testing.T) {
t.Setenv("PATH", t.TempDir()) // empty dir, neither binary present
_, err := FindWine()
if err == nil {
t.Fatal("expected an error when neither wine64 nor wine is on PATH")
}
if !strings.Contains(err.Error(), "install") {
t.Errorf("FindWine() error = %q, want it to say what to install (matching msiextract/cabextract's error style)", err)
}
}
+61
View File
@@ -0,0 +1,61 @@
package wrapper
import (
"os"
"path/filepath"
"testing"
)
func TestClPostProcessRewritesLineDirectivesInPreprocessedOutput(t *testing.T) {
dir := t.TempDir()
fi := filepath.Join(dir, "out.i")
// A #line directive as cl.exe's /P emits it: z:-prefixed, backslash
// path, doubled ("escaped") backslashes, CRLF line ending.
input := "#line 1 \"z:\\\\home\\\\user\\\\src\\\\hello.c\"\r\n" +
"int main(void) { return 0; }\r\n"
if err := os.WriteFile(fi, []byte(input), 0o644); err != nil {
t.Fatal(err)
}
clPostProcess([]string{"/P", "/Fi" + fi, "hello.c"})
got, err := os.ReadFile(fi)
if err != nil {
t.Fatal(err)
}
want := "#line 1 \"/home/user/src/hello.c\"\n" +
"int main(void) { return 0; }\n"
if string(got) != want {
t.Errorf("clPostProcess output = %q, want %q", got, want)
}
}
func TestClPostProcessNoopWithoutP(t *testing.T) {
dir := t.TempDir()
fi := filepath.Join(dir, "out.i")
original := "#line 1 \"z:\\\\foo.c\"\r\n"
if err := os.WriteFile(fi, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
// No "/P" flag - should leave the file untouched even though -Fi is present.
clPostProcess([]string{"/Fi" + fi, "foo.c"})
got, err := os.ReadFile(fi)
if err != nil {
t.Fatal(err)
}
if string(got) != original {
t.Errorf("file was modified without /P: got %q, want unchanged %q", got, original)
}
}
func TestClPostProcessNoopWithoutFi(t *testing.T) {
// Must not panic or error when -Fi wasn't passed - just silently skip.
clPostProcess([]string{"/P", "foo.c"})
}
func TestClPostProcessMissingFileIsSilent(t *testing.T) {
// The referenced -Fi file doesn't exist - clPostProcess must not panic.
clPostProcess([]string{"/P", "/Fi" + filepath.Join(t.TempDir(), "missing.i"), "foo.c"})
}
+195 -16
View File
@@ -1,11 +1,12 @@
package wrapper package wrapper
import ( import (
"os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"github.com/Cheviiot/msvc-go-wine/internal/wineenv" "github.com/Cheviiot/vintner/internal/wineenv"
) )
var reToolsetDir = regexp.MustCompile(`^v(\d+)$`) var reToolsetDir = regexp.MustCompile(`^v(\d+)$`)
@@ -19,12 +20,41 @@ var reToolsetDir = regexp.MustCompile(`^v(\d+)$`)
// Windows Registry. // Windows Registry.
func msbuildEnv(cfg *wineenv.Config, paths *wineenv.Paths) map[string]string { func msbuildEnv(cfg *wineenv.Config, paths *wineenv.Paths) map[string]string {
env := map[string]string{ env := map[string]string{
// WDK driver builds stamp the INF's DriverVer with StampInf (which
// uses the local wall-clock date) and then validate it with
// Inf2Cat (which checks against UTC "now"). For any timezone east
// of UTC, local-vs-UTC disagree on the calendar date for most of
// the day, so Inf2Cat rejects the just-stamped date as "postdated"
// (MSB6006, "DriverVer set to a date in the future"). Forcing both
// tools onto the same UTC clock removes the mismatch.
"TZ": "UTC",
// VCToolsVersion must be a real version string, not left unset:
// Microsoft.Cpp.VCTools.props itself falls back to the literal
// placeholder "VCToolsVersion_is_not_defined" whenever it's empty,
// and that placeholder then reaches unconditional (not gated behind
// CheckMSVCComponents) version-string comparisons elsewhere in
// Microsoft.CppBuild.targets, e.g. the SegmentHeap manifest check's
// VersionGreaterThanOrEquals(), which throws MSB4184 on a
// non-version string.
//
// CheckMSVCComponents=false is what actually makes an aliased
// PlatformToolset safe to combine with that real version: without
// it, CheckVCToolsetVersion (Microsoft.CppBuild.targets, MSB8052)
// rejects the combination whenever VCToolsVersion's numeric
// generation doesn't match PlatformToolset's - exactly the legacy-
// project case toolsetSuffixes/aliasPlatformToolsets exist for (a
// v142 project against the one real, newer compiler actually
// installed). Everything else CheckMSVCComponents gates
// (Microsoft.CppBuild.targets ~495-535) is diagnostic-only - MFC/ATL/
// Spectre component presence warnings, none of it feeding into the
// actual compile/link - so disabling it costs nothing here.
"DisableRegistryUse": "true", "DisableRegistryUse": "true",
"CheckMSVCComponents": "false",
"VCToolsVersion": cfg.MSVCVer, "VCToolsVersion": cfg.MSVCVer,
"VsInstallRoot": paths.BaseWin + `\`, "VsInstallRoot": paths.BaseWin + `\`,
"VSInstallDir": paths.BaseWin + `\`, "VSInstallDir": paths.BaseWin + `\`,
"MicrosoftKitRoot": paths.BaseWin + `\`,
"SDKReferenceDirectoryRoot": paths.BaseWin + `\`, "SDKReferenceDirectoryRoot": paths.BaseWin + `\`,
"SDKExtensionDirectoryRoot": paths.BaseWin + `\`, "SDKExtensionDirectoryRoot": paths.BaseWin + `\`,
"MSBUILDSDKREFERENCEDIRECTORY": paths.BaseWin + `\`, "MSBUILDSDKREFERENCEDIRECTORY": paths.BaseWin + `\`,
@@ -49,29 +79,178 @@ func msbuildEnv(cfg *wineenv.Config, paths *wineenv.Paths) map[string]string {
"Platform": msbuildPlatform(cfg.Arch), "Platform": msbuildPlatform(cfg.Arch),
} }
// Microsoft.Cpp.props resolves the compiler/toolset location through // VCInstallDir_<N>/VCToolsInstallDir_<N> are consulted under two
// VCInstallDir_<N>/VCToolsInstallDir_<N>, where <N> is whatever numeric // completely different numbering schemes, both needing the single real
// suffix the installed MSBuild toolset property sheets use (e.g. // toolchain behind every <N> they might ask for:
// .../MSBuild/Microsoft/VC/v180 -> "180"). Populate every one actually //
// present, so a project pinned to any of them resolves to the one real // - Microsoft.Cpp.Default.props keys its early "is this toolset even
// toolchain that's installed. // installed" check (the one MSB8020 comes from) off <N> = the
matches, _ := filepath.Glob(filepath.Join(paths.BaseUnix, "MSBuild", "Microsoft", "VC", "v*")) // PlatformToolset suffix a .vcxproj actually declares (v142, v143,
for _, m := range matches { // v145, ...) - the same short name Microsoft stamps on
sub := reToolsetDir.FindStringSubmatch(filepath.Base(m)) // VC/Auxiliary/Build/Microsoft.VCToolsVersion.v<N>.default.props for
if sub == nil { // the downloaded compiler.
continue // - Microsoft.CppBuild.targets (MSB8070) instead keys off <N> = the
} // MSBuild targets-schema version whose Microsoft.Cpp.props ended up
env["VCInstallDir_"+sub[1]] = paths.MSVCBaseWin + `\` // imported for this run (MSBuild/Microsoft/VC/v150|v160|v170|v180 -
env["VCToolsInstallDir_"+sub[1]] = paths.MSVCDirWin + `\` // fixed, shipped identically with every MSBuild release, unrelated to
// which compiler is installed), to locate the specific toolset
// version subfolder.
//
// Populate every <N> from both sources, all pointing at the one real
// toolchain that's installed, so a project pinned to any PlatformToolset
// resolves at every stage MSBuild checks it.
for _, n := range toolsetSuffixes(paths.BaseUnix) {
env["VCInstallDir_"+n] = paths.MSVCBaseWin + `\`
env["VCToolsInstallDir_"+n] = paths.MSVCDirWin + `\`
} }
if strings.HasSuffix(paths.MSBuildBinDir, "amd64") { if strings.HasSuffix(paths.MSBuildBinDir, "amd64") {
env["PreferredToolArchitecture"] = "x64" env["PreferredToolArchitecture"] = "x64"
} }
// The WindowsKernelModeDriver10.0/WindowsUserModeDriver10.0
// PlatformToolsets (registered by `download --with-wdk`, see
// internal/download/wdk.go) resolve WDKContentRoot through the
// (nonexistent, under Wine) registry unless it's already set - same
// DisableRegistryUse workaround as WindowsSdkDir_10 above. WDKBuildFolder
// picks the per-SDK-build subtree (c/build/<ver>/...) the NuGet
// package's content is organized under.
wdkContentRoot := filepath.Join(paths.BaseUnix, "wdk", cfg.Arch, "c")
if fi, err := os.Stat(wdkContentRoot); err == nil && fi.IsDir() {
env["WDKContentRoot"] = wineenv.ToWinPath(wdkContentRoot) + `\`
env["WDKBuildFolder"] = cfg.SDKVer
}
return env return env
} }
// toolsetSuffixes collects every numeric <N> that either VCInstallDir_<N>
// lookup mechanism (see msbuildEnv) might be asked to resolve for this
// installation: PlatformToolset short names from
// vc/Auxiliary/Build/Microsoft.VCToolsVersion.v<N>.default.props, MSBuild
// targets-schema versions from MSBuild/Microsoft/VC/v<N>, and every
// historical PlatformToolset name (see wineenv.KnownPlatformToolsets) - a
// project pinned to any of them all resolves to the one real toolchain
// installed.
func toolsetSuffixes(baseUnix string) []string {
seen := map[string]bool{}
var suffixes []string
record := func(n string) {
if seen[n] {
return
}
seen[n] = true
suffixes = append(suffixes, n)
}
add := func(dir, prefix, suffix string) {
matches, _ := filepath.Glob(filepath.Join(dir, "*"))
for _, m := range matches {
name := filepath.Base(m)
if prefix != "" {
if !strings.HasPrefix(name, prefix) {
continue
}
name = strings.TrimPrefix(name, prefix)
}
name = strings.TrimSuffix(name, suffix)
sub := reToolsetDir.FindStringSubmatch(name)
if sub == nil {
continue
}
record(sub[1])
}
}
add(filepath.Join(baseUnix, "vc", "Auxiliary", "Build"), "Microsoft.VCToolsVersion.", ".default.props")
add(filepath.Join(baseUnix, "MSBuild", "Microsoft", "VC"), "", "")
for _, n := range wineenv.KnownPlatformToolsets {
record(n)
}
return suffixes
}
// reGlobalProp matches an MSBuild global-property command-line switch
// ("/p:Name=...", "-property:Name=...", case-insensitive on both the
// -p/-property spelling and the property name) so msbuildGlobalArgs can tell
// whether the caller already pinned a given property themselves.
func reGlobalProp(name string) *regexp.Regexp {
return regexp.MustCompile(`(?i)^[-/](p|property):` + regexp.QuoteMeta(name) + `=`)
}
// msbuildGlobalArgs returns /p: switches to prepend to an MSBuild invocation,
// one per forced property not already present in args.
//
// WindowsTargetPlatformVersion is the one property that needs this rather
// than an env var: unlike VCInstallDir_<N> (an input to a props-file
// *lookup*, so any value msbuildEnv sets is visible no matter what a project
// pins its PlatformToolset to), WindowsTargetPlatformVersion is itself the
// value most legacy .vcxproj files hardcode directly in a PropertyGroup -
// and an explicit PropertyGroup assignment always wins over an inherited
// environment variable of the same name. A command-line global property is
// the one thing a project file can't override, which is exactly what's
// needed here: vintner only ever installs one Windows SDK version, so - same
// reasoning as the PlatformToolset fallback above - any project should
// transparently build against that one installed version rather than fail
// outright over an exact version string it happened to be pinned to when
// last saved from a real Windows SDK selector dropdown.
func msbuildGlobalArgs(cfg *wineenv.Config, args []string) []string {
forced := map[string]string{
"WindowsTargetPlatformVersion": cfg.SDKVer,
}
var out []string
for name, value := range forced {
re := reGlobalProp(name)
alreadySet := false
for _, a := range args {
if re.MatchString(a) {
alreadySet = true
break
}
}
if !alreadySet {
out = append(out, "/p:"+name+"="+value)
}
}
return out
}
var reNodeReuse = regexp.MustCompile(`(?i)^[-/](nodereuse|nr):`)
// msbuildNodeReuseArgs returns ["/nodeReuse:false"] unless args already pins
// node reuse one way or the other.
//
// MSBuild's node-reuse worker processes (its own /nodeReuse:true default)
// don't behave like a normal child process here: they're meant to outlive
// the parent msbuild.exe invocation that spawned them, waiting around under
// Wine for the *next* msbuild call to reuse them - so nothing about
// vintner's own process-lifetime handling (see signals.go) touches them,
// and there's no parent process left to notice if one wedges. If a build is
// interrupted (Ctrl-C, a killed session, a crashed Wine transport) mid-
// compile, the worker can be left holding a half-open pipe/mutex,
// permanently deadlocked rather than exited - confirmed in practice: a
// stale reused node kept throwing an unrelated-looking
// `System.TypeLoadException` on Microsoft.VisualStudio.Telemetry on every
// subsequent build, for hours, until it was killed by hand and the next
// build got a fresh node. Forcing node reuse off means every invocation
// gets a clean process, so a wedged one can never poison a later,
// unrelated build - at the cost of the couple-hundred-ms/node startup time
// node reuse exists to save. Callers who deliberately want reuse (e.g.
// running many builds back to back and are prepared to clean up wedged
// nodes themselves) can still pass their own /nodeReuse or /nr switch to
// override this.
func msbuildNodeReuseArgs(args []string) []string {
for _, a := range args {
if reNodeReuse.MatchString(a) {
return nil
}
}
return []string{"/nodeReuse:false"}
}
func msbuildPlatform(arch string) string { func msbuildPlatform(arch string) string {
switch arch { switch arch {
case "x86": case "x86":
+222
View File
@@ -0,0 +1,222 @@
package wrapper
import (
"os"
"path/filepath"
"testing"
"github.com/Cheviiot/vintner/internal/wineenv"
)
func TestMsbuildPlatform(t *testing.T) {
for _, tc := range []struct{ arch, want string }{
{"x86", "Win32"},
{"x64", "x64"},
{"arm", "ARM"},
{"arm64", "ARM64"},
} {
if got := msbuildPlatform(tc.arch); got != tc.want {
t.Errorf("msbuildPlatform(%q) = %q, want %q", tc.arch, got, tc.want)
}
}
}
func newTestPaths(t *testing.T, cfg *wineenv.Config) (*wineenv.Paths, string) {
t.Helper()
base := t.TempDir()
return wineenv.NewPaths(cfg, base), base
}
func TestMsbuildEnvBasics(t *testing.T) {
cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"}
paths, _ := newTestPaths(t, cfg)
env := msbuildEnv(cfg, paths)
if env["TZ"] != "UTC" {
t.Errorf(`env["TZ"] = %q, want "UTC"`, env["TZ"])
}
if env["DisableRegistryUse"] != "true" {
t.Errorf(`env["DisableRegistryUse"] = %q, want "true"`, env["DisableRegistryUse"])
}
if env["CheckMSVCComponents"] != "false" {
t.Errorf(`env["CheckMSVCComponents"] = %q, want "false" (else CheckVCToolsetVersion errors on an aliased PlatformToolset)`, env["CheckMSVCComponents"])
}
// VCToolsVersion must be a real version string (see msbuildEnv's doc
// comment on it: leaving it unset makes Microsoft.Cpp.VCTools.props
// substitute a placeholder that then breaks unconditional version
// comparisons elsewhere). CheckMSVCComponents=false is what keeps this
// safe to combine with an aliased PlatformToolset.
if env["VCToolsVersion"] != cfg.MSVCVer {
t.Errorf(`env["VCToolsVersion"] = %q, want %q`, env["VCToolsVersion"], cfg.MSVCVer)
}
if env["WindowsTargetPlatformVersion"] != cfg.SDKVer {
t.Errorf(`env["WindowsTargetPlatformVersion"] = %q, want %q`, env["WindowsTargetPlatformVersion"], cfg.SDKVer)
}
if env["Platform"] != "x64" {
t.Errorf(`env["Platform"] = %q, want "x64"`, env["Platform"])
}
if env["SignMode"] != "off" {
t.Errorf(`env["SignMode"] = %q, want "off" (driver builds must not attempt real signing)`, env["SignMode"])
}
// No WDK content on disk in this test - must not claim otherwise.
if _, ok := env["WDKContentRoot"]; ok {
t.Error(`env["WDKContentRoot"] set even though no wdk/<arch>/c directory exists`)
}
}
func TestMsbuildEnvDiscoversEveryToolsetVersion(t *testing.T) {
cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"}
paths, base := newTestPaths(t, cfg)
// Real layout has two independent sources feeding VCInstallDir_<N>/
// VCToolsInstallDir_<N> (see toolsetSuffixes' doc comment for why both
// are needed): the PlatformToolset short names a downloaded compiler
// ships default-props for, and MSBuild's own fixed schema-version dirs.
buildDir := filepath.Join(base, "vc", "Auxiliary", "Build")
if err := os.MkdirAll(buildDir, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{
"Microsoft.VCToolsVersion.v145.default.props",
"Microsoft.VCToolsVersion.v143.default.props",
"Microsoft.VCToolsVersion.default.props", // no version suffix - must not match
} {
if err := os.WriteFile(filepath.Join(buildDir, name), nil, 0o644); err != nil {
t.Fatal(err)
}
}
for _, v := range []string{"v180", "not-a-version"} {
if err := os.MkdirAll(filepath.Join(base, "MSBuild", "Microsoft", "VC", v), 0o755); err != nil {
t.Fatal(err)
}
}
env := msbuildEnv(cfg, paths)
for _, n := range []string{"145", "143", "180"} {
if _, ok := env["VCInstallDir_"+n]; !ok {
t.Errorf("expected VCInstallDir_%s to be set", n)
}
if _, ok := env["VCToolsInstallDir_"+n]; !ok {
t.Errorf("expected VCToolsInstallDir_%s to be set", n)
}
}
if _, ok := env["VCInstallDir_not-a-version"]; ok {
t.Error("a directory not matching v<digits> should not have produced a VCInstallDir_ entry")
}
}
func TestToolsetSuffixesDedupsOverlap(t *testing.T) {
base := t.TempDir()
buildDir := filepath.Join(base, "vc", "Auxiliary", "Build")
if err := os.MkdirAll(buildDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(buildDir, "Microsoft.VCToolsVersion.v180.default.props"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(base, "MSBuild", "Microsoft", "VC", "v180"), 0o755); err != nil {
t.Fatal(err)
}
got := toolsetSuffixes(base)
count := 0
for _, n := range got {
if n == "180" {
count++
}
}
if count != 1 {
t.Errorf("toolsetSuffixes() returned %q with %d entries for \"180\" (from both sources), want exactly 1", got, count)
}
}
func TestMsbuildGlobalArgsForcesWindowsTargetPlatformVersion(t *testing.T) {
cfg := &wineenv.Config{SDKVer: "10.0.26100.0"}
got := msbuildGlobalArgs(cfg, []string{"Foo.sln", "/p:Configuration=Release"})
want := "/p:WindowsTargetPlatformVersion=10.0.26100.0"
found := false
for _, a := range got {
if a == want {
found = true
}
}
if !found {
t.Errorf("msbuildGlobalArgs(...) = %v, want it to contain %q", got, want)
}
}
func TestMsbuildGlobalArgsRespectsExplicitOverride(t *testing.T) {
cfg := &wineenv.Config{SDKVer: "10.0.26100.0"}
for _, explicit := range []string{
"/p:WindowsTargetPlatformVersion=10.0.19041.0",
"-p:WindowsTargetPlatformVersion=10.0.19041.0",
"/property:WindowsTargetPlatformVersion=10.0.19041.0",
} {
got := msbuildGlobalArgs(cfg, []string{"Foo.sln", explicit})
for _, a := range got {
if reGlobalProp("WindowsTargetPlatformVersion").MatchString(a) {
t.Errorf("msbuildGlobalArgs with explicit %q also injected %q - should have left the caller's value alone", explicit, a)
}
}
}
}
func TestMsbuildEnvDetectsWDKContentRoot(t *testing.T) {
cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"}
paths, base := newTestPaths(t, cfg)
if err := os.MkdirAll(filepath.Join(base, "wdk", "x64", "c"), 0o755); err != nil {
t.Fatal(err)
}
env := msbuildEnv(cfg, paths)
if env["WDKContentRoot"] == "" {
t.Error("expected WDKContentRoot to be set once wdk/x64/c exists on disk")
}
if env["WDKBuildFolder"] != cfg.SDKVer {
t.Errorf(`env["WDKBuildFolder"] = %q, want %q`, env["WDKBuildFolder"], cfg.SDKVer)
}
}
func TestMsbuildEnvPreferredToolArchitecture(t *testing.T) {
// PreferredToolArchitecture should only be set when the host toolset
// bin dir is the 64-bit ("amd64") .NET host - not for arm64.
cfg64 := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "1", SDKVer: "1"}
paths64, _ := newTestPaths(t, cfg64)
if env := msbuildEnv(cfg64, paths64); env["PreferredToolArchitecture"] != "x64" {
t.Errorf(`with DotnetHost=amd64, PreferredToolArchitecture = %q, want "x64"`, env["PreferredToolArchitecture"])
}
cfgARM := &wineenv.Config{Arch: "arm64", Host: "arm64", DotnetHost: "arm64", MSVCVer: "1", SDKVer: "1"}
pathsARM, _ := newTestPaths(t, cfgARM)
if env := msbuildEnv(cfgARM, pathsARM); env["PreferredToolArchitecture"] != "" {
t.Errorf(`with DotnetHost=arm64, PreferredToolArchitecture = %q, want unset`, env["PreferredToolArchitecture"])
}
}
func TestMsbuildNodeReuseArgsForcesOffByDefault(t *testing.T) {
got := msbuildNodeReuseArgs([]string{"Foo.sln", "/p:Configuration=Release"})
want := []string{"/nodeReuse:false"}
if len(got) != 1 || got[0] != want[0] {
t.Errorf("msbuildNodeReuseArgs(...) = %v, want %v", got, want)
}
}
func TestMsbuildNodeReuseArgsRespectsExplicitOverride(t *testing.T) {
for _, explicit := range []string{
"/nodeReuse:true",
"-nodeReuse:true",
"/nr:true",
"/NODEREUSE:FALSE", // caller explicitly wanting it off too - still shouldn't double up
} {
got := msbuildNodeReuseArgs([]string{"Foo.sln", explicit})
if got != nil {
t.Errorf("msbuildNodeReuseArgs with explicit %q = %v, want nil (left alone)", explicit, got)
}
}
}
+16 -5
View File
@@ -1,6 +1,7 @@
package wrapper package wrapper
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
) )
@@ -25,11 +26,21 @@ func execInherit(args []string) int {
if len(args) == 0 { if len(args) == 0 {
return 0 return 0
} }
cmd := exec.Command(args[0], args[1:]...) tc, cleanup := newToolCommand(args[0], args[1:]...)
cmd.Stdin = os.Stdin defer cleanup()
cmd.Stdout = os.Stdout tc.Stdin = os.Stdin
cmd.Stderr = os.Stderr tc.Stdout = os.Stdout
if err := cmd.Run(); err != nil { tc.Stderr = os.Stderr
if err := tc.Start(); err != nil {
return 127
}
stopSignals := forwardSignals(tc.Process)
defer stopSignals()
if err := tc.Wait(); err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode() return exitErr.ExitCode()
} }
+160 -64
View File
@@ -10,49 +10,71 @@ import (
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
"time"
"github.com/Cheviiot/msvc-go-wine/internal/wineenv" "github.com/Cheviiot/vintner/internal/wineenv"
) )
// toolRelayName is where `msvc-go-wine install` places the compiled // pipeDrainGrace bounds how long we wait for a tool's stdout/stderr copy
// goroutines to see EOF after the tool's own process has already exited.
// Wine keeps wineserver and its service processes (services.exe,
// winedevice.exe, explorer.exe, ...) running in the background for reuse
// across invocations, and they inherit our pipes' write ends - so EOF can
// otherwise never arrive, hanging any caller piping our output (`| tee`,
// `| tail`, CI log capture) long after the actual build finished.
const pipeDrainGrace = 500 * time.Millisecond
// toolRelayName is where `vintner install` places the compiled
// toolrelay.exe helper, shared across all arch bin dirs. // toolrelay.exe helper, shared across all arch bin dirs.
const toolRelayName = "toolrelay.exe" const toolRelayName = "toolrelay.exe"
// Run executes the named multi-call tool with args, exactly as the original // Run executes the named multi-call tool with args, exactly as the original
// bash wrappers would, and returns the process exit code. // bash wrappers would, and returns the process exit code.
func Run(tool string, args []string) int { //
// binDir is the <dest>/bin/<arch> directory holding env.json for this
// invocation. Pass "" for the ordinary multi-call case (invoked as `cl`,
// `link`, etc. via a same-directory symlink) to have it resolved from the
// running binary's own location; a non-empty value is for `vintner <tool>
// ...` direct dispatch (see cmd/vintner's runTool), which isn't running
// from inside any particular <dest>/bin/<arch> and so has nothing to
// resolve on its own.
func Run(tool string, args []string, binDir string) int {
if nativeTools[tool] { if nativeTools[tool] {
return runNative(tool, args) return runNative(tool, args)
} }
s, ok := Tools[tool] s, ok := Tools[tool]
if !ok { if !ok {
fmt.Fprintf(os.Stderr, "msvc-go-wine: unknown tool %q\n", tool) fmt.Fprintf(os.Stderr, "vintner: unknown tool %q\n", tool)
return 127 return 127
} }
scriptDir := binDir
if scriptDir == "" {
// os.Executable() (backed by /proc/self/exe on Linux) fully resolves // os.Executable() (backed by /proc/self/exe on Linux) fully resolves
// symlinks, unlike os.Args[0]: not every shell passes a PATH-resolved // symlinks, unlike os.Args[0]: not every shell passes a
// absolute path as argv[0] (some just pass the bare command name), which // PATH-resolved absolute path as argv[0] (some just pass the bare
// would make an argv[0]-based lookup resolve against the caller's cwd // command name), which would make an argv[0]-based lookup resolve
// instead of the actual install dir. `install` sets each arch dir up // against the caller's cwd instead of the actual install dir.
// with its own local copy of the binary precisely so this resolves to // `install` sets each arch dir up with its own local copy of the
// <dest>/bin/<arch>, not <dest>/bin. // binary precisely so this resolves to <dest>/bin/<arch>, not
// <dest>/bin.
exePath, err := os.Executable() exePath, err := os.Executable()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
scriptDir := filepath.Dir(exePath) scriptDir = filepath.Dir(exePath)
}
cfg, err := wineenv.Load(scriptDir) cfg, err := wineenv.Load(scriptDir)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine: loading install config:", err) fmt.Fprintln(os.Stderr, "vintner: loading install config:", err)
return 1 return 1
} }
baseUnix, err := wineenv.FindBaseUnix(scriptDir) baseUnix, err := wineenv.FindBaseUnix(scriptDir)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine: locating installation root:", err) fmt.Fprintln(os.Stderr, "vintner: locating installation root:", err)
return 1 return 1
} }
paths := wineenv.NewPaths(cfg, baseUnix) paths := wineenv.NewPaths(cfg, baseUnix)
@@ -61,7 +83,7 @@ func Run(tool string, args []string) int {
wineBin, err := wineenv.FindWine() wineBin, err := wineenv.FindWine()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
@@ -70,28 +92,33 @@ func Run(tool string, args []string) int {
var exitCode int var exitCode int
switch { switch {
case s.rawStdout: case s.rawStdout:
// MSBuild: skip all filtering/toolrelay, inherit stdio directly, and // MSBuild: skip all filtering/toolrelay (its output is meant to be
// add the extra environment MSBuild's own toolset/SDK-detection // read as-is), and add the extra environment MSBuild's own
// props need on top of the generic INCLUDE/LIB/WINEPATH. // toolset/SDK-detection props need on top of the generic
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...) // INCLUDE/LIB/WINEPATH, plus any global properties a project file
// itself could otherwise override (see msbuildGlobalArgs) and a
// forced /nodeReuse:false (see msbuildNodeReuseArgs).
msArgs := append(msbuildGlobalArgs(cfg, rewritten), rewritten...)
msArgs = append(msbuildNodeReuseArgs(rewritten), msArgs...)
tc, cleanup := newToolCommand(wineBin, append([]string{toolExePath}, msArgs...)...)
defer cleanup()
env := buildEnv(paths) env := buildEnv(paths)
for k, v := range msbuildEnv(cfg, paths) { for k, v := range msbuildEnv(cfg, paths) {
env = append(env, k+"="+v) env = append(env, k+"="+v)
} }
cmd.Env = env tc.Env = env
cmd.Stdin = os.Stdin tc.Stdin = os.Stdin
cmd.Stdout = os.Stdout exitCode = runRawStdout(tc)
cmd.Stderr = os.Stderr
exitCode = runAndWait(cmd)
default: default:
relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName) relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName)
if fi, err := os.Stat(relay); err == nil && !fi.IsDir() { if fi, err := os.Stat(relay); err == nil && !fi.IsDir() {
exitCode = runViaToolRelay(wineBin, relay, toolExePath, rewritten, paths, s.stdoutFilter, s.stderrFilter) exitCode = runViaToolRelay(wineBin, relay, toolExePath, rewritten, paths, s.stdoutFilter, s.stderrFilter)
} else { } else {
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...) tc, cleanup := newToolCommand(wineBin, append([]string{toolExePath}, rewritten...)...)
cmd.Env = buildEnv(paths) defer cleanup()
cmd.Stdin = os.Stdin tc.Env = buildEnv(paths)
exitCode = runFiltered(cmd, s.stdoutFilter, s.stderrFilter) tc.Stdin = os.Stdin
exitCode = runFiltered(tc, s.stdoutFilter, s.stderrFilter)
} }
} }
@@ -110,34 +137,37 @@ func Run(tool string, args []string) int {
// observes the real 32-bit exit code via Win32 before translating and // observes the real 32-bit exit code via Win32 before translating and
// re-exiting with a value that fits in a byte. // 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 { 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())) stdoutFifo := filepath.Join(os.TempDir(), fmt.Sprintf("vintner.stdout.%d", os.Getpid()))
stderrFifo := filepath.Join(os.TempDir(), fmt.Sprintf("msvc-go-wine.stderr.%d", os.Getpid())) stderrFifo := filepath.Join(os.TempDir(), fmt.Sprintf("vintner.stderr.%d", os.Getpid()))
os.Remove(stdoutFifo) os.Remove(stdoutFifo)
os.Remove(stderrFifo) os.Remove(stderrFifo)
if err := syscall.Mkfifo(stdoutFifo, 0o600); err != nil { if err := syscall.Mkfifo(stdoutFifo, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
defer os.Remove(stdoutFifo) defer os.Remove(stdoutFifo)
if err := syscall.Mkfifo(stderrFifo, 0o600); err != nil { if err := syscall.Mkfifo(stderrFifo, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
defer os.Remove(stderrFifo) defer os.Remove(stderrFifo)
cmdArgs := append([]string{relayExe, exePath}, args...) cmdArgs := append([]string{relayExe, exePath}, args...)
cmd := exec.Command(wineBin, cmdArgs...) tc, cleanup := newToolCommand(wineBin, cmdArgs...)
cmd.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo) defer cleanup()
tc.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo)
if devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil { if devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil {
defer devNull.Close() defer devNull.Close()
cmd.Stdout = devNull tc.Stdout = devNull
cmd.Stderr = devNull tc.Stderr = devNull
} }
if err := cmd.Start(); err != nil { if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stopSignals := forwardSignals(tc.Process)
defer stopSignals()
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(2) wg.Add(2)
@@ -160,19 +190,80 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
pumpLines(f, os.Stderr, stderrF) pumpLines(f, os.Stderr, stderrF)
}() }()
err := cmd.Wait() err := tc.Wait()
wg.Wait() wg.Wait()
if err != nil { if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode() return exitErr.ExitCode()
} }
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
return 0 return 0
} }
// runRawStdout runs cmd, copying its stdout/stderr through byte-for-byte
// (MSBuild's own console formatting is meant to reach the user as-is). It
// pipes rather than inheriting os.Stdout/os.Stderr directly so that only our
// own copy goroutines - not the caller's terminal or pipe - are exposed to
// Wine's background processes holding those descriptors open; see
// pipeDrainGrace.
func runRawStdout(tc *toolCommand) int {
stdout, err := tc.StdoutPipe()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
stderr, err := tc.StderrPipe()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
stopSignals := forwardSignals(tc.Process)
defer stopSignals()
doneOut := make(chan struct{})
doneErr := make(chan struct{})
go func() { io.Copy(os.Stdout, stdout); close(doneOut) }()
go func() { io.Copy(os.Stderr, stderr); close(doneErr) }()
err = tc.Wait()
drain(doneOut)
drain(doneErr)
if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
return 0
}
// drain waits for a pipe-copy goroutine to see EOF, but not past
// pipeDrainGrace - see its doc comment for why EOF can otherwise never come.
func drain(done <-chan struct{}) {
select {
case <-done:
case <-time.After(pipeDrainGrace):
}
}
func buildEnv(p *wineenv.Paths) []string { func buildEnv(p *wineenv.Paths) []string {
overrides := map[string]string{ overrides := map[string]string{
"INCLUDE": p.Include, "INCLUDE": p.Include,
@@ -202,34 +293,43 @@ func buildEnv(p *wineenv.Paths) []string {
// runFiltered streams stdout/stderr line by line through the tool's // runFiltered streams stdout/stderr line by line through the tool's
// filters (CR-stripping always applied first), then waits for completion. // filters (CR-stripping always applied first), then waits for completion.
func runFiltered(cmd *exec.Cmd, stdoutF, stderrF lineFilter) int { func runFiltered(tc *toolCommand, stdoutF, stderrF lineFilter) int {
stdout, err := cmd.StdoutPipe() stdout, err := tc.StdoutPipe()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stderr, err := cmd.StderrPipe() stderr, err := tc.StderrPipe()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
if err := cmd.Start(); err != nil { if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stopSignals := forwardSignals(tc.Process)
defer stopSignals()
var wg sync.WaitGroup doneOut := make(chan struct{})
wg.Add(2) doneErr := make(chan struct{})
go func() { defer wg.Done(); pumpLines(stdout, os.Stdout, stdoutF) }() go func() { pumpLines(stdout, os.Stdout, stdoutF); close(doneOut) }()
go func() { defer wg.Done(); pumpLines(stderr, os.Stderr, stderrF) }() go func() { pumpLines(stderr, os.Stderr, stderrF); close(doneErr) }()
wg.Wait()
if err := cmd.Wait(); err != nil { err = tc.Wait()
drain(doneOut)
drain(doneErr)
if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode() return exitErr.ExitCode()
} }
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
return 0 return 0
@@ -247,15 +347,11 @@ func pumpLines(r io.Reader, w *os.File, filter lineFilter) {
} }
fmt.Fprintln(w, line) fmt.Fprintln(w, line)
} }
// bufio.Scanner silently stops (dropping the rest of the stream) once a
// single line exceeds its 16MB buffer - surface that rather than letting
// build output vanish without explanation (heavily templated C++ error
// messages are the realistic way to hit this).
if err := scanner.Err(); err != nil {
fmt.Fprintf(w, "vintner: output truncated: %v\n", err)
} }
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
} }
+68
View File
@@ -0,0 +1,68 @@
package wrapper
import (
"os"
"os/exec"
"os/signal"
"syscall"
"time"
)
// killGrace bounds how long a forwarded SIGINT/SIGTERM gets to make a
// subprocess tree exit on its own before escalating to SIGKILL - long
// enough for wineserver to tear down a Windows process tree cleanly, short
// enough that an unresponsive one doesn't hang vintner's own shutdown.
const killGrace = 5 * time.Second
// setNewProcessGroup puts cmd's eventual child in its own process group
// (pgid = its own pid) instead of inheriting vintner's. Without this, a
// caller that signals vintner by PID alone (a CI runner enforcing a
// timeout, a supervisor's `kill <pid>`) never reaches the wine/wineserver
// tree underneath it, which is then reparented to init and keeps running -
// wasting CPU, holding file locks, leaving stray FIFOs/temp files behind.
// (Interactive Ctrl-C already reaches every process in the terminal's
// foreground group regardless of this, but forwardSignals below handles
// that case too now that the child has moved to its own group.)
func setNewProcessGroup(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// forwardSignals relays SIGINT/SIGTERM received by vintner itself to
// proc's entire process group (proc must have been started via a cmd that
// called setNewProcessGroup, making proc.Pid also the group id), escalating
// to SIGKILL after killGrace if the group hasn't exited by then. Callers
// must call the returned stop func once the process has actually exited
// (e.g. right after cmd.Wait() returns), both to stop listening for
// signals and to cancel a pending escalation.
func forwardSignals(proc *os.Process) (stop func()) {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
done := make(chan struct{})
go func() {
pgid := -proc.Pid
for {
select {
case sig := <-sigCh:
s, ok := sig.(syscall.Signal)
if !ok {
continue
}
_ = syscall.Kill(pgid, s)
select {
case <-time.After(killGrace):
_ = syscall.Kill(pgid, syscall.SIGKILL)
case <-done:
return
}
case <-done:
return
}
}
}()
return func() {
signal.Stop(sigCh)
close(done)
}
}
+43
View File
@@ -0,0 +1,43 @@
package wrapper
import (
"os"
"os/exec"
"syscall"
"testing"
"time"
)
// TestForwardSignalsKillsChild verifies the actual mechanism that keeps a
// wine subprocess from being orphaned: a SIGTERM delivered to the current
// process (mimicking `kill <vintner-pid>`, not an interactive Ctrl-C) must
// reach a child started with setNewProcessGroup, even though it's no longer
// in the same process group.
func TestForwardSignalsKillsChild(t *testing.T) {
cmd := exec.Command("sleep", "30")
setNewProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("starting sleep: %v", err)
}
stop := forwardSignals(cmd.Process)
defer stop()
// signal.Notify (inside forwardSignals) intercepts this rather than
// letting it terminate the test binary itself.
if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil {
t.Fatalf("signaling self: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err == nil {
t.Fatal("expected the child to be killed by the forwarded signal, but it exited successfully")
}
case <-time.After(3 * time.Second):
cmd.Process.Kill()
t.Fatal("child was still running 3s after the signal should have been forwarded")
}
}
+97
View File
@@ -0,0 +1,97 @@
package wrapper
import (
"context"
"fmt"
"os"
"os/exec"
"syscall"
"time"
)
// waitDelay bounds how long cmd.Wait() itself may block after the process
// group has already been told to die (by a timeout or a forwarded signal) -
// usually resolved immediately, but a wedged Wine transport is exactly the
// case that isn't guaranteed to notice a plain SIGKILL right away.
const waitDelay = 5 * time.Second
// commandTimeout returns how long a single tool invocation may run before
// vintner kills it and reports a timeout, from VINTNER_TIMEOUT (a
// time.ParseDuration string, e.g. "30m", "2h"). Unset, empty, or invalid
// all mean "no timeout" (0) - the default stays a plain, unbounded build,
// matching every real build observed so far (Ogre3D's from-scratch build
// alone ran several minutes). This exists for exactly one failure mode: a
// wedged Wine-hosted process (a corrupted MSBuild node-reuse worker in the
// one confirmed case so far, but nothing about the mechanism is
// MSBuild-specific) that will otherwise never exit on its own, hanging
// vintner - and whatever's waiting on vintner - forever with no feedback.
// Automated/scripted callers that would rather fail loudly after N minutes
// than risk hanging indefinitely can set this; interactive use is
// unaffected unless it's set.
func commandTimeout() time.Duration {
v := os.Getenv("VINTNER_TIMEOUT")
if v == "" {
return 0
}
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
return 0
}
return d
}
// toolCommand wraps the exec.Cmd every wine-hosted tool invocation is built
// from, plus enough state to tell a VINTNER_TIMEOUT kill apart from every
// other failure once Wait() returns.
type toolCommand struct {
*exec.Cmd
ctx context.Context
cancel context.CancelFunc
timeout time.Duration // 0 if VINTNER_TIMEOUT wasn't set
}
// newToolCommand builds a toolCommand: its own process group
// (setNewProcessGroup) and, when VINTNER_TIMEOUT is set, a deadline that
// kills the *whole group* - not just the immediate `wine` process, since a
// wedged Wine-hosted child surviving past its parent is exactly the
// scenario this needs to reach - if the tool hasn't finished in time.
//
// Callers must defer the returned cleanup func, and should call
// timedOut() after Wait() returns to tell a timeout-triggered kill apart
// from every other failure.
func newToolCommand(name string, args ...string) (tc *toolCommand, cleanup func()) {
timeout := commandTimeout()
if timeout <= 0 {
cmd := exec.Command(name, args...)
setNewProcessGroup(cmd)
return &toolCommand{Cmd: cmd, ctx: context.Background()}, func() {}
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
cmd := exec.CommandContext(ctx, name, args...)
setNewProcessGroup(cmd)
// cmd.Cancel's default (Go 1.20+) only signals the immediate child;
// override it to reach the whole process group, same as
// forwardSignals - the wedged process a timeout exists to clean up is
// typically under wine, not wine itself.
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
cmd.WaitDelay = waitDelay
tc = &toolCommand{Cmd: cmd, ctx: ctx, cancel: cancel, timeout: timeout}
return tc, cancel
}
// timedOut reports whether this command was killed by its own
// VINTNER_TIMEOUT deadline rather than exiting (however it exited) on its
// own - call after Wait() returns a non-nil error.
func (tc *toolCommand) timedOut() bool {
return tc.ctx.Err() == context.DeadlineExceeded
}
func (tc *toolCommand) timeoutMessage() string {
return fmt.Sprintf("vintner: %s: timed out after %s (VINTNER_TIMEOUT), killed", tc.Path, tc.timeout)
}
+82
View File
@@ -0,0 +1,82 @@
package wrapper
import (
"syscall"
"testing"
"time"
)
func TestCommandTimeout(t *testing.T) {
for _, tc := range []struct {
name string
env string
want time.Duration
}{
{"unset", "", 0},
{"valid", "30m", 30 * time.Minute},
{"invalid unit-less number", "30", 0},
{"zero", "0s", 0},
{"negative", "-5m", 0},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("VINTNER_TIMEOUT", tc.env)
if got := commandTimeout(); got != tc.want {
t.Errorf("commandTimeout() with VINTNER_TIMEOUT=%q = %v, want %v", tc.env, got, tc.want)
}
})
}
}
// TestNewToolCommandKillsOnTimeout is the real end-to-end check: start a
// process that would otherwise run far longer than the timeout (mimicking
// a wedged Wine-hosted tool), and confirm newToolCommand's deadline
// actually kills it - not just that timedOut() would report true in
// principle, but that Wait() actually returns, promptly, with the process
// gone.
func TestNewToolCommandKillsOnTimeout(t *testing.T) {
t.Setenv("VINTNER_TIMEOUT", "300ms")
tc, cleanup := newToolCommand("sleep", "30")
defer cleanup()
if err := tc.Start(); err != nil {
t.Fatalf("starting sleep: %v", err)
}
pid := tc.Process.Pid
done := make(chan error, 1)
go func() { done <- tc.Wait() }()
select {
case err := <-done:
if err == nil {
t.Fatal("expected sleep 30 to be killed by the timeout, but it exited successfully")
}
if !tc.timedOut() {
t.Errorf("Wait() returned an error (%v) but timedOut() = false", err)
}
case <-time.After(5 * time.Second):
t.Fatal("newToolCommand's timeout did not kill the process within 5s of a 300ms deadline")
}
// Belt-and-suspenders: the process should genuinely be gone, not just
// reported as such. Signal 0 sends nothing but still fails with ESRCH
// once the pid is gone - the standard Unix way to probe existence.
if err := syscall.Kill(pid, 0); err == nil {
t.Errorf("pid %d still exists after the timeout killed it", pid)
}
}
func TestNewToolCommandNoTimeoutByDefault(t *testing.T) {
t.Setenv("VINTNER_TIMEOUT", "")
tc, cleanup := newToolCommand("true")
defer cleanup()
if err := tc.Run(); err != nil {
t.Fatalf("running `true` with no VINTNER_TIMEOUT set: %v", err)
}
if tc.timedOut() {
t.Error("timedOut() = true for a command that finished well within any reasonable time, with no timeout configured")
}
}
+31 -1
View File
@@ -4,7 +4,11 @@
// filtering its output. // filtering its output.
package wrapper package wrapper
import "github.com/Cheviiot/msvc-go-wine/internal/wineenv" import (
"sort"
"github.com/Cheviiot/vintner/internal/wineenv"
)
// dirKind selects which install directory a tool's real .exe lives in. // dirKind selects which install directory a tool's real .exe lives in.
type dirKind int type dirKind int
@@ -46,6 +50,32 @@ var Tools = map[string]spec{
// nativeTools are handled entirely without Wine. // nativeTools are handled entirely without Wine.
var nativeTools = map[string]bool{"cmd": true, "findstr": true} var nativeTools = map[string]bool{"cmd": true, "findstr": true}
// IsTool reports whether name is a recognized wrapped tool - either a
// Wine-hosted one in Tools or a native shim in nativeTools. Shared between
// the ordinary multi-call dispatch (invoked *as* one of these names via a
// same-directory symlink) and `vintner <tool> ...` direct dispatch, so both
// recognize exactly the same set of names.
func IsTool(name string) bool {
_, ok := Tools[name]
return ok || nativeTools[name]
}
// ToolNames returns every recognized tool name, sorted - Tools and
// nativeTools combined. Used to generate shell completion without a
// separate hand-maintained list that could drift from the real set (as
// happened once already with a stale --with-dxsdk completion entry).
func ToolNames() []string {
names := make([]string, 0, len(Tools)+len(nativeTools))
for name := range Tools {
names = append(names, name)
}
for name := range nativeTools {
names = append(names, name)
}
sort.Strings(names)
return names
}
func (s spec) exeDir(p *wineenv.Paths) string { func (s spec) exeDir(p *wineenv.Paths) string {
switch s.dir { switch s.dir {
case dirSDK: case dirSDK:
+63
View File
@@ -0,0 +1,63 @@
package wrapper
import (
"testing"
"github.com/Cheviiot/vintner/internal/wineenv"
)
func TestSpecExeDir(t *testing.T) {
paths := &wineenv.Paths{
BinDir: "/bin-dir",
SDKBinDir: "/sdk-bin-dir",
MSBuildBinDir: "/msbuild-bin-dir",
}
for _, tc := range []struct {
name string
dir dirKind
want string
}{
{"dirBin", dirBin, "/bin-dir"},
{"dirSDK", dirSDK, "/sdk-bin-dir"},
{"dirMSBuild", dirMSBuild, "/msbuild-bin-dir"},
} {
s := spec{dir: tc.dir}
if got := s.exeDir(paths); got != tc.want {
t.Errorf("%s: exeDir() = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestToolsAndNativeToolsAreDisjoint(t *testing.T) {
for name := range Tools {
if nativeTools[name] {
t.Errorf("%q is in both Tools and nativeTools", name)
}
}
}
func TestEveryToolHasAnExeName(t *testing.T) {
for name, s := range Tools {
if s.exeName == "" {
t.Errorf("Tools[%q] has no exeName", name)
}
}
}
func TestIsTool(t *testing.T) {
for name := range Tools {
if !IsTool(name) {
t.Errorf("IsTool(%q) = false, want true (it's in Tools)", name)
}
}
for name := range nativeTools {
if !IsTool(name) {
t.Errorf("IsTool(%q) = false, want true (it's in nativeTools)", name)
}
}
for _, name := range []string{"download", "install", "env", "version", "help", "completion", "frobnicate", ""} {
if IsTool(name) {
t.Errorf("IsTool(%q) = true, want false", name)
}
}
}