@@ -178,9 +178,9 @@ func Diff(current *api.FleetState, proposed *parser.ParsedRepo, teamFilters []st
178178 // the field is null but the YAML defines fleet-maintained apps,
179179 // silently reconstruct the current state from software titles +
180180 // the fleet-maintained catalog so we can produce an accurate diff.
181- if currentTeam .Software .FleetMaintained == nil &&
182- len (proposedTeam .Software .FleetMaintained ) > 0 {
183- inferred := inferFleetMaintainedApps (currentTeam , current .FleetMaintainedCatalog )
181+ if currentTeam .Software .FleetMaintained == nil &&
182+ len (proposedTeam .Software .FleetMaintained ) > 0 {
183+ inferred := inferFleetMaintainedApps (currentTeam , current .FleetMaintainedCatalog , proposedTeam . Software . Packages )
184184 currentSoftware .FleetMaintained = inferred // may be nil; that's fine
185185 }
186186
@@ -599,27 +599,27 @@ func sortResourceChanges(rd *ResourceDiff) {
599599 sort .Slice (rd .Deleted , func (i , j int ) bool { return byName (rd .Deleted [i ], rd .Deleted [j ]) })
600600}
601601
602- func inferFleetMaintainedApps (team api.Team , catalog []api.FleetMaintainedApp ) []api.TeamFleetApp {
602+ func inferFleetMaintainedApps (team api.Team , catalog []api.FleetMaintainedApp , proposedPackages []parser. ParsedSoftwarePackage ) []api.TeamFleetApp {
603603 if len (team .SoftwareTitles ) == 0 || len (catalog ) == 0 {
604604 return nil
605605 }
606606
607- customPackageURLs := make (map [string ]bool )
608- for _ , p := range team .Software .Packages {
607+ // Build exclusion set from YAML-defined custom packages. We cannot use
608+ // team.Software.Packages because the API merges fleet-maintained apps
609+ // into that list when fleet_maintained_apps is null. The proposed YAML
610+ // packages are the authoritative set of custom (non-FMA) software.
611+ proposedPkgURLs := make (map [string ]bool )
612+ for _ , p := range proposedPackages {
609613 u := normalizeSoftwarePath (p .URL )
610614 if u != "" {
611- customPackageURLs [u ] = true
615+ proposedPkgURLs [u ] = true
612616 }
613617 }
614618
615- // Index 1 (strongest): catalog app ID -> catalog entry.
616- // The API returns fleet_maintained_app_id on software titles that were
617- // installed via a fleet-maintained app; this is the most reliable join.
618619 catalogByAppID := make (map [uint ]api.FleetMaintainedApp )
619- // Index 2: catalog SoftwareTitleID -> catalog entry.
620620 catalogByTitleID := make (map [uint ]api.FleetMaintainedApp )
621- // Index 3 (weakest): lowercase name|platform -> catalog entries.
622621 catalogByNamePlatform := make (map [string ][]api.FleetMaintainedApp )
622+ catalogByPlatform := make (map [string ][]api.FleetMaintainedApp )
623623 for _ , app := range catalog {
624624 if app .ID != 0 {
625625 catalogByAppID [app .ID ] = app
@@ -631,6 +631,10 @@ func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp) [
631631 if key != "" {
632632 catalogByNamePlatform [key ] = append (catalogByNamePlatform [key ], app )
633633 }
634+ plat := normalizeFleetPlatform (app .Platform )
635+ if plat != "" {
636+ catalogByPlatform [plat ] = append (catalogByPlatform [plat ], app )
637+ }
634638 }
635639
636640 inferred := make (map [string ]api.TeamFleetApp )
@@ -643,14 +647,11 @@ func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp) [
643647 }
644648
645649 packageURL := normalizeSoftwarePath (title .SoftwarePackage .PackageURL )
646- if packageURL != "" && customPackageURLs [packageURL ] {
650+ if packageURL != "" && proposedPkgURLs [packageURL ] {
647651 continue
648652 }
649653
650654 // Strategy 1: match by fleet_maintained_app_id (exact, from API).
651- // This works across all platforms (macOS source="apps", Windows
652- // source="programs"/"ps1_packages") so we check it before any
653- // source-based filtering.
654655 if title .SoftwarePackage .FleetMaintainedAppID != nil {
655656 if app , ok := catalogByAppID [* title .SoftwarePackage .FleetMaintainedAppID ]; ok {
656657 slug := normalizeSoftwarePath (app .Slug )
@@ -664,13 +665,6 @@ func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp) [
664665 }
665666 }
666667
667- // Strategies 2 and 3 rely on heuristics that only work reliably for
668- // macOS "apps" source titles. Skip other sources to avoid false matches
669- // against custom packages (source="programs", "deb_packages", etc.).
670- if ! strings .EqualFold (strings .TrimSpace (title .Source ), "apps" ) {
671- continue
672- }
673-
674668 // Strategy 2: match by SoftwareTitleID -> catalog SoftwareTitleID.
675669 if app , ok := catalogByTitleID [title .ID ]; ok {
676670 slug := normalizeSoftwarePath (app .Slug )
@@ -683,9 +677,24 @@ func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp) [
683677 }
684678 }
685679
686- // Strategy 3: match by name|platform (requires exactly 1 match).
680+ // Strategy 3: match by name|platform (requires exactly 1 catalog hit).
681+ // Windows titles often include arch suffixes (e.g., "Notepad++ (64-bit x64)")
682+ // that the catalog omits, so try the raw name first, then stripped.
683+ // The catalog uses short marketing names (e.g., "OBS", "Zoom") while the
684+ // OS reports full product names (e.g., "OBS Studio", "Zoom Workplace"),
685+ // so fall back to prefix matching when exact/stripped matching fails.
687686 key := fleetCatalogKey (title .Name , title .SoftwarePackage .Platform )
688687 matches := catalogByNamePlatform [key ]
688+ if len (matches ) != 1 {
689+ stripped := stripArchSuffix (title .Name )
690+ if stripped != strings .TrimSpace (strings .ToLower (title .Name )) {
691+ key = fleetCatalogKey (stripped , title .SoftwarePackage .Platform )
692+ matches = catalogByNamePlatform [key ]
693+ }
694+ }
695+ if len (matches ) != 1 {
696+ matches = catalogPrefixMatch (title .Name , title .SoftwarePackage .Platform , catalogByPlatform )
697+ }
689698 if len (matches ) != 1 {
690699 continue
691700 }
@@ -710,6 +719,31 @@ func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp) [
710719 return out
711720}
712721
722+ // catalogPrefixMatch finds catalog entries whose name is a prefix of the
723+ // title name on the same platform. Returns matches only when exactly one
724+ // catalog entry qualifies (ambiguous results are discarded). This handles
725+ // cases where the catalog uses short marketing names ("OBS", "Zoom") while
726+ // the OS reports full product names ("OBS Studio", "Zoom Workplace").
727+ func catalogPrefixMatch (titleName , titlePlatform string , byPlatform map [string ][]api.FleetMaintainedApp ) []api.FleetMaintainedApp {
728+ plat := normalizeFleetPlatform (titlePlatform )
729+ candidates := byPlatform [plat ]
730+ if len (candidates ) == 0 {
731+ return nil
732+ }
733+ norm := strings .TrimSpace (strings .ToLower (stripArchSuffix (titleName )))
734+ if norm == "" {
735+ return nil
736+ }
737+ var hits []api.FleetMaintainedApp
738+ for _ , app := range candidates {
739+ catName := strings .TrimSpace (strings .ToLower (app .Name ))
740+ if catName != "" && strings .HasPrefix (norm , catName ) {
741+ hits = append (hits , app )
742+ }
743+ }
744+ return hits
745+ }
746+
713747func fleetCatalogKey (name , platform string ) string {
714748 name = strings .TrimSpace (strings .ToLower (name ))
715749 platform = normalizeFleetPlatform (platform )
@@ -719,6 +753,12 @@ func fleetCatalogKey(name, platform string) string {
719753 return name + "|" + platform
720754}
721755
756+ var archSuffixRe = regexp .MustCompile (`(?i)\s*\((?:x64|x86|64-bit(?:\s+x64)?|32-bit|arm64|amd64)\)\s*$` )
757+
758+ func stripArchSuffix (name string ) string {
759+ return strings .TrimSpace (archSuffixRe .ReplaceAllString (name , "" ))
760+ }
761+
722762func normalizeFleetPlatform (platform string ) string {
723763 p := strings .TrimSpace (strings .ToLower (platform ))
724764 switch p {
0 commit comments