From ff8748123b36bded382700844091cb949088291b Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 27 May 2026 12:20:15 +0300 Subject: [PATCH] feat: auto-setup tool + docs update (v0.5.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added cmd/setup — downloads correct wgpu-native v29 binary for current platform. Public API in setup/ package: Install(), FindLibrary(), Version. Implementation in internal/nativelib/ with tests (11 pass). Updated README: auto-setup section, gogpu/wgpu integration section. Updated CHANGELOG: v0.5.0 date fixed, v0.5.1 entry. deps: goffi v0.5.0 → v0.5.2 --- CHANGELOG.md | 14 +++- README.md | 27 ++++++- cmd/setup/main.go | 26 +++++++ go.mod | 4 +- go.sum | 8 +- internal/nativelib/download.go | 71 ++++++++++++++++++ internal/nativelib/download_test.go | 106 ++++++++++++++++++++++++++ internal/nativelib/platform.go | 111 ++++++++++++++++++++++++++++ internal/nativelib/platform_test.go | 90 ++++++++++++++++++++++ setup/setup.go | 87 ++++++++++++++++++++++ setup/setup_test.go | 21 ++++++ 11 files changed, 555 insertions(+), 10 deletions(-) create mode 100644 cmd/setup/main.go create mode 100644 internal/nativelib/download.go create mode 100644 internal/nativelib/download_test.go create mode 100644 internal/nativelib/platform.go create mode 100644 internal/nativelib/platform_test.go create mode 100644 setup/setup.go create mode 100644 setup/setup_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 192157e..39fec23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## v0.5.0 (Unreleased) +## v0.5.1 (2026-05-27) + +### Added + +- **Auto-setup tool** — `go run github.com/go-webgpu/webgpu/cmd/setup@latest` downloads correct wgpu-native binary for your platform +- **setup package** — public API: `setup.Install(dir)`, `setup.FindLibrary()`, `setup.Version` +- **internal/nativelib** — platform detection, download, zip extraction with tests + +### Changed + +- **deps:** goffi v0.5.0 → v0.5.2 + +## v0.5.0 (2026-05-26) ### Breaking Changes - **wgpu-native v29.0.0.0**: Migrated from v27.0.4.0 to v29.0.0.0 with stable webgpu-headers diff --git a/README.md b/README.md index 3945f3b..114a6dd 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,34 @@ Pure Go WebGPU bindings using [goffi](https://github.com/go-webgpu/goffi) + [wgp go get github.com/go-webgpu/webgpu ``` -Download wgpu-native and place `wgpu_native.dll` (Windows) or `libwgpu_native.so` (Linux) in your project directory or system PATH. +### wgpu-native Setup (auto) -To use a custom library location: ```bash -export WGPU_NATIVE_PATH=/path/to/libwgpu_native.so +go run github.com/go-webgpu/webgpu/cmd/setup@latest ``` +This downloads the correct wgpu-native v29 binary for your platform (Windows/macOS/Linux, amd64/arm64) into `./lib/`. + +### wgpu-native Setup (manual) + +Download from [gfx-rs/wgpu-native releases](https://github.com/gfx-rs/wgpu-native/releases/tag/v29.0.0.0) and place in your project directory or system PATH. + +Custom library location: +```bash +export WGPU_NATIVE_PATH=/path/to/libwgpu_native.so # Linux/macOS +set WGPU_NATIVE_PATH=C:\path\to\wgpu_native.dll # Windows +``` + +## gogpu/wgpu Integration + +This library is the **Rust FFI backend** for [gogpu/wgpu](https://github.com/gogpu/wgpu) — the unified Go WebGPU package. Build with `-tags rust` to use wgpu-native instead of the Pure Go implementation: + +```bash +go build -tags rust ./myapp +``` + +Same API, same types, same user code — build tag selects the implementation. + ## Type System WebGPU types from [gputypes](https://github.com/gogpu/gputypes) are re-exported as type aliases in the `wgpu` package. A single import is sufficient: diff --git a/cmd/setup/main.go b/cmd/setup/main.go new file mode 100644 index 0000000..130b1de --- /dev/null +++ b/cmd/setup/main.go @@ -0,0 +1,26 @@ +// Command setup downloads and installs the wgpu-native library. +// +// Usage: +// +// go run github.com/go-webgpu/webgpu/cmd/setup@latest +// go run github.com/go-webgpu/webgpu/cmd/setup@latest ./path/to/lib +package main + +import ( + "fmt" + "os" + + "github.com/go-webgpu/webgpu/setup" +) + +func main() { + destDir := "lib" + if len(os.Args) > 1 { + destDir = os.Args[1] + } + + if _, err := setup.Install(destDir); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 0c08ae2..2d9faa8 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,8 @@ module github.com/go-webgpu/webgpu go 1.25.0 -require github.com/go-webgpu/goffi v0.5.0 +require github.com/go-webgpu/goffi v0.5.2 require golang.org/x/sys v0.42.0 -require github.com/gogpu/gputypes v0.3.0 +require github.com/gogpu/gputypes v0.5.0 diff --git a/go.sum b/go.sum index d7788da..8579796 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ -github.com/go-webgpu/goffi v0.5.0 h1:EuvVRiRn9qAfCkYYXbHs9gz8NY+zv2/OA1N7gi56UVE= -github.com/go-webgpu/goffi v0.5.0/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= -github.com/gogpu/gputypes v0.3.0 h1:gcwxsBrcoCX19GqqqiV55wLv2iFwaybiOluKCb0hVrs= -github.com/gogpu/gputypes v0.3.0/go.mod h1:cnXrDMwTpWTvJLW1Vreop3PcT6a2YP/i3s91rPaOavw= +github.com/go-webgpu/goffi v0.5.2 h1:Kq2llPlA6IhkkmAffkM5CtsgaXuBQC4mBI0BOREwV04= +github.com/go-webgpu/goffi v0.5.2/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= +github.com/gogpu/gputypes v0.5.0 h1:i2ED/9w6m6yLxf8XJT69/NIMSNTLO2y5F1LqvugCKIE= +github.com/gogpu/gputypes v0.5.0/go.mod h1:cnXrDMwTpWTvJLW1Vreop3PcT6a2YP/i3s91rPaOavw= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/nativelib/download.go b/internal/nativelib/download.go new file mode 100644 index 0000000..8c3d2f7 --- /dev/null +++ b/internal/nativelib/download.go @@ -0,0 +1,71 @@ +package nativelib + +import ( + "archive/zip" + "fmt" + "io" + "net/http" + "os" + "path/filepath" +) + +func Download(url string) (string, error) { + resp, err := http.Get(url) //nolint:gosec // G107: URL constructed from constants + if err != nil { + return "", fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download failed: HTTP %d from %s", resp.StatusCode, url) + } + + tmpFile, err := os.CreateTemp("", "wgpu-native-*.zip") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + defer tmpFile.Close() + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("download write: %w", err) + } + + return tmpFile.Name(), nil +} + +func ExtractLibrary(zipPath, destDir, libName string) (string, error) { + r, err := zip.OpenReader(zipPath) + if err != nil { + return "", fmt.Errorf("open zip: %w", err) + } + defer r.Close() + + for _, f := range r.File { + name := filepath.Base(f.Name) + if name != libName { + continue + } + + src, err := f.Open() + if err != nil { + return "", fmt.Errorf("open %s in zip: %w", f.Name, err) + } + defer src.Close() + + destPath := filepath.Join(destDir, libName) + dst, err := os.Create(destPath) + if err != nil { + return "", fmt.Errorf("create %s: %w", destPath, err) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return "", fmt.Errorf("extract %s: %w", libName, err) + } + + return destPath, nil + } + + return "", fmt.Errorf("%s not found in archive", libName) +} diff --git a/internal/nativelib/download_test.go b/internal/nativelib/download_test.go new file mode 100644 index 0000000..4017a0e --- /dev/null +++ b/internal/nativelib/download_test.go @@ -0,0 +1,106 @@ +package nativelib + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" +) + +func TestExtractLibrary(t *testing.T) { + zipPath := createTestZip(t, "test.dll", []byte("fake dll content")) + destDir := t.TempDir() + + path, err := ExtractLibrary(zipPath, destDir, "test.dll") + if err != nil { + t.Fatalf("ExtractLibrary() error: %v", err) + } + + if filepath.Base(path) != "test.dll" { + t.Errorf("extracted path = %q, want test.dll basename", path) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read extracted file: %v", err) + } + if string(data) != "fake dll content" { + t.Errorf("content = %q, want %q", string(data), "fake dll content") + } +} + +func TestExtractLibraryNestedPath(t *testing.T) { + zipPath := createTestZipNested(t, "lib/wgpu_native.dll", []byte("nested content")) + destDir := t.TempDir() + + path, err := ExtractLibrary(zipPath, destDir, "wgpu_native.dll") + if err != nil { + t.Fatalf("ExtractLibrary() error: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(data) != "nested content" { + t.Errorf("content = %q, want %q", string(data), "nested content") + } +} + +func TestExtractLibraryNotFound(t *testing.T) { + zipPath := createTestZip(t, "other.dll", []byte("data")) + destDir := t.TempDir() + + _, err := ExtractLibrary(zipPath, destDir, "missing.dll") + if err == nil { + t.Fatal("expected error for missing file in archive") + } +} + +func TestExtractLibraryInvalidZip(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "bad.zip") + os.WriteFile(tmpFile, []byte("not a zip"), 0o644) + + _, err := ExtractLibrary(tmpFile, t.TempDir(), "test.dll") + if err == nil { + t.Fatal("expected error for invalid zip") + } +} + +func createTestZip(t *testing.T, name string, content []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "test.zip") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + w := zip.NewWriter(f) + entry, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + entry.Write(content) + w.Close() + return path +} + +func createTestZipNested(t *testing.T, name string, content []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "test.zip") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + w := zip.NewWriter(f) + entry, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + entry.Write(content) + w.Close() + return path +} diff --git a/internal/nativelib/platform.go b/internal/nativelib/platform.go new file mode 100644 index 0000000..9d6358c --- /dev/null +++ b/internal/nativelib/platform.go @@ -0,0 +1,111 @@ +package nativelib + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +const ( + libWindows = "wgpu_native.dll" + libDarwin = "libwgpu_native.dylib" + libLinux = "libwgpu_native.so" +) + +type Platform struct { + OS string + Arch string + LibName string +} + +func DetectPlatform() (*Platform, error) { + p := &Platform{OS: runtime.GOOS, Arch: runtime.GOARCH} + + switch p.OS { + case "windows": + p.LibName = libWindows + case "darwin": + p.LibName = libDarwin + case "linux": + p.LibName = libLinux + default: + return nil, fmt.Errorf("unsupported OS: %s", p.OS) + } + + if p.Arch != "amd64" && p.Arch != "arm64" { + return nil, fmt.Errorf("unsupported architecture: %s", p.Arch) + } + + return p, nil +} + +func (p *Platform) archName() string { + if p.Arch == "amd64" { + return "x86_64" + } + return "aarch64" +} + +func (p *Platform) ZipName() string { + arch := p.archName() + switch p.OS { + case "windows": + return fmt.Sprintf("wgpu-%s-%s-msvc-release.zip", p.OS, arch) + case "darwin": + return fmt.Sprintf("wgpu-macos-%s-release.zip", arch) + case "linux": + return fmt.Sprintf("wgpu-%s-%s-release.zip", p.OS, arch) + } + return "" +} + +func (p *Platform) DownloadURL(version string) string { + return fmt.Sprintf("https://github.com/gfx-rs/wgpu-native/releases/download/%s/%s", version, p.ZipName()) +} + +func LibraryName() string { + switch runtime.GOOS { + case "windows": + return libWindows + case "darwin": + return libDarwin + default: + return libLinux + } +} + +func FindLibrary() string { + if p := os.Getenv("WGPU_NATIVE_PATH"); p != "" { + if _, err := os.Stat(p); err == nil { + return p + } + } + + libName := LibraryName() + searchPaths := []string{ + filepath.Join(".", libName), + filepath.Join("lib", libName), + } + + if exe, err := os.Executable(); err == nil { + searchPaths = append(searchPaths, filepath.Join(filepath.Dir(exe), libName)) + } + + for _, p := range searchPaths { + if _, err := os.Stat(p); err == nil { + abs, _ := filepath.Abs(p) + return abs + } + } + + for _, dir := range strings.Split(os.Getenv("PATH"), string(os.PathListSeparator)) { + p := filepath.Join(dir, libName) + if _, err := os.Stat(p); err == nil { + return p + } + } + + return "" +} diff --git a/internal/nativelib/platform_test.go b/internal/nativelib/platform_test.go new file mode 100644 index 0000000..ca86a64 --- /dev/null +++ b/internal/nativelib/platform_test.go @@ -0,0 +1,90 @@ +package nativelib + +import ( + "runtime" + "testing" +) + +func TestDetectPlatform(t *testing.T) { + p, err := DetectPlatform() + if err != nil { + t.Fatalf("DetectPlatform() error: %v", err) + } + + if p.OS != runtime.GOOS { + t.Errorf("OS = %q, want %q", p.OS, runtime.GOOS) + } + if p.Arch != runtime.GOARCH { + t.Errorf("Arch = %q, want %q", p.Arch, runtime.GOARCH) + } + if p.LibName == "" { + t.Error("LibName is empty") + } +} + +func TestPlatformZipName(t *testing.T) { + tests := []struct { + os, arch string + want string + }{ + {"windows", "amd64", "wgpu-windows-x86_64-msvc-release.zip"}, + {"windows", "arm64", "wgpu-windows-aarch64-msvc-release.zip"}, + {"darwin", "amd64", "wgpu-macos-x86_64-release.zip"}, + {"darwin", "arm64", "wgpu-macos-aarch64-release.zip"}, + {"linux", "amd64", "wgpu-linux-x86_64-release.zip"}, + {"linux", "arm64", "wgpu-linux-aarch64-release.zip"}, + } + + for _, tt := range tests { + p := &Platform{OS: tt.os, Arch: tt.arch} + got := p.ZipName() + if got != tt.want { + t.Errorf("ZipName(%s/%s) = %q, want %q", tt.os, tt.arch, got, tt.want) + } + } +} + +func TestPlatformDownloadURL(t *testing.T) { + p := &Platform{OS: "windows", Arch: "amd64"} + url := p.DownloadURL("v29.0.0.0") + + expected := "https://github.com/gfx-rs/wgpu-native/releases/download/v29.0.0.0/wgpu-windows-x86_64-msvc-release.zip" + if url != expected { + t.Errorf("DownloadURL() = %q, want %q", url, expected) + } +} + +func TestLibraryName(t *testing.T) { + name := LibraryName() + if name == "" { + t.Error("LibraryName() is empty") + } + + switch runtime.GOOS { + case "windows": + if name != "wgpu_native.dll" { + t.Errorf("LibraryName() = %q, want wgpu_native.dll", name) + } + case "darwin": + if name != "libwgpu_native.dylib" { + t.Errorf("LibraryName() = %q, want libwgpu_native.dylib", name) + } + case "linux": + if name != "libwgpu_native.so" { + t.Errorf("LibraryName() = %q, want libwgpu_native.so", name) + } + } +} + +func TestDetectPlatformUnsupportedArch(t *testing.T) { + // Can't directly test unsupported arch on current platform, + // but verify the function returns valid values for current platform. + p, err := DetectPlatform() + if err != nil { + t.Skip("current platform not supported:", err) + } + + if p.archName() != "x86_64" && p.archName() != "aarch64" { + t.Errorf("archName() = %q, want x86_64 or aarch64", p.archName()) + } +} diff --git a/setup/setup.go b/setup/setup.go new file mode 100644 index 0000000..362d4ce --- /dev/null +++ b/setup/setup.go @@ -0,0 +1,87 @@ +// Package setup provides wgpu-native library installation for go-webgpu. +// +// Usage: +// +// go run github.com/go-webgpu/webgpu/cmd/setup@latest +// +// Or programmatically: +// +// path, err := setup.Install("lib") +// found := setup.FindLibrary() +package setup + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/go-webgpu/webgpu/internal/nativelib" +) + +const Version = "v29.0.0.0" + +// Install downloads and installs the wgpu-native library to destDir. +// If destDir is empty, defaults to "./lib". +// Returns the absolute path to the installed library. +func Install(destDir string) (string, error) { + platform, err := nativelib.DetectPlatform() + if err != nil { + return "", err + } + + if destDir == "" { + destDir = "lib" + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return "", fmt.Errorf("create directory %s: %w", destDir, err) + } + + url := platform.DownloadURL(Version) + fmt.Printf("Downloading wgpu-native %s for %s/%s...\n", Version, platform.OS, platform.Arch) + fmt.Printf("URL: %s\n", url) + + zipPath, err := nativelib.Download(url) + if err != nil { + return "", err + } + defer os.Remove(zipPath) + + fmt.Printf("Extracting %s...\n", platform.LibName) + libPath, err := nativelib.ExtractLibrary(zipPath, destDir, platform.LibName) + if err != nil { + return "", err + } + + absPath, _ := filepath.Abs(libPath) + fmt.Printf("Installed: %s\n\n", absPath) + printUsage(platform, absPath, destDir) + + return absPath, nil +} + +// FindLibrary searches common locations for the wgpu-native library. +// Returns the absolute path if found, empty string otherwise. +// Search order: WGPU_NATIVE_PATH env → ./lib/ → executable dir → PATH. +func FindLibrary() string { + return nativelib.FindLibrary() +} + +func printUsage(platform *nativelib.Platform, absPath, destDir string) { + dir, _ := filepath.Abs(destDir) + + fmt.Println("To use, set environment variable:") + switch platform.OS { + case "windows": + fmt.Printf(" set WGPU_NATIVE_PATH=%s\n", absPath) + default: + fmt.Printf(" export WGPU_NATIVE_PATH=%s\n", absPath) + } + + fmt.Println("\nOr add directory to PATH:") + switch platform.OS { + case "windows": + fmt.Printf(" set PATH=%s;%%PATH%%\n", dir) + default: + fmt.Printf(" export LD_LIBRARY_PATH=%s:$LD_LIBRARY_PATH\n", dir) + } +} diff --git a/setup/setup_test.go b/setup/setup_test.go new file mode 100644 index 0000000..5420ec8 --- /dev/null +++ b/setup/setup_test.go @@ -0,0 +1,21 @@ +package setup + +import ( + "testing" +) + +func TestVersion(t *testing.T) { + if Version == "" { + t.Error("Version is empty") + } + if Version[0] != 'v' { + t.Errorf("Version = %q, should start with 'v'", Version) + } +} + +func TestFindLibrary(t *testing.T) { + // FindLibrary searches for the native library. + // On dev machines it may or may not be found — just verify no panic. + path := FindLibrary() + t.Logf("FindLibrary() = %q", path) +}