mirror of
https://github.com/Cheviiot/Vintner.git
synced 2026-08-03 15:57:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6220d26072 | ||
|
|
de49e80cb7 | ||
|
|
98bac75767 | ||
|
|
5cf52c9f9f | ||
|
|
b366dfa9ac | ||
|
|
738234186d | ||
|
|
f6b9a0811a | ||
|
|
bdea270d71 | ||
|
|
25f751e874 | ||
|
|
4795c7c105 | ||
|
|
91471397fa | ||
|
|
94a19e6b43 | ||
|
|
6186837fd2 | ||
|
|
c049274626 | ||
|
|
fc20b2fb15 | ||
|
|
26f6df6a9a | ||
|
|
98ee39018a | ||
|
|
4b3aacfdf7 | ||
|
|
0b7b686e3a | ||
|
|
0a4f05c673 | ||
|
|
e0475101b2 | ||
|
|
d11b534fa1 | ||
|
|
a1743e4435 | ||
|
|
23ea620ce2 | ||
|
|
8551d7fe99 | ||
|
|
44fee57ddd | ||
|
|
47471e007c |
@@ -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
|
||||||
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
/msvc-go-wine
|
/vintner
|
||||||
*.test
|
*.test
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
+2
-2
@@ -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.
|
||||||
|
|||||||
@@ -1,129 +1,301 @@
|
|||||||
# msvc-go-wine
|
# Vintner
|
||||||
|
|
||||||
Cross compile with MSVC on Linux, using Wine — a single-binary Go tool
|
[](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml)
|
||||||
inspired by [mstorsjo/msvc-wine](https://github.com/mstorsjo/msvc-wine)'s
|
[](https://github.com/Cheviiot/vintner/releases/latest)
|
||||||
approach (download the real MSVC/WinSDK, wrap the compiler under Wine),
|
[](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
-1
@@ -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
|
||||||
|
|||||||
Vendored
+11
-3
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
@@ -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 "$@"
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,3 +1,3 @@
|
|||||||
module github.com/Cheviiot/msvc-go-wine
|
module github.com/Cheviiot/vintner
|
||||||
|
|
||||||
go 1.23
|
go 1.23
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -40,11 +40,15 @@ 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"`
|
||||||
License string `json:"license"`
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
License string `json:"license"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Package is one entry from the installer manifest's "packages" array.
|
// Package is one entry from the installer manifest's "packages" array.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package download
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,8 +15,7 @@ var reSDKVersion = regexp.MustCompile(`^\d+\.\d+\.\d+`)
|
|||||||
// default) explicitly chose to include/exclude the component.
|
// default) explicitly chose to include/exclude the component.
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] выше.",
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"})
|
||||||
|
}
|
||||||
+199
-20
@@ -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{
|
||||||
"DisableRegistryUse": "true",
|
// WDK driver builds stamp the INF's DriverVer with StampInf (which
|
||||||
"VCToolsVersion": cfg.MSVCVer,
|
// uses the local wall-clock date) and then validate it with
|
||||||
"VsInstallRoot": paths.BaseWin + `\`,
|
// Inf2Cat (which checks against UTC "now"). For any timezone east
|
||||||
"VSInstallDir": paths.BaseWin + `\`,
|
// 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",
|
||||||
|
"CheckMSVCComponents": "false",
|
||||||
|
"VCToolsVersion": cfg.MSVCVer,
|
||||||
|
"VsInstallRoot": 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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-68
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// os.Executable() (backed by /proc/self/exe on Linux) fully resolves
|
scriptDir := binDir
|
||||||
// symlinks, unlike os.Args[0]: not every shell passes a PATH-resolved
|
if scriptDir == "" {
|
||||||
// absolute path as argv[0] (some just pass the bare command name), which
|
// os.Executable() (backed by /proc/self/exe on Linux) fully resolves
|
||||||
// would make an argv[0]-based lookup resolve against the caller's cwd
|
// symlinks, unlike os.Args[0]: not every shell passes a
|
||||||
// instead of the actual install dir. `install` sets each arch dir up
|
// PATH-resolved absolute path as argv[0] (some just pass the bare
|
||||||
// with its own local copy of the binary precisely so this resolves to
|
// command name), which would make an argv[0]-based lookup resolve
|
||||||
// <dest>/bin/<arch>, not <dest>/bin.
|
// against the caller's cwd instead of the actual install dir.
|
||||||
exePath, err := os.Executable()
|
// `install` sets each arch dir up with its own local copy of the
|
||||||
if err != nil {
|
// binary precisely so this resolves to <dest>/bin/<arch>, not
|
||||||
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
|
// <dest>/bin.
|
||||||
return 1
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "vintner:", err)
|
||||||
|
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
|
||||||
func runAndWait(cmd *exec.Cmd) int {
|
// build output vanish without explanation (heavily templated C++ error
|
||||||
if err := cmd.Run(); err != nil {
|
// messages are the realistic way to hit this).
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
if err := scanner.Err(); err != nil {
|
||||||
return exitErr.ExitCode()
|
fmt.Fprintf(w, "vintner: output truncated: %v\n", err)
|
||||||
}
|
|
||||||
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user