From c6e1b9e037cad9714a0cce092f7de39ea7ab94ab Mon Sep 17 00:00:00 2001 From: MsfPablo <129399053+MsfPablo@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:47:00 +0200 Subject: [PATCH] Output actual App Store name of an installed app `InstalledApp` derived `name` solely from the Spotlight attribute `_kMDItemDisplayNameWithExtensions`, which records an app bundle's on-disk name rather than its App Store name. Neither Spotlight nor `Info.plist` records the App Store name, so the output name was only an approximation; e.g., "WFMU Radio" (324175340) was output as "WFMU". Resolve each installed app's name via the iTunes Search API instead, reusing the existing `Environment.lookupAppFromAppID` abstraction that `mas lookup` & `mas outdated` already use. Lookups run concurrently. Apps that the App Store does not know about, such as TestFlight apps, & apps whose lookup fails, such as when offline, keep their approximated names. `InstalledApp.init(for:)` must stay synchronous, so the resolved name is applied afterwards, in `installedApps(matching:withFullJSON:)`, before apps are sorted by name. `concurrentMap(maxConcurrentTaskCount:_:)` is no longer unused, so its `unused_declaration` & `periphery:ignore` suppressions are removed. Fixes #784 --- Sources/mas/Models/InstalledApp.swift | 72 +++++++++++++++---- Sources/mas/Utilities/Swift/Collection.swift | 4 +- .../Models/MASTests+InstalledApp.swift | 64 +++++++++++++++++ 3 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 Tests/MASTests/Models/MASTests+InstalledApp.swift diff --git a/Sources/mas/Models/InstalledApp.swift b/Sources/mas/Models/InstalledApp.swift index e5df988b5..fc1c1c1a1 100644 --- a/Sources/mas/Models/InstalledApp.swift +++ b/Sources/mas/Models/InstalledApp.swift @@ -28,20 +28,36 @@ struct InstalledApp { lazyJSONObject.value } - fileprivate init(for valueByAttribute: [String: Any]) { - adamID = valueByAttribute["kMDItemAppStoreAdamID"] as? ADAMID ?? 0 - bundleID = .init(describing: valueByAttribute[NSMetadataItemCFBundleIdentifierKey] ?? "") - name = .init(describing: valueByAttribute["_kMDItemDisplayNameWithExtensions"] ?? "").removingSuffix(".app") - path = valueByAttribute[NSMetadataItemPathKey].map { pathAny in - let path = String(describing: pathAny) - return (try? URL(folderPath: path).resourceValues(forKeys: [.canonicalPathKey]))?.canonicalPath ?? path - } - ?? "" - version = .init(describing: valueByAttribute[NSMetadataItemVersionKey] ?? "") + init(for valueByAttribute: [String: Any]) { + self.init( + adamID: valueByAttribute["kMDItemAppStoreAdamID"] as? ADAMID ?? 0, + bundleID: .init(describing: valueByAttribute[NSMetadataItemCFBundleIdentifierKey] ?? ""), + name: .init(describing: valueByAttribute["_kMDItemDisplayNameWithExtensions"] ?? "").removingSuffix(".app"), + path: valueByAttribute[NSMetadataItemPathKey].map { pathAny in + let path = String(describing: pathAny) + return (try? URL(folderPath: path).resourceValues(forKeys: [.canonicalPathKey]))?.canonicalPath ?? path + } + ?? "", + version: .init(describing: valueByAttribute[NSMetadataItemVersionKey] ?? ""), + jsonObjectRaw: .init(valueByAttribute.map { (.init(rawValue: $0.key), .init(for: $0.value)) }), + ) + } - jsonObjectRaw = .init(valueByAttribute.map { (.init(rawValue: $0.key), .init(for: $0.value)) }) - let jsonObjectRaw = jsonObjectRaw - let name = name + private init( + adamID: ADAMID, + bundleID: String, + name: String, + path: String, + version: String, + jsonObjectRaw: JSON.Object, + ) { + self.adamID = adamID + self.bundleID = bundleID + self.name = name + self.path = path + self.version = version + + self.jsonObjectRaw = jsonObjectRaw lazyJSONObject = .init( .init( (jsonObjectRaw.fields.map { ($0.normalized, $1) } + [("name", .string(name))]) @@ -60,6 +76,10 @@ struct InstalledApp { self.bundleID == bundleID } } + + fileprivate func named(_ name: String) -> Self { + .init(adamID: adamID, bundleID: bundleID, name: name, path: path, version: version, jsonObjectRaw: jsonObjectRaw) + } } extension InstalledApp: CustomStringConvertible { @@ -68,6 +88,31 @@ extension InstalledApp: CustomStringConvertible { } } +extension [InstalledApp] { + /// Replaces each app's Spotlight-derived name with its exact App Store name. + /// + /// Neither Spotlight nor `Info.plist` records the App Store name, so both + /// only approximate it; e.g., "WFMU Radio" (324175340) is named "WFMU" in + /// both. Apps that the App Store does not know about, such as TestFlight + /// apps, keep their approximated names. + var withCatalogNames: Self { + get async { + let lookupAppFromAppID = Environment.current.lookupAppFromAppID + return await concurrentMap { installedApp in + guard + installedApp.adamID != 0, + let catalogName = try? await lookupAppFromAppID(.adamID(installedApp.adamID)).name, + !catalogName.isEmpty + else { + return installedApp + } + + return installedApp.named(catalogName) + } + } + } +} + private extension JSON.Node { init(for value: Any?) { self = switch value { @@ -304,6 +349,7 @@ func installedApps( func installedApps(matching appIDs: [AppID], withFullJSON: Bool) async -> [InstalledApp] { await unsortedInstalledApps(matching: appIDs, withFullJSON: withFullJSON) + .withCatalogNames .sorted(using: KeyPathComparator(\.name, comparator: .localizedStandard)) } diff --git a/Sources/mas/Utilities/Swift/Collection.swift b/Sources/mas/Utilities/Swift/Collection.swift index 79961fef4..1da473f14 100644 --- a/Sources/mas/Utilities/Swift/Collection.swift +++ b/Sources/mas/Utilities/Swift/Collection.swift @@ -6,10 +6,10 @@ // extension Collection where Element: Sendable { - func concurrentMap( // swiftlint:disable:this unused_declaration + func concurrentMap( maxConcurrentTaskCount: Int = defaultMaxConcurrentTaskCount, _ transform: @escaping @Sendable (Element) async -> T, - ) async -> [T] { // periphery:ignore + ) async -> [T] { await concurrentTransform(maxConcurrentTaskCount: maxConcurrentTaskCount, transform) } diff --git a/Tests/MASTests/Models/MASTests+InstalledApp.swift b/Tests/MASTests/Models/MASTests+InstalledApp.swift new file mode 100644 index 000000000..0c7804327 --- /dev/null +++ b/Tests/MASTests/Models/MASTests+InstalledApp.swift @@ -0,0 +1,64 @@ +// +// MASTests+InstalledApp.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +private import Foundation +@testable private import mas +internal import Testing + +private extension MASTests { + @Test + func `uses App Store name for installed app`() async throws { + let catalogApp = try decode(CatalogApp.self, fromResource: "things-lookup") + let actual = try await consequencesOf( + await Environment.$current.withValue(environment { _ in catalogApp }) { + await [InstalledApp(for: installedThingsAttributes())].withCatalogNames.first?.name + }, + ) + let expected = Consequences("Things That Go Bump") + #expect(actual == expected) + } + + @Test + func `falls back to approximated name of installed app unknown to App Store`() async throws { + let actual = try await consequencesOf( + await Environment.$current.withValue(environment { throw MASError.unknownAppID($0) }) { + await [InstalledApp(for: installedThingsAttributes())].withCatalogNames.first?.name + }, + ) + let expected = Consequences("Things That Go Bmup") + #expect(actual == expected) + } + + @Test + func `keeps approximated name of installed app without an ADAM ID`() async throws { + let catalogApp = try decode(CatalogApp.self, fromResource: "things-lookup") + let actual = try await consequencesOf( + await Environment.$current.withValue(environment { _ in catalogApp }) { + await [InstalledApp(for: installedThingsAttributes(adamID: 0))].withCatalogNames.first?.name + }, + ) + let expected = Consequences("Things That Go Bmup") + #expect(actual == expected) + } +} + +private func environment( + lookingUpAppFromAppID lookupAppFromAppID: @escaping @Sendable (AppID) async throws -> CatalogApp, +) -> Environment { + .init(lookupAppFromAppID: lookupAppFromAppID) +} + +private func installedThingsAttributes(adamID: ADAMID = 1_472_954_003) -> [String: Any] { + [ + "kMDItemAppStoreAdamID": adamID, + NSMetadataItemCFBundleIdentifierKey: "uikitformac.com.tinybop.thingamabops", + // Deliberately misspelled to distinguish the approximated name from the App Store name + "_kMDItemDisplayNameWithExtensions": "Things That Go Bmup.app", + NSMetadataItemPathKey: "/Applications/Things That Go Bmup.app", + NSMetadataItemVersionKey: "1.3.0", + ] +}