Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 59 additions & 13 deletions Sources/mas/Models/InstalledApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))])
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/mas/Utilities/Swift/Collection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
//

extension Collection where Element: Sendable {
func concurrentMap<T: Sendable>( // swiftlint:disable:this unused_declaration
func concurrentMap<T: Sendable>(
maxConcurrentTaskCount: Int = defaultMaxConcurrentTaskCount,
_ transform: @escaping @Sendable (Element) async -> T,
) async -> [T] { // periphery:ignore
) async -> [T] {
await concurrentTransform(maxConcurrentTaskCount: maxConcurrentTaskCount, transform)
}

Expand Down
64 changes: 64 additions & 0 deletions Tests/MASTests/Models/MASTests+InstalledApp.swift
Original file line number Diff line number Diff line change
@@ -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 {

Check warning on line 14 in Tests/MASTests/Models/MASTests+InstalledApp.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "uses App Store name for installed app" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=mas-cli_mas&issues=AZ_JbSi8SSHY8RHOD7J3&open=AZ_JbSi8SSHY8RHOD7J3&pullRequest=1292

Check warning on line 14 in Tests/MASTests/Models/MASTests+InstalledApp.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove backticks (`) from "uses App Store name for installed app" and rename it.

See more on https://sonarcloud.io/project/issues?id=mas-cli_mas&issues=AZ_JbSi8SSHY8RHOD7J4&open=AZ_JbSi8SSHY8RHOD7J4&pullRequest=1292
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 {

Check warning on line 26 in Tests/MASTests/Models/MASTests+InstalledApp.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "falls back to approximated name of installed app unknown to App Store" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=mas-cli_mas&issues=AZ_JbSi8SSHY8RHOD7J5&open=AZ_JbSi8SSHY8RHOD7J5&pullRequest=1292

Check warning on line 26 in Tests/MASTests/Models/MASTests+InstalledApp.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove backticks (`) from "falls back to approximated name of installed app unknown to App Store" and rename it.

See more on https://sonarcloud.io/project/issues?id=mas-cli_mas&issues=AZ_JbSi8SSHY8RHOD7J6&open=AZ_JbSi8SSHY8RHOD7J6&pullRequest=1292
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 {

Check warning on line 37 in Tests/MASTests/Models/MASTests+InstalledApp.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "keeps approximated name of installed app without an ADAM ID" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=mas-cli_mas&issues=AZ_JbSi8SSHY8RHOD7J7&open=AZ_JbSi8SSHY8RHOD7J7&pullRequest=1292

Check warning on line 37 in Tests/MASTests/Models/MASTests+InstalledApp.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove backticks (`) from "keeps approximated name of installed app without an ADAM ID" and rename it.

See more on https://sonarcloud.io/project/issues?id=mas-cli_mas&issues=AZ_JbSi8SSHY8RHOD7J8&open=AZ_JbSi8SSHY8RHOD7J8&pullRequest=1292
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",

Check warning on line 61 in Tests/MASTests/Models/MASTests+InstalledApp.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=mas-cli_mas&issues=AZ_JbSi8SSHY8RHOD7J9&open=AZ_JbSi8SSHY8RHOD7J9&pullRequest=1292
NSMetadataItemVersionKey: "1.3.0",
]
}