mirror of https://github.com/Wox-launcher/Wox
462 lines
13 KiB
Go
462 lines
13 KiB
Go
package app
|
|
|
|
/*
|
|
#cgo CFLAGS: -x objective-c
|
|
#cgo LDFLAGS: -framework Foundation -framework Cocoa
|
|
#include <stdlib.h>
|
|
#include <sys/sysctl.h>
|
|
|
|
const unsigned char *GetPrefPaneIcon(const char *prefPanePath, size_t *length);
|
|
int get_process_list(struct kinfo_proc **procList, size_t *procCount);
|
|
char* get_process_path(pid_t pid);
|
|
*/
|
|
import "C"
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"unsafe"
|
|
"wox/common"
|
|
"wox/plugin"
|
|
"wox/util"
|
|
"wox/util/shell"
|
|
|
|
"github.com/disintegration/imaging"
|
|
"github.com/mitchellh/go-homedir"
|
|
"github.com/tidwall/gjson"
|
|
"howett.net/plist"
|
|
)
|
|
|
|
var appRetriever = &MacRetriever{}
|
|
|
|
var defaultAppIcon = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns"
|
|
|
|
type processInfo struct {
|
|
Pid int
|
|
Path string
|
|
}
|
|
|
|
type MacRetriever struct {
|
|
runningProcesses []processInfo
|
|
lastProcessUpdateTime int64
|
|
api plugin.API
|
|
}
|
|
|
|
func (a *MacRetriever) UpdateAPI(api plugin.API) {
|
|
a.api = api
|
|
}
|
|
|
|
func (a *MacRetriever) GetPlatform() string {
|
|
return util.PlatformMacOS
|
|
}
|
|
|
|
func (a *MacRetriever) GetAppDirectories(ctx context.Context) []appDirectory {
|
|
userHomeApps, _ := homedir.Expand("~/Applications")
|
|
return []appDirectory{
|
|
{
|
|
Path: userHomeApps, Recursive: false,
|
|
},
|
|
{
|
|
Path: "/Applications", Recursive: true, RecursiveDepth: 2,
|
|
},
|
|
{
|
|
Path: "/System/Applications", Recursive: true, RecursiveDepth: 2,
|
|
},
|
|
{
|
|
Path: "/System/Library/CoreServices/Applications", Recursive: false,
|
|
},
|
|
{
|
|
Path: "/System/Library/PreferencePanes", Recursive: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (a *MacRetriever) GetAppExtensions(ctx context.Context) []string {
|
|
return []string{"app", "prefPane"}
|
|
}
|
|
|
|
func (a *MacRetriever) ParseAppInfo(ctx context.Context, path string) (appInfo, error) {
|
|
var appName string
|
|
var err error
|
|
|
|
if strings.HasSuffix(path, ".prefPane") {
|
|
appName, err = a.getPrefPaneName(path)
|
|
} else {
|
|
appName, err = a.getAppNameFromMdls(path)
|
|
}
|
|
|
|
if err != nil {
|
|
return appInfo{}, err
|
|
}
|
|
|
|
if appName == "(null)" {
|
|
appName = filepath.Base(path)
|
|
a.api.Log(ctx, plugin.LogLevelWarning, fmt.Sprintf("failed to get app name from mdls(%s), using filename instead", path))
|
|
}
|
|
for _, extension := range a.GetAppExtensions(ctx) {
|
|
if strings.HasSuffix(appName, "."+extension) {
|
|
appName = appName[:len(appName)-len(extension)-1]
|
|
}
|
|
}
|
|
|
|
info := appInfo{
|
|
Name: appName,
|
|
Path: path,
|
|
}
|
|
icon, iconErr := a.getMacAppIcon(ctx, path)
|
|
if iconErr != nil {
|
|
a.api.Log(ctx, plugin.LogLevelError, iconErr.Error())
|
|
}
|
|
info.Icon = icon
|
|
|
|
return info, nil
|
|
}
|
|
|
|
func (a *MacRetriever) getPrefPaneName(path string) (string, error) {
|
|
plistPath := filepath.Join(path, "Contents", "Info.plist")
|
|
plistFile, err := os.Open(plistPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer plistFile.Close()
|
|
|
|
var plistData map[string]interface{}
|
|
decoder := plist.NewDecoder(plistFile)
|
|
if err := decoder.Decode(&plistData); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if name, ok := plistData["CFBundleName"].(string); ok && name != "" {
|
|
return name, nil
|
|
}
|
|
|
|
if name, ok := plistData["NSPrefPaneIconLabel"].(string); ok && name != "" {
|
|
return name, nil
|
|
}
|
|
|
|
return filepath.Base(path), nil
|
|
}
|
|
|
|
func (a *MacRetriever) getAppNameFromMdls(path string) (string, error) {
|
|
out, err := shell.RunOutput("mdls", "-name", "kMDItemDisplayName", "-raw", path)
|
|
if err != nil {
|
|
msg := fmt.Sprintf("failed to get app name from mdls(%s): %s", path, err.Error())
|
|
var exitError *exec.ExitError
|
|
if errors.As(err, &exitError) {
|
|
msg = fmt.Sprintf("failed to get app name from mdls(%s): %s", path, exitError.Stderr)
|
|
}
|
|
return "", errors.New(msg)
|
|
}
|
|
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
func (a *MacRetriever) getMacAppIcon(ctx context.Context, appPath string) (common.WoxImage, error) {
|
|
if v, ok := iconsMap[appPath]; ok {
|
|
return v, nil
|
|
}
|
|
|
|
// md5 iconPath
|
|
iconPathMd5 := fmt.Sprintf("%x", md5.Sum([]byte(appPath)))
|
|
iconCachePath := path.Join(util.GetLocation().GetImageCacheDirectory(), fmt.Sprintf("app_%s.png", iconPathMd5))
|
|
if _, err := os.Stat(iconCachePath); err == nil {
|
|
return common.WoxImage{
|
|
ImageType: common.WoxImageTypeAbsolutePath,
|
|
ImageData: iconCachePath,
|
|
}, nil
|
|
}
|
|
|
|
rawImagePath, iconErr := a.getMacAppIconImagePath(ctx, appPath)
|
|
if iconErr != nil {
|
|
// use default icon if no icon is found, and don't cache
|
|
a.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("failed to get app icon for path: %s, %s", appPath, iconErr.Error()))
|
|
return common.WoxImage{
|
|
ImageType: common.WoxImageTypeAbsolutePath,
|
|
ImageData: defaultAppIcon,
|
|
}, nil
|
|
}
|
|
|
|
if strings.HasSuffix(rawImagePath, ".icns") {
|
|
//use sips to convert icns to png
|
|
//sips -s format png /Applications/Calculator.app/Contents/Resources/AppIcon.icns --out /tmp/wox-app-icon.png
|
|
out, openErr := shell.RunOutput("sips", "-s", "format", "png", rawImagePath, "--out", iconCachePath)
|
|
if openErr != nil {
|
|
msg := fmt.Sprintf("failed to convert icns to png: %s", openErr.Error())
|
|
if out != nil {
|
|
msg = fmt.Sprintf("%s, output: %s", msg, string(out))
|
|
}
|
|
return common.WoxImage{}, errors.New(msg)
|
|
}
|
|
} else {
|
|
originF, originErr := os.Open(rawImagePath)
|
|
if originErr != nil {
|
|
return common.WoxImage{}, fmt.Errorf("can't open origin image file: %s", originErr.Error())
|
|
}
|
|
|
|
//copy image to cache
|
|
destF, destErr := os.Create(iconCachePath)
|
|
if destErr != nil {
|
|
return common.WoxImage{}, fmt.Errorf("can't create cache file: %s", destErr.Error())
|
|
}
|
|
defer destF.Close()
|
|
|
|
if _, err := io.Copy(destF, originF); err != nil {
|
|
return common.WoxImage{}, fmt.Errorf("can't copy image to cache: %s", err.Error())
|
|
}
|
|
}
|
|
|
|
a.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("app icon cache created: %s", iconCachePath))
|
|
return common.WoxImage{
|
|
ImageType: common.WoxImageTypeAbsolutePath,
|
|
ImageData: iconCachePath,
|
|
}, nil
|
|
}
|
|
|
|
func (a *MacRetriever) GetExtraApps(ctx context.Context) ([]appInfo, error) {
|
|
//use `system_profiler SPApplicationsDataType -json` to get all apps
|
|
out, err := shell.RunOutput("system_profiler", "SPApplicationsDataType", "-json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get extra apps: %s", err.Error())
|
|
}
|
|
|
|
//parse json
|
|
results := gjson.Get(string(out), "SPApplicationsDataType")
|
|
if !results.Exists() {
|
|
return nil, errors.New("failed to parse extra apps")
|
|
}
|
|
var appPaths []string
|
|
for _, app := range results.Array() {
|
|
appPath := app.Get("path").String()
|
|
if appPath == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(appPath, "/System/Library/CoreServices/") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(appPath, "/System/Library/PrivateFrameworks/") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(appPath, "/System/Library/Frameworks/") {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(appPath, ".app") {
|
|
continue
|
|
}
|
|
|
|
appPaths = append(appPaths, appPath)
|
|
}
|
|
|
|
// split into groups, so we can index apps in parallel
|
|
var appPathGroups [][]string
|
|
var groupSize = 25
|
|
for i := 0; i < len(appPaths); i += groupSize {
|
|
var end = i + groupSize
|
|
if end > len(appPaths) {
|
|
end = len(appPaths)
|
|
}
|
|
appPathGroups = append(appPathGroups, appPaths[i:end])
|
|
}
|
|
a.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("found extra %d apps in %d groups", len(appPaths), len(appPathGroups)))
|
|
|
|
// index apps in parallel
|
|
var appInfos []appInfo
|
|
var waitGroup sync.WaitGroup
|
|
var lock sync.Mutex
|
|
waitGroup.Add(len(appPathGroups))
|
|
for groupIndex := range appPathGroups {
|
|
var appPathGroup = appPathGroups[groupIndex]
|
|
util.Go(ctx, fmt.Sprintf("index extra app group: %d", groupIndex), func() {
|
|
for _, appPath := range appPathGroup {
|
|
info, getErr := a.ParseAppInfo(ctx, appPath)
|
|
if getErr != nil {
|
|
a.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("error getting extra app info for %s: %s", appPath, getErr.Error()))
|
|
continue
|
|
}
|
|
|
|
lock.Lock()
|
|
appInfos = append(appInfos, info)
|
|
lock.Unlock()
|
|
}
|
|
waitGroup.Done()
|
|
}, func() {
|
|
waitGroup.Done()
|
|
})
|
|
}
|
|
|
|
waitGroup.Wait()
|
|
|
|
return appInfos, nil
|
|
}
|
|
|
|
func (a *MacRetriever) getMacAppIconImagePath(ctx context.Context, appPath string) (string, error) {
|
|
iconPath, infoPlistErr := a.parseMacAppIconFromInfoPlist(ctx, appPath)
|
|
if infoPlistErr == nil {
|
|
return iconPath, nil
|
|
}
|
|
a.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("get icon from info.plist fail, try to parse with cgo path=%s, err=%s", appPath, infoPlistErr.Error()))
|
|
|
|
iconPath2, cgoErr := a.parseMacAppIconFromCgo(ctx, appPath)
|
|
if cgoErr == nil {
|
|
return iconPath2, nil
|
|
} else {
|
|
a.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("get icon from cgo fail, return default icon path=%s, err=%s", appPath, cgoErr.Error()))
|
|
}
|
|
|
|
return "", fmt.Errorf("info plist err: %s, cgo err: %s", infoPlistErr.Error(), cgoErr.Error())
|
|
}
|
|
|
|
func (a *MacRetriever) parseMacAppIconFromInfoPlist(ctx context.Context, appPath string) (string, error) {
|
|
plistPath := path.Join(appPath, "Contents", "Info.plist")
|
|
plistFile, openErr := os.Open(plistPath)
|
|
if openErr != nil {
|
|
plistPath = path.Join(appPath, "WrappedBundle", "Info.plist")
|
|
plistFile, openErr = os.Open(plistPath)
|
|
if openErr != nil {
|
|
return "", fmt.Errorf("can't find Info.plist in this app: %s", openErr.Error())
|
|
}
|
|
}
|
|
defer plistFile.Close()
|
|
|
|
decoder := plist.NewDecoder(plistFile)
|
|
var plistData map[string]any
|
|
decodeErr := decoder.Decode(&plistData)
|
|
if decodeErr != nil {
|
|
return "", fmt.Errorf("failed to decode Info.plist: %s", decodeErr.Error())
|
|
}
|
|
|
|
// handle CFBundleIconFile
|
|
iconName, exist := plistData["CFBundleIconFile"].(string)
|
|
if exist {
|
|
if !strings.HasSuffix(iconName, ".icns") {
|
|
iconName = iconName + ".icns"
|
|
}
|
|
iconPath := path.Join(appPath, "Contents", "Resources", iconName)
|
|
if _, statErr := os.Stat(iconPath); os.IsNotExist(statErr) {
|
|
return "", fmt.Errorf("icon file not found: %s", iconPath)
|
|
}
|
|
|
|
return iconPath, nil
|
|
}
|
|
|
|
// handle CFBundleIcons if not found above
|
|
icons, cfBundleIconsExist := plistData["CFBundleIcons"].(map[string]any)
|
|
if cfBundleIconsExist {
|
|
primaryIcon, cfBundlePrimaryIconExist := icons["CFBundlePrimaryIcon"].(map[string]any)
|
|
if cfBundlePrimaryIconExist {
|
|
iconFiles, cfBundleIconFilesExist := primaryIcon["CFBundleIconFiles"].([]any)
|
|
if cfBundleIconFilesExist {
|
|
lastIconName := iconFiles[len(iconFiles)-1].(string)
|
|
iconPath := ""
|
|
files, readDirErr := os.ReadDir(path.Dir(plistPath))
|
|
if readDirErr == nil {
|
|
for _, file := range files {
|
|
if strings.HasPrefix(file.Name(), lastIconName) {
|
|
iconPath = path.Join(path.Dir(plistPath), file.Name())
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if iconPath != "" {
|
|
return iconPath, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("info plist doesn't have CFBundleIconFile property")
|
|
}
|
|
|
|
func (a *MacRetriever) parseMacAppIconFromCgo(ctx context.Context, appPath string) (string, error) {
|
|
cPath := C.CString(appPath)
|
|
defer C.free(unsafe.Pointer(cPath))
|
|
|
|
var length C.size_t
|
|
cIcon := C.GetPrefPaneIcon(cPath, &length)
|
|
if cIcon != nil {
|
|
defer C.free(unsafe.Pointer(cIcon))
|
|
pngBytes := C.GoBytes(unsafe.Pointer(cIcon), C.int(length))
|
|
imgReader := bytes.NewReader(pngBytes)
|
|
img, _, err := image.Decode(imgReader)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to decode icon image with system api: %v", err)
|
|
}
|
|
|
|
iconPathMd5 := fmt.Sprintf("%x", md5.Sum([]byte(appPath)))
|
|
iconCachePath := path.Join(util.GetLocation().GetImageCacheDirectory(), fmt.Sprintf("app_cgo_%s.png", iconPathMd5))
|
|
saveErr := imaging.Save(img, iconCachePath)
|
|
if saveErr != nil {
|
|
return "", saveErr
|
|
}
|
|
|
|
return iconCachePath, nil
|
|
}
|
|
|
|
return "", errors.New("no icon found with system api")
|
|
}
|
|
|
|
func (a *MacRetriever) GetPid(ctx context.Context, app appInfo) int {
|
|
if util.GetSystemTimestamp()-a.lastProcessUpdateTime > 1000 {
|
|
a.lastProcessUpdateTime = util.GetSystemTimestamp()
|
|
a.runningProcesses = a.getRunningProcesses()
|
|
}
|
|
|
|
for _, proc := range a.runningProcesses {
|
|
if strings.HasPrefix(proc.Path, app.Path) {
|
|
return proc.Pid
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
func (a *MacRetriever) getRunningProcesses() (infos []processInfo) {
|
|
var procList *C.struct_kinfo_proc
|
|
var procCount C.size_t
|
|
|
|
if C.get_process_list(&procList, &procCount) == -1 {
|
|
return
|
|
}
|
|
defer C.free(unsafe.Pointer(procList))
|
|
|
|
slice := (*[1 << 30]C.struct_kinfo_proc)(unsafe.Pointer(procList))[:procCount:procCount]
|
|
|
|
for _, proc := range slice {
|
|
pid := proc.kp_proc.p_pid
|
|
ppid := proc.kp_eproc.e_ppid
|
|
if ppid > 1 {
|
|
//only show user process
|
|
continue
|
|
}
|
|
cPath := C.get_process_path(pid)
|
|
if cPath == nil {
|
|
continue
|
|
}
|
|
appPath := C.GoString(cPath)
|
|
C.free(unsafe.Pointer(cPath))
|
|
if appPath == "" {
|
|
continue
|
|
}
|
|
|
|
infos = append(infos, processInfo{
|
|
Pid: int(pid),
|
|
Path: appPath,
|
|
})
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (a *MacRetriever) OpenAppFolder(ctx context.Context, app appInfo) error {
|
|
return shell.OpenFileInFolder(app.Path)
|
|
}
|