feat(app): enhance app displays on windows, fix #4236

* Implemented `removeDuplicateApps` function to filter out duplicate applications based on `Name` and `Path`.
* Updated `parseShortcut` and `parseExe` methods to utilize the display name from file version info, improving user experience.
* Added logging for removed duplicates and display name resolution for better debugging.
This commit is contained in:
qianlifeng 2025-07-12 23:11:32 +08:00
parent fe183f5bf6
commit 871182c029
2 changed files with 362 additions and 16 deletions

View File

@ -328,6 +328,9 @@ func (a *ApplicationPlugin) indexApps(ctx context.Context) {
} }
} }
// Remove duplicates with same Name and Path
appInfos = a.removeDuplicateApps(ctx, appInfos)
a.apps = appInfos a.apps = appInfos
a.saveAppToCache(ctx) a.saveAppToCache(ctx)
@ -502,3 +505,23 @@ func (a *ApplicationPlugin) loadAppCache(ctx context.Context) ([]appInfo, error)
a.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("loaded %d apps from cache, cost %d ms", len(apps), util.GetSystemTimestamp()-startTimestamp)) a.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("loaded %d apps from cache, cost %d ms", len(apps), util.GetSystemTimestamp()-startTimestamp))
return apps, nil return apps, nil
} }
// removeDuplicateApps removes duplicate apps with same Name and Path, keeping only one
func (a *ApplicationPlugin) removeDuplicateApps(ctx context.Context, apps []appInfo) []appInfo {
seen := make(map[string]bool)
var result []appInfo
for _, app := range apps {
// Create a unique key combining Name and Path
key := app.Name + "|" + app.Path
if !seen[key] {
seen[key] = true
result = append(result, app)
} else {
a.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("removed duplicate app: %s (%s)", app.Name, app.Path))
}
}
a.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("removed %d duplicate apps, %d apps remaining", len(apps)-len(result), len(result)))
return result
}

View File

@ -23,12 +23,42 @@ import (
) )
var ( var (
// Load shell32.dll instead of user32.dll // Load shell32.dll and user32.dll
shell32 = syscall.NewLazyDLL("shell32.dll") shell32 = syscall.NewLazyDLL("shell32.dll")
// Get the address of ExtractIconExW from shell32.dll user32 = syscall.NewLazyDLL("user32.dll")
extractIconEx = shell32.NewProc("ExtractIconExW") // Get the address of APIs
extractIconEx = shell32.NewProc("ExtractIconExW")
privateExtractIcons = user32.NewProc("PrivateExtractIconsW")
shGetFileInfo = shell32.NewProc("SHGetFileInfoW")
// Load version.dll for file version info
version = syscall.NewLazyDLL("version.dll")
getFileVersionInfoSize = version.NewProc("GetFileVersionInfoSizeW")
getFileVersionInfo = version.NewProc("GetFileVersionInfoW")
verQueryValue = version.NewProc("VerQueryValueW")
) )
// Windows constants for icon extraction
const (
SHGFI_ICON = 0x000000100
SHGFI_LARGEICON = 0x000000000
SHGFI_SMALLICON = 0x000000001
SHGFI_SYSICONINDEX = 0x000004000
SHGFI_SHELLICONSIZE = 0x000000004
IMAGE_ICON = 1
LR_DEFAULTSIZE = 0x00000040
LR_LOADFROMFILE = 0x00000010
)
// SHFILEINFO structure for SHGetFileInfo
type SHFILEINFO struct {
HIcon win.HICON
IIcon int32
DwAttributes uint32
SzDisplayName [260]uint16
SzTypeName [80]uint16
}
var appRetriever = &WindowsRetriever{} var appRetriever = &WindowsRetriever{}
type WindowsRetriever struct { type WindowsRetriever struct {
@ -119,8 +149,16 @@ func (a *WindowsRetriever) parseShortcut(ctx context.Context, appPath string) (a
} }
} }
// Try to get display name from target exe file version info
displayName := a.getFileDisplayName(ctx, targetPath)
if displayName == "" {
// Fallback to shortcut filename if no display name found
displayName = strings.TrimSuffix(filepath.Base(appPath), filepath.Ext(appPath))
a.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Using shortcut filename as display name: %s", displayName))
}
return appInfo{ return appInfo{
Name: strings.TrimSuffix(filepath.Base(appPath), filepath.Ext(appPath)), Name: displayName,
Path: filepath.Clean(targetPath), Path: filepath.Clean(targetPath),
Icon: icon, Icon: icon,
Type: AppTypeDesktop, Type: AppTypeDesktop,
@ -142,15 +180,67 @@ func (a *WindowsRetriever) parseExe(ctx context.Context, appPath string) (appInf
} }
} }
// Try to get display name from exe file version info
displayName := a.getFileDisplayName(ctx, appPath)
if displayName == "" {
// Fallback to exe filename if no display name found
displayName = strings.TrimSuffix(filepath.Base(appPath), filepath.Ext(appPath))
util.GetLogger().Debug(ctx, fmt.Sprintf("Using exe filename as display name: %s", displayName))
}
return appInfo{ return appInfo{
Name: strings.TrimSuffix(filepath.Base(appPath), filepath.Ext(appPath)), Name: displayName,
Path: filepath.Clean(appPath), Path: filepath.Clean(appPath),
Icon: icon, Icon: icon,
Type: AppTypeDesktop, // 使用常量 Type: AppTypeDesktop,
}, nil }, nil
} }
func (a *WindowsRetriever) GetAppIcon(ctx context.Context, path string) (image.Image, error) { func (a *WindowsRetriever) GetAppIcon(ctx context.Context, path string) (image.Image, error) {
// Priority 1: Try to get high resolution icon using PrivateExtractIconsW (best quality)
if icon, err := a.getHighResIcon(ctx, path); err == nil {
return icon, nil
}
// Priority 2: Try to get large icon using SHGetFileInfo (public API fallback)
if icon, err := a.getIconUsingSHGetFileInfo(ctx, path); err == nil {
return icon, nil
}
// Priority 3: Try ExtractIconEx
if icon, err := a.getIconUsingExtractIconEx(ctx, path); err == nil {
return icon, nil
}
// Priority 4: Final fallback to Windows default executable icon
return a.getWindowsDefaultIcon(ctx)
}
func (a *WindowsRetriever) getIconUsingSHGetFileInfo(ctx context.Context, path string) (image.Image, error) {
// Convert file path to UTF16
lpIconPath, err := syscall.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
var shfi SHFILEINFO
ret, _, _ := shGetFileInfo.Call(
uintptr(unsafe.Pointer(lpIconPath)),
0,
uintptr(unsafe.Pointer(&shfi)),
uintptr(unsafe.Sizeof(shfi)),
SHGFI_ICON|SHGFI_LARGEICON,
)
if ret == 0 || shfi.HIcon == 0 {
return nil, fmt.Errorf("failed to get icon using SHGetFileInfo")
}
defer win.DestroyIcon(shfi.HIcon)
return a.convertIconToImage(ctx, shfi.HIcon)
}
func (a *WindowsRetriever) getIconUsingExtractIconEx(ctx context.Context, path string) (image.Image, error) {
// Convert file path to UTF16 // Convert file path to UTF16
lpIconPath, err := syscall.UTF16PtrFromString(path) lpIconPath, err := syscall.UTF16PtrFromString(path)
if err != nil { if err != nil {
@ -172,34 +262,107 @@ func (a *WindowsRetriever) GetAppIcon(ctx context.Context, path string) (image.I
} }
defer win.DestroyIcon(largeIcon) // Ensure icon resources are released defer win.DestroyIcon(largeIcon) // Ensure icon resources are released
return a.convertIconToImage(ctx, largeIcon)
}
func (a *WindowsRetriever) getHighResIcon(ctx context.Context, path string) (image.Image, error) {
// Safely try to use PrivateExtractIconsW (undocumented API, but provides best quality)
defer func() {
if r := recover(); r != nil {
util.GetLogger().Debug(ctx, fmt.Sprintf("PrivateExtractIconsW caused panic (API may not be available): %v", r))
}
}()
// Check if PrivateExtractIconsW is available
if err := privateExtractIcons.Find(); err != nil {
return nil, fmt.Errorf("PrivateExtractIconsW not available: %v", err)
}
// Convert file path to UTF16
lpIconPath, err := syscall.UTF16PtrFromString(path)
if err != nil {
return nil, fmt.Errorf("failed to convert path to UTF16: %v", err)
}
// Try different icon sizes: 256, 128, 64, 48 (prioritize larger sizes)
sizes := []int{256, 128, 64, 48}
for _, size := range sizes {
var hIcon win.HICON
// Use a safe call wrapper
ret, _, callErr := func() (uintptr, uintptr, error) {
defer func() {
if r := recover(); r != nil {
util.GetLogger().Debug(ctx, fmt.Sprintf("PrivateExtractIconsW call panicked for size %d: %v", size, r))
}
}()
return privateExtractIcons.Call(
uintptr(unsafe.Pointer(lpIconPath)),
0, // icon index
uintptr(size), // cx - desired width
uintptr(size), // cy - desired height
uintptr(unsafe.Pointer(&hIcon)),
0, // icon IDs (not needed)
1, // number of icons to extract
0, // flags
)
}()
// Check for system call errors (ignore "operation completed successfully" and "user stopped resource enumeration")
if callErr != nil &&
callErr.Error() != "The operation completed successfully." &&
callErr.Error() != "User stopped resource enumeration." {
continue
}
if ret > 0 && hIcon != 0 {
defer win.DestroyIcon(hIcon)
util.GetLogger().Info(ctx, fmt.Sprintf("Successfully extracted %dx%d high-res icon from %s using PrivateExtractIconsW", size, size, path))
return a.convertIconToImage(ctx, hIcon)
}
}
return nil, fmt.Errorf("failed to extract high resolution icon using PrivateExtractIconsW")
}
func (a *WindowsRetriever) convertIconToImage(ctx context.Context, hIcon win.HICON) (image.Image, error) {
// Get icon information // Get icon information
var iconInfo win.ICONINFO var iconInfo win.ICONINFO
if win.GetIconInfo(largeIcon, &iconInfo) == false { if !win.GetIconInfo(hIcon, &iconInfo) {
return nil, fmt.Errorf("failed to get icon info") return nil, fmt.Errorf("failed to get icon info")
} }
defer win.DeleteObject(win.HGDIOBJ(iconInfo.HbmColor)) defer win.DeleteObject(win.HGDIOBJ(iconInfo.HbmColor))
defer win.DeleteObject(win.HGDIOBJ(iconInfo.HbmMask)) defer win.DeleteObject(win.HGDIOBJ(iconInfo.HbmMask))
// Create device-independent bitmap (DIB) to receive image data // Get actual bitmap dimensions
hdc := win.GetDC(0) hdc := win.GetDC(0)
defer win.ReleaseDC(0, hdc) defer win.ReleaseDC(0, hdc)
// Get bitmap info to determine actual size
var bitmap win.BITMAP
if win.GetObject(win.HGDIOBJ(iconInfo.HbmColor), uintptr(unsafe.Sizeof(bitmap)), unsafe.Pointer(&bitmap)) == 0 {
return nil, fmt.Errorf("failed to get bitmap object")
}
width := int(bitmap.BmWidth)
height := int(bitmap.BmHeight)
var bmpInfo win.BITMAPINFO var bmpInfo win.BITMAPINFO
bmpInfo.BmiHeader.BiSize = uint32(unsafe.Sizeof(bmpInfo.BmiHeader)) bmpInfo.BmiHeader.BiSize = uint32(unsafe.Sizeof(bmpInfo.BmiHeader))
bmpInfo.BmiHeader.BiWidth = int32(iconInfo.XHotspot * 2) bmpInfo.BmiHeader.BiWidth = int32(width)
bmpInfo.BmiHeader.BiHeight = -int32(iconInfo.YHotspot * 2) // Negative value indicates top-down DIB bmpInfo.BmiHeader.BiHeight = -int32(height) // Negative value indicates top-down DIB
bmpInfo.BmiHeader.BiPlanes = 1 bmpInfo.BmiHeader.BiPlanes = 1
bmpInfo.BmiHeader.BiBitCount = 32 bmpInfo.BmiHeader.BiBitCount = 32
bmpInfo.BmiHeader.BiCompression = win.BI_RGB bmpInfo.BmiHeader.BiCompression = win.BI_RGB
// Allocate memory to store bitmap data // Allocate memory to store bitmap data
bits := make([]byte, iconInfo.XHotspot*2*iconInfo.YHotspot*2*4) bits := make([]byte, width*height*4)
if win.GetDIBits(hdc, win.HBITMAP(iconInfo.HbmColor), 0, uint32(iconInfo.YHotspot*2), &bits[0], &bmpInfo, win.DIB_RGB_COLORS) == 0 { if win.GetDIBits(hdc, win.HBITMAP(iconInfo.HbmColor), 0, uint32(height), &bits[0], &bmpInfo, win.DIB_RGB_COLORS) == 0 {
return nil, fmt.Errorf("failed to get DIB bits") return nil, fmt.Errorf("failed to get DIB bits")
} }
width := int(iconInfo.XHotspot * 2)
height := int(iconInfo.YHotspot * 2)
img := image.NewRGBA(image.Rect(0, 0, width, height)) img := image.NewRGBA(image.Rect(0, 0, width, height))
// Copy the bitmap data into the img.Pix slice. // Copy the bitmap data into the img.Pix slice.
@ -244,6 +407,83 @@ func (a *WindowsRetriever) resolveShortcutWithAPI(ctx context.Context, shortcutP
return targetPath, nil return targetPath, nil
} }
// getFileDisplayName gets the display name from file version info
func (a *WindowsRetriever) getFileDisplayName(ctx context.Context, filePath string) string {
// Convert file path to UTF16
lpFileName, err := syscall.UTF16PtrFromString(filePath)
if err != nil {
util.GetLogger().Debug(ctx, fmt.Sprintf("Failed to convert file path to UTF16: %s", err.Error()))
return ""
}
// Get version info size
size, _, _ := getFileVersionInfoSize.Call(uintptr(unsafe.Pointer(lpFileName)), 0)
if size == 0 {
util.GetLogger().Debug(ctx, fmt.Sprintf("No version info found for file: %s", filePath))
return ""
}
// Allocate buffer for version info
buffer := make([]byte, size)
// Get version info
ret, _, _ := getFileVersionInfo.Call(
uintptr(unsafe.Pointer(lpFileName)),
0,
uintptr(size),
uintptr(unsafe.Pointer(&buffer[0])),
)
if ret == 0 {
util.GetLogger().Debug(ctx, fmt.Sprintf("Failed to get version info for file: %s", filePath))
return ""
}
// Try to get FileDescription first, then ProductName
displayNames := []string{
"\\StringFileInfo\\040904e4\\FileDescription",
"\\StringFileInfo\\040904e4\\ProductName",
"\\StringFileInfo\\040904b0\\FileDescription", // Simplified Chinese
"\\StringFileInfo\\040904b0\\ProductName",
}
for _, queryPath := range displayNames {
name := a.queryVersionString(ctx, buffer, queryPath)
if name != "" {
util.GetLogger().Debug(ctx, fmt.Sprintf("Found display name '%s' for file: %s", name, filePath))
return name
}
}
util.GetLogger().Debug(ctx, fmt.Sprintf("No display name found in version info for file: %s", filePath))
return ""
}
// queryVersionString queries a string value from version info buffer
func (a *WindowsRetriever) queryVersionString(ctx context.Context, buffer []byte, queryPath string) string {
lpSubBlock, err := syscall.UTF16PtrFromString(queryPath)
if err != nil {
return ""
}
var lpBuffer uintptr
var puLen uint32
ret, _, _ := verQueryValue.Call(
uintptr(unsafe.Pointer(&buffer[0])),
uintptr(unsafe.Pointer(lpSubBlock)),
uintptr(unsafe.Pointer(&lpBuffer)),
uintptr(unsafe.Pointer(&puLen)),
)
if ret == 0 || puLen == 0 {
return ""
}
// Convert UTF16 string to Go string
utf16Slice := (*[256]uint16)(unsafe.Pointer(lpBuffer))[:puLen/2]
return syscall.UTF16ToString(utf16Slice)
}
func (a *WindowsRetriever) GetExtraApps(ctx context.Context) ([]appInfo, error) { func (a *WindowsRetriever) GetExtraApps(ctx context.Context) ([]appInfo, error) {
uwpApps := a.GetUWPApps(ctx) uwpApps := a.GetUWPApps(ctx)
util.GetLogger().Info(ctx, fmt.Sprintf("Found %d UWP apps", len(uwpApps))) util.GetLogger().Info(ctx, fmt.Sprintf("Found %d UWP apps", len(uwpApps)))
@ -341,7 +581,7 @@ func (a *WindowsRetriever) GetUWPApps(ctx context.Context) []appInfo {
Name: name, Name: name,
Path: "shell:AppsFolder\\" + appID, Path: "shell:AppsFolder\\" + appID,
Icon: appIcon, Icon: appIcon,
Type: AppTypeUWP, // 使用常量 Type: AppTypeUWP,
} }
// Get app icon // Get app icon
@ -469,3 +709,86 @@ func (a *WindowsRetriever) GetUWPAppIcon(ctx context.Context, appID string) (com
return common.NewWoxImageAbsolutePath(iconPath), nil return common.NewWoxImageAbsolutePath(iconPath), nil
} }
func (a *WindowsRetriever) getWindowsDefaultIcon(ctx context.Context) (image.Image, error) {
// Try to get high resolution default icon using PrivateExtractIconsW first
if icon, err := a.getHighResDefaultIcon(ctx); err == nil {
return icon, nil
}
// Fallback to standard SHGetFileInfo method
return a.getStandardDefaultIcon(ctx)
}
func (a *WindowsRetriever) getHighResDefaultIcon(ctx context.Context) (image.Image, error) {
// Try to extract high-res icon from shell32.dll (contains default icons)
shell32Path, err := syscall.UTF16PtrFromString("shell32.dll")
if err != nil {
return nil, fmt.Errorf("failed to convert shell32.dll path to UTF16: %v", err)
}
// Check if PrivateExtractIconsW is available
if err := privateExtractIcons.Find(); err != nil {
return nil, fmt.Errorf("PrivateExtractIconsW not available: %v", err)
}
// Try different icon sizes: 256, 128, 64, 48
sizes := []int{256, 128, 64, 48}
for _, size := range sizes {
var hIcon win.HICON
// Extract icon index 2 from shell32.dll (default executable icon)
ret, _, callErr := privateExtractIcons.Call(
uintptr(unsafe.Pointer(shell32Path)),
2, // icon index 2 is typically the default executable icon
uintptr(size), // cx - desired width
uintptr(size), // cy - desired height
uintptr(unsafe.Pointer(&hIcon)),
0, // icon IDs (not needed)
1, // number of icons to extract
0, // flags
)
// Check for system call errors (ignore "operation completed successfully" and "user stopped resource enumeration")
if callErr != nil &&
callErr.Error() != "The operation completed successfully." &&
callErr.Error() != "User stopped resource enumeration." {
continue
}
if ret > 0 && hIcon != 0 {
defer win.DestroyIcon(hIcon)
util.GetLogger().Info(ctx, fmt.Sprintf("Successfully extracted %dx%d default icon from shell32.dll", size, size))
return a.convertIconToImage(ctx, hIcon)
}
}
return nil, fmt.Errorf("failed to extract high resolution default icon from shell32.dll")
}
func (a *WindowsRetriever) getStandardDefaultIcon(ctx context.Context) (image.Image, error) {
// Get the default icon for .exe files from Windows
// This will return the standard Windows executable file icon
exeExtension, err := syscall.UTF16PtrFromString(".exe")
if err != nil {
return nil, fmt.Errorf("failed to convert .exe extension to UTF16: %v", err)
}
var shfi SHFILEINFO
ret, _, _ := shGetFileInfo.Call(
uintptr(unsafe.Pointer(exeExtension)),
0x80, // FILE_ATTRIBUTE_NORMAL
uintptr(unsafe.Pointer(&shfi)),
uintptr(unsafe.Sizeof(shfi)),
SHGFI_ICON|SHGFI_LARGEICON|0x000000010, // SHGFI_USEFILEATTRIBUTES
)
if ret == 0 || shfi.HIcon == 0 {
return nil, fmt.Errorf("failed to get default Windows executable icon")
}
defer win.DestroyIcon(shfi.HIcon)
util.GetLogger().Info(ctx, "Using Windows standard default executable icon as fallback")
return a.convertIconToImage(ctx, shfi.HIcon)
}