wails/v3/internal/libpath/libpath_linux.go
Lea Anthony f1a4ffe72d
feat(linux): add libpath package for finding native library paths (#4847)
* 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)
2026-01-04 11:59:22 +11:00

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