Add support for getting binary icon and name from exe on Windows

This commit is contained in:
Daniel 2023-12-15 14:03:57 +01:00
parent 19ad1817f2
commit 2a04bf33b1
10 changed files with 394 additions and 58 deletions

6
go.mod
View file

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

32
profile/icons/convert.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

121
profile/icons/name.go Normal file
View file

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

View file

@ -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."),
)
}

View file

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