Files
2026-07-16 15:59:19 -04:00

167 lines
5.2 KiB
Go

// Package apps detects installed AI desktop applications (and AI IDEs as apps)
// and whether they are running. Discovery is per-OS: macOS .app bundles +
// Info.plist, Windows uninstall registry keys, Linux .desktop files. Liveness
// comes from the shared process snapshot.
package apps
import (
"strings"
"github.com/fleetdm/fleet/v4/orbit/pkg/table/ai_tools/internal/fsutil"
"github.com/fleetdm/fleet/v4/orbit/pkg/table/ai_tools/internal/homes"
"github.com/fleetdm/fleet/v4/orbit/pkg/table/ai_tools/internal/proc"
)
// App is a detected AI desktop application.
type App struct {
Name string
Vendor string
Path string
BundleID string
Version string
PlatformSource string // applications | registry | desktop-file
Scope string // system | user
ServesLocalAPI int
APIPort int
Running int
PID int
SHA256 string // hash of the app's primary executable (best-effort, diffable identity)
execPath string // resolved executable file, set per-platform; hashed in Scan
}
type knownApp struct {
name string
match []string // tokens matched against display name / bundle id (lowercased)
processNames []string // tokens matched against running process names (lowercased)
apiPort int // local inference API port, if any
}
func knownApps() []knownApp {
return []knownApp{
{"claude-desktop", []string{"claude"}, []string{"claude"}, 0},
{"chatgpt", []string{"chatgpt"}, []string{"chatgpt"}, 0},
{"ollama", []string{"ollama"}, []string{"ollama"}, 11434},
{"lm-studio", []string{"lm studio", "lmstudio", "lm-studio"}, []string{"lm studio", "lm-studio", "lmstudio"}, 1234},
{"jan", []string{"jan.app", "jan ", "/jan"}, []string{"jan"}, 1337},
{"gpt4all", []string{"gpt4all"}, []string{"gpt4all"}, 0},
{"msty", []string{"msty"}, []string{"msty"}, 0},
{"anythingllm", []string{"anythingllm", "anything llm"}, []string{"anythingllm"}, 0},
{"comet", []string{"comet.app", "comet "}, []string{"comet"}, 0}, // Perplexity Comet (AI browser)
{"dia", []string{"dia.app", "dia "}, []string{"dia"}, 0}, // Browser Company Dia (AI browser)
{"perplexity", []string{"perplexity"}, []string{"perplexity"}, 0},
{"cursor", []string{"cursor"}, []string{"cursor"}, 0},
{"windsurf", []string{"windsurf"}, []string{"windsurf"}, 0},
{"antigravity", []string{"antigravity"}, []string{"antigravity"}, 0},
{"trae", []string{"trae.app", "trae "}, []string{"trae"}, 0},
{"lm-studio-cli", []string{"lms"}, []string{"lms"}, 0},
}
}
// Scan returns all detected AI apps with running state filled in.
func Scan(homesList []homes.Home, snap *proc.Snapshot) []App {
out := scanApps(homesList) // platform-specific (build-tagged)
for i := range out {
k, ok := knownByName(out[i].Name)
if !ok {
continue
}
if k.apiPort > 0 {
out[i].ServesLocalAPI = 1
out[i].APIPort = k.apiPort
}
markRunning(&out[i], k, snap)
// Hash the primary executable (best-effort): per-platform execPath first,
// falling back to Path when it points directly at a file.
if h := fsutil.SHA256(out[i].execPath); h != "" {
out[i].SHA256 = h
} else {
out[i].SHA256 = fsutil.SHA256(out[i].Path)
}
}
return out
}
// containsWordBoundary reports whether want appears in pn delimited by
// non-alphanumeric characters (or the string edges). Several processNames tokens
// are short, common substrings (e.g. "dia", "jan"); a plain strings.Contains
// would falsely match unrelated processes like "mediaanalysisd", marking an app
// Running with an unrelated PID. It scans every occurrence so a bounded match
// later in the string is still found.
func containsWordBoundary(pn, want string) bool {
if want == "" {
return false
}
for from := 0; from <= len(pn)-len(want); {
i := strings.Index(pn[from:], want)
if i < 0 {
return false
}
i += from
before := i == 0 || !isAlnum(pn[i-1])
after := i+len(want) == len(pn) || !isAlnum(pn[i+len(want)])
if before && after {
return true
}
from = i + 1
}
return false
}
func isAlnum(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
// matchKnown finds the AI app a set of identifying strings belongs to.
func matchKnown(tokens ...string) (knownApp, bool) {
hay := strings.ToLower(strings.Join(tokens, "\x00"))
for _, k := range knownApps() {
for _, m := range k.match {
if strings.Contains(hay, m) {
return k, true
}
}
}
return knownApp{}, false
}
// firstNonEmpty is used by scanApps on darwin and linux; the windows build has
// no caller, so it is exempt from the unused check there.
//
//nolint:unused
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func knownByName(name string) (knownApp, bool) {
for _, k := range knownApps() {
if k.name == name {
return k, true
}
}
return knownApp{}, false
}
func markRunning(a *App, k knownApp, snap *proc.Snapshot) {
if snap == nil {
return
}
for pid, p := range snap.Procs {
pn := strings.ToLower(p.Name)
if pn == "" {
continue
}
for _, want := range k.processNames {
if pn == want || containsWordBoundary(pn, want) {
a.Running, a.PID = 1, pid
return
}
}
}
}