From 2a04bf33b1499f4bede8aaa33518902fb5962b66 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 15 Dec 2023 14:03:57 +0100 Subject: [PATCH] Add support for getting binary icon and name from exe on Windows --- go.mod | 6 ++ profile/icons/convert.go | 32 ++++++++ profile/icons/find_default.go | 8 +- profile/icons/find_linux.go | 48 +++++++----- profile/icons/find_linux_test.go | 2 +- profile/icons/find_windows.go | 115 +++++++++++++++++++++++++++ profile/icons/find_windows_test.go | 27 +++++++ profile/icons/name.go | 121 +++++++++++++++++++++++++++++ profile/icons/name_test.go | 48 ++++++++++++ profile/profile.go | 45 +++-------- 10 files changed, 394 insertions(+), 58 deletions(-) create mode 100644 profile/icons/convert.go create mode 100644 profile/icons/find_windows.go create mode 100644 profile/icons/find_windows_test.go create mode 100644 profile/icons/name.go create mode 100644 profile/icons/name_test.go diff --git a/go.mod b/go.mod index 21c4d6f8..9b573ddb 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.21.1 toolchain go1.21.2 +// TODO: Remove when https://github.com/tc-hib/winres/pull/4 is merged or changes are otherwise integrated. +replace github.com/tc-hib/winres => github.com/dhaavi/winres v0.2.2 + require ( github.com/Xuanwo/go-locale v1.1.0 github.com/agext/levenshtein v1.2.3 @@ -11,6 +14,7 @@ require ( github.com/coreos/go-iptables v0.7.0 github.com/florianl/go-conntrack v0.4.0 github.com/florianl/go-nfqueue v1.3.1 + github.com/fogleman/gg v1.3.0 github.com/ghodss/yaml v1.0.0 github.com/godbus/dbus/v5 v5.1.0 github.com/google/gopacket v1.1.19 @@ -18,6 +22,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.6.0 github.com/jackc/puddle/v2 v2.2.1 + github.com/mat/besticon v3.12.0+incompatible github.com/miekg/dns v1.1.57 github.com/mitchellh/go-server-timing v1.0.1 github.com/oschwald/maxminddb-golang v1.12.0 @@ -30,6 +35,7 @@ require ( github.com/spkg/zipfs v0.7.1 github.com/stretchr/testify v1.8.4 github.com/tannerryan/ring v1.1.2 + github.com/tc-hib/winres v0.2.1 github.com/tevino/abool v1.2.0 github.com/umahmood/haversine v0.0.0-20151105152445-808ab04add26 github.com/vincent-petithory/dataurl v1.0.0 diff --git a/profile/icons/convert.go b/profile/icons/convert.go new file mode 100644 index 00000000..da98f052 --- /dev/null +++ b/profile/icons/convert.go @@ -0,0 +1,32 @@ +package icons + +import ( + "bytes" + "fmt" + "image" + _ "image/png" // Register png support for image package + + "github.com/fogleman/gg" + _ "github.com/mat/besticon/ico" // Register ico support for image package +) + +// ConvertICOtoPNG converts a an .ico to a .png image. +func ConvertICOtoPNG(ico []byte) (png []byte, err error) { + // Decode the ICO. + icon, _, err := image.Decode(bytes.NewReader(ico)) + if err != nil { + return nil, fmt.Errorf("failed to decode ICO: %w", err) + } + + // Convert to raw image. + img := gg.NewContextForImage(icon) + + // Convert to PNG. + imgBuf := &bytes.Buffer{} + err = img.EncodePNG(imgBuf) + if err != nil { + return nil, fmt.Errorf("failed to encode PNG: %w", err) + } + + return imgBuf.Bytes(), nil +} diff --git a/profile/icons/find_default.go b/profile/icons/find_default.go index 489b70a5..6b3be542 100644 --- a/profile/icons/find_default.go +++ b/profile/icons/find_default.go @@ -1,10 +1,10 @@ -//go:build !linux +//go:build !linux && !windows package icons import "context" -// FindIcon returns nil, nil for unsupported platforms. -func FindIcon(ctx context.Context, binName string, homeDir string) (*Icon, error) { - return nil, nil +// GetIconAndName returns zero values for unsupported platforms. +func GetIconAndName(ctx context.Context, binPath string, homeDir string) (icon *Icon, name string, err error) { + return nil, "", nil } diff --git a/profile/icons/find_linux.go b/profile/icons/find_linux.go index 5162eaad..ee74d062 100644 --- a/profile/icons/find_linux.go +++ b/profile/icons/find_linux.go @@ -9,36 +9,46 @@ import ( "strings" ) -// FindIcon finds an icon for the given binary name. -// Providing the home directory of the user running the process of that binary can help find an icon. -func FindIcon(ctx context.Context, binName string, homeDir string) (*Icon, error) { +// GetIconAndName returns an icon and name of the given binary path. +// Providing the home directory of the user running the process of that binary can improve results. +// Even if an error is returned, the other return values are valid, if set. +func GetIconAndName(ctx context.Context, binPath string, homeDir string) (icon *Icon, name string, err error) { + // Derive name from binary. + name = GenerateBinaryNameFromPath(binPath) + // Search for icon. - iconPath, err := search(binName, homeDir) + iconPath, err := searchForIcon(binPath, homeDir) if iconPath == "" { if err != nil { - return nil, fmt.Errorf("failed to find icon for %s: %w", binName, err) + return nil, name, fmt.Errorf("failed to find icon for %s: %w", binPath, err) } - return nil, nil + return nil, name, nil } - return LoadAndSaveIcon(ctx, iconPath) + // Save icon to internal storage. + icon, err = LoadAndSaveIcon(ctx, iconPath) + if err != nil { + return nil, name, fmt.Errorf("failed to store icon for %s: %w", binPath, err) + } + + return icon, name, nil } -func search(binName string, homeDir string) (iconPath string, err error) { - binName = strings.ToLower(binName) +func searchForIcon(binPath string, homeDir string) (iconPath string, err error) { + binPath = strings.ToLower(binPath) // Search for icon path. for _, iconLoc := range iconLocations { - basePath := iconLoc.GetPath(binName, homeDir) + basePath := iconLoc.GetPath(binPath, homeDir) if basePath == "" { continue } switch iconLoc.Type { case FlatDir: - iconPath, err = searchDirectory(basePath, binName) + iconPath, err = searchDirectory(basePath, binPath) case XDGIcons: - iconPath, err = searchXDGIconStructure(basePath, binName) + iconPath, err = searchXDGIconStructure(basePath, binPath) } if iconPath != "" { @@ -48,10 +58,10 @@ func search(binName string, homeDir string) (iconPath string, err error) { return } -func searchXDGIconStructure(baseDirectory string, binName string) (iconPath string, err error) { +func searchXDGIconStructure(baseDirectory string, binPath string) (iconPath string, err error) { for _, xdgIconDir := range xdgIconPaths { directory := filepath.Join(baseDirectory, xdgIconDir) - iconPath, err = searchDirectory(directory, binName) + iconPath, err = searchDirectory(directory, binPath) if iconPath != "" { return } @@ -59,7 +69,7 @@ func searchXDGIconStructure(baseDirectory string, binName string) (iconPath stri return } -func searchDirectory(directory string, binName string) (iconPath string, err error) { +func searchDirectory(directory string, binPath string) (iconPath string, err error) { entries, err := os.ReadDir(directory) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -82,13 +92,13 @@ func searchDirectory(directory string, binName string) (iconPath string, err err iconName := strings.ToLower(entry.Name()) iconName = strings.TrimSuffix(iconName, filepath.Ext(iconName)) switch { - case len(iconName) < len(binName): + case len(iconName) < len(binPath): // Continue to next. - case iconName == binName: + case iconName == binPath: // Exact match, return immediately. return filepath.Join(directory, entry.Name()), nil - case strings.HasPrefix(iconName, binName): - excessChars := len(iconName) - len(binName) + case strings.HasPrefix(iconName, binPath): + excessChars := len(iconName) - len(binPath) if bestMatch == "" || excessChars < bestMatchExcessChars { bestMatch = entry.Name() bestMatchExcessChars = excessChars diff --git a/profile/icons/find_linux_test.go b/profile/icons/find_linux_test.go index f9f18852..5faf3644 100644 --- a/profile/icons/find_linux_test.go +++ b/profile/icons/find_linux_test.go @@ -19,7 +19,7 @@ func TestFindIcon(t *testing.T) { func testFindIcon(t *testing.T, binName string, homeDir string) { t.Helper() - iconPath, err := search(binName, homeDir) + iconPath, err := searchForIcon(binName, homeDir) if err != nil { t.Error(err) return diff --git a/profile/icons/find_windows.go b/profile/icons/find_windows.go new file mode 100644 index 00000000..c6a28c71 --- /dev/null +++ b/profile/icons/find_windows.go @@ -0,0 +1,115 @@ +package icons + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + + "github.com/tc-hib/winres" + "github.com/tc-hib/winres/version" +) + +// GetIconAndName returns an icon and name of the given binary path. +// Providing the home directory of the user running the process of that binary can improve results. +// Even if an error is returned, the other return values are valid, if set. +func GetIconAndName(ctx context.Context, binPath string, homeDir string) (icon *Icon, name string, err error) { + // Get name and png from exe. + png, name, err := getIconAndNamefromRSS(ctx, binPath) + + // Fall back to name generation if name is not set. + if name == "" { + name = GenerateBinaryNameFromPath(binPath) + } + + // Handle previous error. + if err != nil { + return nil, name, err + } + + // Update profile icon and return icon object. + filename, err := UpdateProfileIcon(png, "png") + if err != nil { + return nil, name, fmt.Errorf("failed to store icon: %w", err) + } + + return &Icon{ + Type: IconTypeAPI, + Value: filename, + }, name, nil +} + +func getIconAndNamefromRSS(ctx context.Context, binPath string) (png []byte, name string, err error) { + // Open .exe file. + exeFile, err := os.Open(binPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, "", nil + } + return nil, "", fmt.Errorf("failed to open exe %s to get icon: %w", binPath, err) + } + defer exeFile.Close() //nolint:errcheck + + // Load .exe resources. + rss, err := winres.LoadFromEXE(exeFile) + if err != nil { + return nil, "", fmt.Errorf("failed to get rss: %w", err) + } + + // DEBUG: Print all available resources: + // rss.Walk(func(typeID, resID winres.Identifier, langID uint16, data []byte) bool { + // fmt.Printf("typeID=%d resID=%d langID=%d\n", typeID, resID, langID) + // return true + // }) + + // Get first icon. + var ( + icon *winres.Icon + iconErr error + ) + rss.WalkType(winres.RT_GROUP_ICON, func(resID winres.Identifier, langID uint16, _ []byte) bool { + icon, iconErr = rss.GetIconTranslation(resID, langID) + return iconErr != nil + }) + if iconErr != nil { + return nil, "", fmt.Errorf("failed to get icon: %w", err) + } + // Convert icon. + icoBuf := &bytes.Buffer{} + err = icon.SaveICO(icoBuf) + if err != nil { + return nil, "", fmt.Errorf("failed to save ico: %w", err) + } + png, err = ConvertICOtoPNG(icoBuf.Bytes()) + if err != nil { + return nil, "", fmt.Errorf("failed to convert ico to png: %w", err) + } + + // Get name from version record. + var ( + versionInfo *version.Info + versionInfoErr error + ) + rss.WalkType(winres.RT_VERSION, func(resID winres.Identifier, langID uint16, data []byte) bool { + versionInfo, versionInfoErr = version.FromBytes(data) + switch { + case versionInfoErr != nil: + return true + case versionInfo == nil: + return true + } + + // Get metadata table and main language. + table := versionInfo.Table().GetMainTranslation() + if table == nil { + return true + } + + name = table[version.ProductName] + return name == "" + }) + name = cleanFileDescription(name) + + return png, name, nil +} diff --git a/profile/icons/find_windows_test.go b/profile/icons/find_windows_test.go new file mode 100644 index 00000000..09a035bc --- /dev/null +++ b/profile/icons/find_windows_test.go @@ -0,0 +1,27 @@ +package icons + +import ( + "context" + "os" + "testing" +) + +func TestFindIcon(t *testing.T) { + if testing.Short() { + t.Skip("test meant for compiling and running on desktop") + } + t.Parallel() + + binName := os.Args[len(os.Args)-1] + t.Logf("getting name and icon for %s", binName) + png, name, err := getIconAndNamefromRSS(context.Background(), binName) + if err != nil { + t.Fatal(err) + } + + t.Logf("name: %s", name) + err = os.WriteFile("icon.png", png, 0o0600) + if err != nil { + t.Fatal(err) + } +} diff --git a/profile/icons/name.go b/profile/icons/name.go new file mode 100644 index 00000000..0df1d75b --- /dev/null +++ b/profile/icons/name.go @@ -0,0 +1,121 @@ +package icons + +import ( + "path/filepath" + "regexp" + "strings" +) + +var ( + segmentsSplitter = regexp.MustCompile("[^A-Za-z0-9]*[A-Z]?[a-z0-9]*") + nameOnly = regexp.MustCompile("^[A-Za-z0-9]+$") + delimitersAtStart = regexp.MustCompile("^[^A-Za-z0-9]+") + delimitersOnly = regexp.MustCompile("^[^A-Za-z0-9]+$") + removeQuotes = strings.NewReplacer(`"`, ``, `'`, ``) +) + +// GenerateBinaryNameFromPath generates a more human readable binary name from +// the given path. This function is used as fallback in the GetBinaryName +// functions. +func GenerateBinaryNameFromPath(path string) string { + // Get file name from path. + _, fileName := filepath.Split(path) + + // Split up into segments. + segments := segmentsSplitter.FindAllString(fileName, -1) + + // Remove last segment if it's an extension. + if len(segments) >= 2 { + switch strings.ToLower(segments[len(segments)-1]) { + case + ".exe", // Windows Executable + ".msi", // Windows Installer + ".bat", // Windows Batch File + ".cmd", // Windows Command Script + ".ps1", // Windows Powershell Cmdlet + ".run", // Linux Executable + ".appimage", // Linux AppImage + ".app", // MacOS Executable + ".action", // MacOS Automator Action + ".out": // Generic Compiled Executable + segments = segments[:len(segments)-1] + } + } + + // Debugging snippet: + // fmt.Printf("segments: %s\n", segments) + + // Go through segments and collect name parts. + nameParts := make([]string, 0, len(segments)) + var fragments string + for _, segment := range segments { + // Group very short segments. + if len(delimitersAtStart.ReplaceAllString(segment, "")) <= 2 { + fragments += segment + continue + } else if fragments != "" { + nameParts = append(nameParts, fragments) + fragments = "" + } + + // Add segment to name. + nameParts = append(nameParts, segment) + } + // Add last fragment. + if fragments != "" { + nameParts = append(nameParts, fragments) + } + + // Debugging snippet: + // fmt.Printf("parts: %s\n", nameParts) + + // Post-process name parts + for i := range nameParts { + // Remove any leading delimiters. + nameParts[i] = delimitersAtStart.ReplaceAllString(nameParts[i], "") + + // Title-case name-only parts. + if nameOnly.MatchString(nameParts[i]) { + nameParts[i] = strings.Title(nameParts[i]) //nolint:staticcheck + } + } + + // Debugging snippet: + // fmt.Printf("final: %s\n", nameParts) + + return strings.Join(nameParts, " ") +} + +func cleanFileDescription(fileDescr string) string { + fields := strings.Fields(fileDescr) + + // Clean out and `"` and `'`. + for i := range fields { + fields[i] = removeQuotes.Replace(fields[i]) + } + + // If there is a 1 or 2 character delimiter field, only use fields before it. + endIndex := len(fields) + for i, field := range fields { + // Ignore the first field as well as fields with more than two characters. + if i >= 1 && len(field) <= 2 && !nameOnly.MatchString(field) { + endIndex = i + break + } + } + + // Concatenate name + binName := strings.Join(fields[:endIndex], " ") + + // If there are multiple sentences, only use the first. + if strings.Contains(binName, ". ") { + binName = strings.SplitN(binName, ". ", 2)[0] + } + + // If does not have any characters or numbers, return an empty string. + if delimitersOnly.MatchString(binName) { + return "" + } + + return strings.TrimSpace(binName) +} diff --git a/profile/icons/name_test.go b/profile/icons/name_test.go new file mode 100644 index 00000000..fa972e54 --- /dev/null +++ b/profile/icons/name_test.go @@ -0,0 +1,48 @@ +package icons + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateBinaryNameFromPath(t *testing.T) { + t.Parallel() + + assert.Equal(t, "Nslookup", GenerateBinaryNameFromPath("nslookup.exe")) + assert.Equal(t, "System Settings", GenerateBinaryNameFromPath("SystemSettings.exe")) + assert.Equal(t, "One Drive Setup", GenerateBinaryNameFromPath("OneDriveSetup.exe")) + assert.Equal(t, "Msedge", GenerateBinaryNameFromPath("msedge.exe")) + assert.Equal(t, "SIH Client", GenerateBinaryNameFromPath("SIHClient.exe")) + assert.Equal(t, "Openvpn Gui", GenerateBinaryNameFromPath("openvpn-gui.exe")) + assert.Equal(t, "Portmaster Core v0-1-2", GenerateBinaryNameFromPath("portmaster-core_v0-1-2.exe")) + assert.Equal(t, "Win Store App", GenerateBinaryNameFromPath("WinStore.App.exe")) + assert.Equal(t, "Test Script", GenerateBinaryNameFromPath(".test-script")) + assert.Equal(t, "Browser Broker", GenerateBinaryNameFromPath("browser_broker.exe")) + assert.Equal(t, "Virtual Box VM", GenerateBinaryNameFromPath("VirtualBoxVM")) + assert.Equal(t, "Io Elementary Appcenter", GenerateBinaryNameFromPath("io.elementary.appcenter")) + assert.Equal(t, "Microsoft Windows Store", GenerateBinaryNameFromPath("Microsoft.WindowsStore")) +} + +func TestCleanFileDescription(t *testing.T) { + t.Parallel() + + assert.Equal(t, "Product Name", cleanFileDescription("Product Name")) + assert.Equal(t, "Product Name", cleanFileDescription("Product Name. Does this and that.")) + assert.Equal(t, "Product Name", cleanFileDescription("Product Name - Does this and that.")) + assert.Equal(t, "Product Name", cleanFileDescription("Product Name / Does this and that.")) + assert.Equal(t, "Product Name", cleanFileDescription("Product Name :: Does this and that.")) + assert.Equal(t, "/ Product Name", cleanFileDescription("/ Product Name")) + assert.Equal(t, "Product", cleanFileDescription("Product / Name")) + assert.Equal(t, "Software 2", cleanFileDescription("Software 2")) + assert.Equal(t, "Launcher for Software 2", cleanFileDescription("Launcher for 'Software 2'")) + assert.Equal(t, "", cleanFileDescription(". / Name")) + assert.Equal(t, "", cleanFileDescription(". ")) + assert.Equal(t, "", cleanFileDescription(".")) + assert.Equal(t, "N/A", cleanFileDescription("N/A")) + + assert.Equal(t, + "Product Name a Does this and that.", + cleanFileDescription("Product Name a Does this and that."), + ) +} diff --git a/profile/profile.go b/profile/profile.go index 43101fd8..30702b69 100644 --- a/profile/profile.go +++ b/profile/profile.go @@ -15,7 +15,6 @@ import ( "github.com/safing/portbase/database/record" "github.com/safing/portbase/log" "github.com/safing/portbase/utils" - "github.com/safing/portbase/utils/osdetail" "github.com/safing/portmaster/intel/filterlists" "github.com/safing/portmaster/profile/endpoints" "github.com/safing/portmaster/profile/icons" @@ -476,7 +475,7 @@ func (profile *Profile) updateMetadata(binaryPath string) (changed bool) { // Set Name if unset. if profile.Name == "" && profile.PresentationPath != "" { // Generate a default profile name from path. - profile.Name = osdetail.GenerateBinaryNameFromPath(profile.PresentationPath) + profile.Name = icons.GenerateBinaryNameFromPath(profile.PresentationPath) changed = true } @@ -514,38 +513,16 @@ func (profile *Profile) updateMetadataFromSystem(ctx context.Context, md Matchin return fmt.Errorf("tried to update metadata for non-local or non-path profile %s", profile.ScopedID()) } - // Get binary name from PresentationPath. - newName, err := osdetail.GetBinaryNameFromSystem(profile.PresentationPath) + // Get home from ENV. + var home string + if env := md.Env(); env != nil { + home = env["HOME"] + } + + // Get binary icon and name. + newIcon, newName, err := icons.GetIconAndName(ctx, profile.PresentationPath, home) if err != nil { - switch { - case errors.Is(err, osdetail.ErrNotSupported): - case errors.Is(err, osdetail.ErrNotFound): - case errors.Is(err, osdetail.ErrEmptyOutput): - default: - log.Warningf("profile: error while getting binary name for %s: %s", profile.PresentationPath, err) - } - return nil - } - - // Check if the new name is valid. - if strings.TrimSpace(newName) == "" { - return nil - } - - // Get icon if path matches presentation path. - var newIcon *icons.Icon - if profile.PresentationPath == md.Path() { - // Get home from ENV. - var home string - if env := md.Env(); env != nil { - home = env["HOME"] - } - var err error - newIcon, err = icons.FindIcon(ctx, profile.PresentationPath, home) - if err != nil { - log.Warningf("profile: failed to find icon for %s: %s", profile.PresentationPath, err) - newIcon = nil - } + log.Warningf("profile: failed to get binary icon/name for %s: %s", profile.PresentationPath, err) } // Apply new data to profile. @@ -555,7 +532,7 @@ func (profile *Profile) updateMetadataFromSystem(ctx context.Context, md Matchin defer profile.Unlock() // Apply new name if it changed. - if profile.Name != newName { + if newName != "" && profile.Name != newName { profile.Name = newName changed = true }