mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-15 23:25:49 +01:00
* feat(linux): add libpath package for finding native library paths
Add a new internal/libpath package that locates shared libraries (.so files)
on Linux systems. Supports multiple distributions and package managers.
Features:
- Multi-tier search: pkg-config -> ldconfig -> filesystem scanning
- Parallel search using goroutines for faster lookups
- Cached dynamic path discovery for Flatpak, Snap, and Nix
- Support for Debian/Ubuntu, Fedora/RHEL, Arch, openSUSE, NixOS
- Context-aware cancellation for graceful shutdown
Performance:
- Library found: ~1.4ms (parallel search)
- Library not found: ~46ms (was 84ms sequential)
- Cached path discovery: 14ns (was 15ms uncached)
* feat(libpath): add multi-library parallel search functions
Add functions to search for multiple library candidates in parallel:
- FindFirstLibrary: Search multiple libs in parallel, return first found
- FindFirstLibraryOrdered: Search in order of preference (for version priority)
- FindAllLibraries: Find all available libraries from a list
Useful when the exact library version is unknown, e.g.:
match, _ := FindFirstLibrary("webkit2gtk-4.1", "webkit2gtk-4.0", "webkit2gtk-6.0")
Also adds findLibraryPathCtx for context-aware searching used by the
multi-library functions.
* refactor(libpath): split into separate files and fix race condition
Split libpath_linux.go into smaller, focused files:
- cache_linux.go: Path cache with thread-safe init/invalidate
- flatpak_linux.go: Flatpak runtime path discovery
- snap_linux.go: Snap package path discovery
- nix_linux.go: Nix/NixOS path discovery
- libpath_linux.go: Core search functions
Fixes:
- Fix data race between init() and invalidate() by holding mutex
during cache writes inside sync.Once.Do (CodeRabbit review)
- Fix FindLibraryPathWithOptions not searching dynamic paths
(Flatpak/Snap/Nix) - now uses GetAllLibPaths() (CodeRabbit review)
551 lines
14 KiB
Go
551 lines
14 KiB
Go
//go:build linux
|
|
|
|
package libpath
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Common library search paths on Linux systems
|
|
var defaultLibPaths = []string{
|
|
// Standard paths
|
|
"/usr/lib",
|
|
"/usr/lib64",
|
|
"/lib",
|
|
"/lib64",
|
|
|
|
// Debian/Ubuntu multiarch
|
|
"/usr/lib/x86_64-linux-gnu",
|
|
"/usr/lib/aarch64-linux-gnu",
|
|
"/usr/lib/i386-linux-gnu",
|
|
"/usr/lib/arm-linux-gnueabihf",
|
|
"/lib/x86_64-linux-gnu",
|
|
"/lib/aarch64-linux-gnu",
|
|
|
|
// Fedora/RHEL/CentOS
|
|
"/usr/lib64/gtk-3.0",
|
|
"/usr/lib64/gtk-4.0",
|
|
"/usr/lib/gcc/x86_64-redhat-linux",
|
|
"/usr/lib/gcc/aarch64-redhat-linux",
|
|
|
|
// Arch Linux
|
|
"/usr/lib/webkit2gtk-4.0",
|
|
"/usr/lib/webkit2gtk-4.1",
|
|
"/usr/lib/gtk-3.0",
|
|
"/usr/lib/gtk-4.0",
|
|
|
|
// openSUSE
|
|
"/usr/lib64/gcc/x86_64-suse-linux",
|
|
|
|
// Local installations
|
|
"/usr/local/lib",
|
|
"/usr/local/lib64",
|
|
}
|
|
|
|
// searchResult holds the result from a parallel search goroutine.
|
|
type searchResult struct {
|
|
path string
|
|
source string // for debugging: "pkg-config", "ldconfig", "filesystem"
|
|
}
|
|
|
|
// FindLibraryPath attempts to find the path to a library using multiple methods
|
|
// in parallel. It searches via pkg-config, ldconfig, and filesystem simultaneously,
|
|
// returning as soon as any method finds the library.
|
|
//
|
|
// The libName should be the pkg-config name (e.g., "gtk+-3.0", "webkit2gtk-4.1").
|
|
// Returns the library directory path and any error encountered.
|
|
func FindLibraryPath(libName string) (string, error) {
|
|
return findLibraryPathCtx(context.Background(), libName)
|
|
}
|
|
|
|
// FindLibraryPathSequential is the original sequential implementation.
|
|
// Use this if you need deterministic search order (pkg-config → ldconfig → filesystem).
|
|
func FindLibraryPathSequential(libName string) (string, error) {
|
|
// Try pkg-config first (most reliable when available)
|
|
if path, err := findWithPkgConfig(libName); err == nil {
|
|
return path, nil
|
|
}
|
|
|
|
// Try ldconfig cache
|
|
if path, err := findWithLdconfig(libName); err == nil {
|
|
return path, nil
|
|
}
|
|
|
|
// Fall back to searching common paths
|
|
return findInCommonPaths(libName)
|
|
}
|
|
|
|
// FindLibraryFile finds the full path to a specific library file (e.g., "libgtk-3.so").
|
|
func FindLibraryFile(fileName string) (string, error) {
|
|
// Try ldconfig first
|
|
if path, err := findFileWithLdconfig(fileName); err == nil {
|
|
return path, nil
|
|
}
|
|
|
|
// Search all paths including dynamic ones
|
|
for _, dir := range GetAllLibPaths() {
|
|
// Check exact match
|
|
fullPath := filepath.Join(dir, fileName)
|
|
if _, err := os.Stat(fullPath); err == nil {
|
|
return fullPath, nil
|
|
}
|
|
|
|
// Check with .so suffix variations
|
|
matches, err := filepath.Glob(filepath.Join(dir, fileName+"*"))
|
|
if err == nil && len(matches) > 0 {
|
|
return matches[0], nil
|
|
}
|
|
}
|
|
|
|
return "", &LibraryNotFoundError{Name: fileName}
|
|
}
|
|
|
|
// findWithPkgConfig uses pkg-config to find library paths.
|
|
func findWithPkgConfig(libName string) (string, error) {
|
|
return findWithPkgConfigCtx(context.Background(), libName)
|
|
}
|
|
|
|
// findWithPkgConfigCtx uses pkg-config to find library paths with context support.
|
|
func findWithPkgConfigCtx(ctx context.Context, libName string) (string, error) {
|
|
// Check if already cancelled
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
default:
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "pkg-config", "--libs-only-L", libName)
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Parse -L flags from output
|
|
parts := strings.Fields(string(output))
|
|
for _, part := range parts {
|
|
if strings.HasPrefix(part, "-L") {
|
|
path := strings.TrimPrefix(part, "-L")
|
|
if _, err := os.Stat(path); err == nil {
|
|
return path, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check context before second command
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// If no -L flag, try --variable=libdir
|
|
cmd = exec.CommandContext(ctx, "pkg-config", "--variable=libdir", libName)
|
|
output, err = cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
path := strings.TrimSpace(string(output))
|
|
if path != "" {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return path, nil
|
|
}
|
|
}
|
|
|
|
return "", &LibraryNotFoundError{Name: libName}
|
|
}
|
|
|
|
// findWithLdconfig searches the ldconfig cache for library paths.
|
|
func findWithLdconfig(libName string) (string, error) {
|
|
return findWithLdconfigCtx(context.Background(), libName)
|
|
}
|
|
|
|
// findWithLdconfigCtx searches the ldconfig cache for library paths with context support.
|
|
func findWithLdconfigCtx(ctx context.Context, libName string) (string, error) {
|
|
// Check if already cancelled
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Convert pkg-config name to library name pattern
|
|
// e.g., "gtk+-3.0" -> "libgtk-3", "webkit2gtk-4.1" -> "libwebkit2gtk-4.1"
|
|
searchName := pkgConfigToLibName(libName)
|
|
|
|
cmd := exec.CommandContext(ctx, "ldconfig", "-p")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for _, line := range strings.Split(string(output), "\n") {
|
|
if strings.Contains(line, searchName) {
|
|
// Line format: " libname.so.X (libc6,x86-64) => /path/to/lib"
|
|
parts := strings.Split(line, "=>")
|
|
if len(parts) == 2 {
|
|
libPath := strings.TrimSpace(parts[1])
|
|
return filepath.Dir(libPath), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", &LibraryNotFoundError{Name: libName}
|
|
}
|
|
|
|
// findFileWithLdconfig finds a specific library file using ldconfig.
|
|
func findFileWithLdconfig(fileName string) (string, error) {
|
|
cmd := exec.Command("ldconfig", "-p")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
baseName := strings.TrimSuffix(fileName, ".so")
|
|
for _, line := range strings.Split(string(output), "\n") {
|
|
if strings.Contains(line, baseName) {
|
|
parts := strings.Split(line, "=>")
|
|
if len(parts) == 2 {
|
|
return strings.TrimSpace(parts[1]), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", &LibraryNotFoundError{Name: fileName}
|
|
}
|
|
|
|
// findInCommonPaths searches common library directories including
|
|
// dynamically discovered Flatpak, Snap, and Nix paths.
|
|
func findInCommonPaths(libName string) (string, error) {
|
|
return findInCommonPathsCtx(context.Background(), libName)
|
|
}
|
|
|
|
// findInCommonPathsCtx searches common library directories with context support.
|
|
func findInCommonPathsCtx(ctx context.Context, libName string) (string, error) {
|
|
searchName := pkgConfigToLibName(libName)
|
|
|
|
// Search all paths including dynamic ones
|
|
allPaths := GetAllLibPaths()
|
|
|
|
for _, dir := range allPaths {
|
|
// Check if cancelled periodically
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if _, err := os.Stat(dir); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Look for the library file
|
|
pattern := filepath.Join(dir, searchName+"*.so*")
|
|
matches, err := filepath.Glob(pattern)
|
|
if err == nil && len(matches) > 0 {
|
|
return dir, nil
|
|
}
|
|
|
|
// Also check pkgconfig subdirectory for .pc files
|
|
pcPath := filepath.Join(dir, "pkgconfig", libName+".pc")
|
|
if _, err := os.Stat(pcPath); err == nil {
|
|
return dir, nil
|
|
}
|
|
}
|
|
|
|
return "", &LibraryNotFoundError{Name: libName}
|
|
}
|
|
|
|
// pkgConfigToLibName converts a pkg-config package name to a library name pattern.
|
|
func pkgConfigToLibName(pkgName string) string {
|
|
// Common transformations
|
|
name := pkgName
|
|
|
|
// Remove version suffix like "-3.0", "-4.1"
|
|
// but keep it for webkit2gtk-4.1 style names
|
|
if strings.HasPrefix(name, "gtk+-") {
|
|
// gtk+-3.0 -> libgtk-3
|
|
name = "libgtk-" + strings.TrimPrefix(name, "gtk+-")
|
|
name = strings.Split(name, ".")[0]
|
|
} else if strings.HasPrefix(name, "webkit2gtk-") {
|
|
// webkit2gtk-4.1 -> libwebkit2gtk-4.1
|
|
name = "lib" + name
|
|
} else if !strings.HasPrefix(name, "lib") {
|
|
name = "lib" + name
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
// GetAllLibPaths returns all library paths from LD_LIBRARY_PATH, default paths,
|
|
// and dynamically discovered paths from Flatpak, Snap, and Nix.
|
|
// It does NOT include the current directory for security reasons.
|
|
func GetAllLibPaths() []string {
|
|
var paths []string
|
|
|
|
// Add LD_LIBRARY_PATH entries first (highest priority)
|
|
if ldPath := os.Getenv("LD_LIBRARY_PATH"); ldPath != "" {
|
|
for _, p := range strings.Split(ldPath, ":") {
|
|
if p != "" {
|
|
paths = append(paths, p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add default system paths
|
|
paths = append(paths, defaultLibPaths...)
|
|
|
|
// Add dynamically discovered paths from package managers
|
|
paths = append(paths, getFlatpakLibPaths()...)
|
|
paths = append(paths, getSnapLibPaths()...)
|
|
paths = append(paths, getNixLibPaths()...)
|
|
|
|
return paths
|
|
}
|
|
|
|
// FindOptions controls library search behavior.
|
|
type FindOptions struct {
|
|
// IncludeCurrentDir includes "." in the search path.
|
|
// WARNING: This is a security risk and should only be used for development.
|
|
IncludeCurrentDir bool
|
|
|
|
// ExtraPaths are additional paths to search before the defaults.
|
|
ExtraPaths []string
|
|
}
|
|
|
|
// FindLibraryPathWithOptions attempts to find the path to a library with custom options.
|
|
func FindLibraryPathWithOptions(libName string, opts FindOptions) (string, error) {
|
|
// Try pkg-config first (most reliable when available)
|
|
if path, err := findWithPkgConfig(libName); err == nil {
|
|
return path, nil
|
|
}
|
|
|
|
// Try ldconfig cache
|
|
if path, err := findWithLdconfig(libName); err == nil {
|
|
return path, nil
|
|
}
|
|
|
|
// Build search paths - include all dynamic paths too
|
|
allPaths := GetAllLibPaths()
|
|
searchPaths := make([]string, 0, len(opts.ExtraPaths)+len(allPaths)+1)
|
|
|
|
if opts.IncludeCurrentDir {
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
searchPaths = append(searchPaths, cwd)
|
|
}
|
|
}
|
|
|
|
searchPaths = append(searchPaths, opts.ExtraPaths...)
|
|
searchPaths = append(searchPaths, allPaths...)
|
|
|
|
// Search the paths
|
|
searchName := pkgConfigToLibName(libName)
|
|
for _, dir := range searchPaths {
|
|
if _, err := os.Stat(dir); err != nil {
|
|
continue
|
|
}
|
|
|
|
pattern := filepath.Join(dir, searchName+"*.so*")
|
|
matches, err := filepath.Glob(pattern)
|
|
if err == nil && len(matches) > 0 {
|
|
return dir, nil
|
|
}
|
|
|
|
pcPath := filepath.Join(dir, "pkgconfig", libName+".pc")
|
|
if _, err := os.Stat(pcPath); err == nil {
|
|
return dir, nil
|
|
}
|
|
}
|
|
|
|
return "", &LibraryNotFoundError{Name: libName}
|
|
}
|
|
|
|
// LibraryNotFoundError is returned when a library cannot be found.
|
|
type LibraryNotFoundError struct {
|
|
Name string
|
|
}
|
|
|
|
func (e *LibraryNotFoundError) Error() string {
|
|
return "library not found: " + e.Name
|
|
}
|
|
|
|
// LibraryMatch holds information about a found library.
|
|
type LibraryMatch struct {
|
|
// Name is the pkg-config name that was searched for.
|
|
Name string
|
|
// Path is the directory containing the library.
|
|
Path string
|
|
}
|
|
|
|
// FindFirstLibrary searches for multiple libraries in parallel and returns
|
|
// the first one found. This is useful when you don't know the exact version
|
|
// of a library installed (e.g., gtk+-3.0 vs gtk+-4.0).
|
|
//
|
|
// The search order among candidates is non-deterministic - whichever is found
|
|
// first wins. If you need a specific preference order, list preferred libraries
|
|
// first and use FindFirstLibraryOrdered instead.
|
|
//
|
|
// Example:
|
|
//
|
|
// match, err := FindFirstLibrary("webkit2gtk-4.1", "webkit2gtk-4.0", "webkit2gtk-6.0")
|
|
// if err != nil {
|
|
// log.Fatal("No WebKit2GTK found")
|
|
// }
|
|
// fmt.Printf("Found %s at %s\n", match.Name, match.Path)
|
|
func FindFirstLibrary(libNames ...string) (*LibraryMatch, error) {
|
|
if len(libNames) == 0 {
|
|
return nil, &LibraryNotFoundError{Name: "no libraries specified"}
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
results := make(chan *LibraryMatch, len(libNames))
|
|
var wg sync.WaitGroup
|
|
|
|
for _, name := range libNames {
|
|
wg.Add(1)
|
|
go func(libName string) {
|
|
defer wg.Done()
|
|
if path, err := findLibraryPathCtx(ctx, libName); err == nil {
|
|
select {
|
|
case results <- &LibraryMatch{Name: libName, Path: path}:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
}(name)
|
|
}
|
|
|
|
// Close results when all goroutines complete
|
|
go func() {
|
|
wg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
if result := <-results; result != nil {
|
|
return result, nil
|
|
}
|
|
|
|
return nil, &LibraryNotFoundError{Name: strings.Join(libNames, ", ")}
|
|
}
|
|
|
|
// FindFirstLibraryOrdered searches for libraries in order of preference,
|
|
// returning the first one found. Unlike FindFirstLibrary, this respects
|
|
// the order of candidates - earlier entries are preferred.
|
|
//
|
|
// This is useful when you want to prefer newer library versions:
|
|
//
|
|
// match, err := FindFirstLibraryOrdered("gtk+-4.0", "gtk+-3.0")
|
|
// // Will return gtk+-4.0 if available, otherwise gtk+-3.0
|
|
func FindFirstLibraryOrdered(libNames ...string) (*LibraryMatch, error) {
|
|
if len(libNames) == 0 {
|
|
return nil, &LibraryNotFoundError{Name: "no libraries specified"}
|
|
}
|
|
|
|
for _, name := range libNames {
|
|
if path, err := FindLibraryPath(name); err == nil {
|
|
return &LibraryMatch{Name: name, Path: path}, nil
|
|
}
|
|
}
|
|
|
|
return nil, &LibraryNotFoundError{Name: strings.Join(libNames, ", ")}
|
|
}
|
|
|
|
// FindAllLibraries searches for multiple libraries in parallel and returns
|
|
// all that are found. This is useful for discovering which library versions
|
|
// are available on the system.
|
|
//
|
|
// Example:
|
|
//
|
|
// matches := FindAllLibraries("gtk+-3.0", "gtk+-4.0", "webkit2gtk-4.0", "webkit2gtk-4.1")
|
|
// for _, m := range matches {
|
|
// fmt.Printf("Found %s at %s\n", m.Name, m.Path)
|
|
// }
|
|
func FindAllLibraries(libNames ...string) []LibraryMatch {
|
|
if len(libNames) == 0 {
|
|
return nil
|
|
}
|
|
|
|
results := make(chan *LibraryMatch, len(libNames))
|
|
var wg sync.WaitGroup
|
|
|
|
for _, name := range libNames {
|
|
wg.Add(1)
|
|
go func(libName string) {
|
|
defer wg.Done()
|
|
if path, err := FindLibraryPath(libName); err == nil {
|
|
results <- &LibraryMatch{Name: libName, Path: path}
|
|
}
|
|
}(name)
|
|
}
|
|
|
|
// Close results when all goroutines complete
|
|
go func() {
|
|
wg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
var matches []LibraryMatch
|
|
for result := range results {
|
|
matches = append(matches, *result)
|
|
}
|
|
|
|
return matches
|
|
}
|
|
|
|
// findLibraryPathCtx is FindLibraryPath with context support.
|
|
func findLibraryPathCtx(ctx context.Context, libName string) (string, error) {
|
|
// Create a child context for this search
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
results := make(chan searchResult, 3)
|
|
var wg sync.WaitGroup
|
|
wg.Add(3)
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
if path, err := findWithPkgConfigCtx(ctx, libName); err == nil {
|
|
select {
|
|
case results <- searchResult{path: path, source: "pkg-config"}:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
if path, err := findWithLdconfigCtx(ctx, libName); err == nil {
|
|
select {
|
|
case results <- searchResult{path: path, source: "ldconfig"}:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
if path, err := findInCommonPathsCtx(ctx, libName); err == nil {
|
|
select {
|
|
case results <- searchResult{path: path, source: "filesystem"}:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
if result, ok := <-results; ok {
|
|
return result.path, nil
|
|
}
|
|
|
|
return "", &LibraryNotFoundError{Name: libName}
|
|
}
|