diff --git a/.periphery.yaml b/.periphery.yaml index 1f3586cb5..b6e74fd90 100644 --- a/.periphery.yaml +++ b/.periphery.yaml @@ -2,7 +2,7 @@ # .periphery.yaml # mas # -# Periphery 3.7.2 +# Periphery 3.7.4 # --- color: always diff --git a/AGENTS.md b/AGENTS.md index 45e51f7d2..f89731dab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,12 +40,12 @@ Do NOT refactor code if doing so makes the caller interface worse. Specifically: verbosity, introduces duplication bugs & makes code harder to maintain. Keep clean abstractions. Example of what NOT to do: ```swift - // ❌ BAD: Inlining capitalizingFirstCharacter at multiple call sites + // ❌ BAD: Inlining uppercasingFirst at multiple call sites action1.performing.prefix(1).uppercased() + action.performing.dropFirst() action2.performing.prefix(1).uppercased() + action.performing.dropFirst() // ✅ GOOD: Use the utility function - action1.performing.capitalizingFirstCharacter - action2.performing.capitalizingFirstCharacter + action1.performing.uppercasingFirst + action2.performing.uppercasingFirst ``` - **Never replace a clean, readable abstraction with a verbose closure**. e.g., if a custom `SortComparator` or similar is used multiple times, keep it. diff --git a/Brewfile b/Brewfile index f10907897..3b93bcc5a 100644 --- a/Brewfile +++ b/Brewfile @@ -4,7 +4,7 @@ brew "gh" # 2.91.0 brew "git" # 2.54.0 brew "ipsw" # 3.1.672 brew "markdownlint-cli2" # 0.22.1 -brew "periphery" if MacOS.version >= :sequoia && `/usr/bin/arch` == "arm64" # 3.7.2 +brew "periphery" if MacOS.version >= :sequoia && `/usr/bin/arch` == "arm64" # 3.7.4 brew "shellcheck" # 0.11.0 brew "swiftformat" # 0.61.0 brew "swiftlint" # 0.63.2 diff --git a/README.md b/README.md index 5b5cbfc6f..365da272c 100644 --- a/README.md +++ b/README.md @@ -141,20 +141,23 @@ block for each app, with a blank line between apps. If `--json` is supplied, these commands output a stream of JSON objects—one per app—each containing all fields provided by Apple for that app. -Many of the JSON keys provided by Apple are poorly named, so they are mapped to +Many of the keys provided by Apple are poorly named, so they are mapped to better names by an algorithm. -Mapped JSON keys are [sorted]( +Mapped keys are [sorted]( https://developer.apple.com/documentation/foundation/nsstring/compareoptions/numeric ). -Each JSON key should be unique within an object; if duplicate keys exist in an +Each key should be unique within an object; if duplicate keys exist in an object, their relative ordering in the input is preserved in the output. -If Apple renames or adds JSON keys, suboptimal JSON keys might be output until -the mapping is updated. +For tabular output, if an object contains duplicate keys, the last value is +used. + +If Apple renames or adds keys, suboptimal keys might be output until the mapping +is updated. ## JSON Config Output @@ -162,8 +165,7 @@ the mapping is updated. If `--json` is supplied, `config` outputs all settings in a single JSON object. -Since the JSON keys are defined by mas, they are guaranteed to be unique & -correct. +Since the keys are defined by mas, they are guaranteed to be unique & correct. ## Spotlight diff --git a/Scripts/mas b/Scripts/mas index d5d871394..4a414a619 100755 --- a/Scripts/mas +++ b/Scripts/mas @@ -22,44 +22,33 @@ readonly jq [[ -t 2 ]] && readonly error_prefix=$'\u001B[4;31mError:\u001B[0m' || readonly error_prefix=Error: +# shellcheck disable=SC1072,SC1073 case "${1:-}" in outdated) - # shellcheck disable=SC1056,SC1072,SC1073 - { - # shellcheck disable=SC1083 - { exec "${mas}" "${@}" 3>&1 1>&4 2>&5 } | "${jq}" -sr ' -try ( - (map(.adamID | tostring | length) | max) as $max_adam_id_length | - (map(.version // "" | length) | max) as $max_version_length | - .[] | - (.adamID | tostring) as $adam_id | - (.version // "") as $version | - [ - " " * ($max_adam_id_length - ($adam_id | length)) + $adam_id, - .name, - "(" + $version + " " * ($max_version_length - ($version | length)) + " -> " + .newVersion + ")" - ] | - join("\u001f") -) catch ("'"${error_prefix}"' Invalid data from mas: \(.)\n" | halt_error(1)) -' | "${column}" -ts $'\u001f' - } 4>&1 5>&2 - ;; + readonly max_version_length_jq=$'\n\t(map(.version // "" | length) | max) as $max_version_length |' + readonly new_version_jq=$' + ($version | pad_spaces($max_version_length)) + " -> " + .newVersion' + ;| search) [[ -n ${argv[(r)--price]-} ]] && readonly output_price=' + [.formattedPrice // .price // "?"]' ;& -list) +list|outdated) + # shellcheck disable=SC1056 { - { exec "${mas}" "${@}" 3>&1 1>&4 2>&5 } | - "${jq}" -sr ' + # shellcheck disable=SC1083 + { exec "${mas}" "${@}" 3>&1 1>&4 2>&5 } | "${jq}" -sr ' +def pad_spaces($count): + " " * ($count - (. | length)) +; try ( - (map(.adamID | tostring | length) | max) as $max_adam_id_length | + (map(.adamID | tostring | length) | max) as $max_adam_id_length |'"${max_version_length_jq-}"' .[] | (.adamID | tostring) as $adam_id | + (.version // "") as $version | [ - " " * ($max_adam_id_length - ($adam_id | length)) + $adam_id, + ($adam_id | pad_spaces($max_adam_id_length)) + $adam_id, .name, - "(" + (.version // "") + ")" - ] '"${output_price-}"'| + "(" + $version'"${new_version_jq-}"' + ")" + ]'"${output_price-}"' | join("\u001f") ) catch ("'"${error_prefix}"' Invalid data from mas: \(.)\n" | halt_error(1)) ' | "${column}" -ts $'\u001f' diff --git a/Sources/mas/AppStore/AppStoreAction+download.swift b/Sources/mas/AppStore/AppStoreAction+download.swift index cb6b92830..9340dc242 100644 --- a/Sources/mas/AppStore/AppStoreAction+download.swift +++ b/Sources/mas/AppStore/AppStoreAction+download.swift @@ -266,8 +266,8 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { } MAS.printer.notice(PhaseType.downloaded, snapshot.appNameAndVersion) - MAS.printer.notice(action.performing.capitalizingFirstCharacter, snapshot.appNameAndVersion) - MAS.printer.info(action.rawValue.capitalizingFirstCharacter, "progress cannot be displayed", terminator: "") + MAS.printer.notice(action.performing.uppercasingFirst, snapshot.appNameAndVersion) + MAS.printer.info(action.rawValue.uppercasingFirst, "progress cannot be displayed", terminator: "") appFolderURL = try await install(appNameAndVersion: snapshot.appNameAndVersion) MAS.printer.clearCurrentLine(of: .standardOutput) } else { @@ -286,7 +286,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { } MAS.printer.notice( - [action.performed.capitalizingFirstCharacter, snapshot.appNameAndVersion] + [action.performed.uppercasingFirst, snapshot.appNameAndVersion] + (appFolderURL.map { ["in", $0.filePath] } ?? .init()), ) @@ -312,7 +312,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { "Multiple installations of ", snapshot.name ?? "unknown app", " exist in the applications folders\n\n", - action.performed.capitalizingFirstCharacter, + action.performed.uppercasingFirst, ":\n", appFolderPath, "\n\nOthers:\n", @@ -322,7 +322,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { } } else { MAS.printer.warning( - action.performed.capitalizingFirstCharacter, + action.performed.uppercasingFirst, snapshot.appNameAndVersion, "outside of the applications folders, in", appFolderURL.filePath, diff --git a/Sources/mas/Commands/Home.swift b/Sources/mas/Commands/Home.swift index bba1467a3..a941d5a7c 100644 --- a/Sources/mas/Commands/Home.swift +++ b/Sources/mas/Commands/Home.swift @@ -33,7 +33,7 @@ extension MAS { private func run(appStorePageURLStrings: [String]) async { await appStorePageURLStrings.forEach(attemptTo: "open") { appStorePageURLString in guard let url = URL(string: appStorePageURLString) else { - throw MASError.unparsableURL(appStorePageURLString) + throw MASError.invalidURL(appStorePageURLString) } _ = try await url.open() diff --git a/Sources/mas/Commands/Open.swift b/Sources/mas/Commands/Open.swift index c5fa0a314..725cac919 100644 --- a/Sources/mas/Commands/Open.swift +++ b/Sources/mas/Commands/Open.swift @@ -63,12 +63,12 @@ private func openMacAppStore() async throws { private func openMacAppStorePage(forAppStorePageURLString appStorePageURLString: String) async throws { guard var urlComponents = URLComponents(string: appStorePageURLString) else { - throw MASError.unparsableURL(appStorePageURLString) + throw MASError.invalidURL(appStorePageURLString) } urlComponents.scheme = masScheme guard let url = urlComponents.url else { - throw MASError.unparsableURL(.init(describing: urlComponents)) + throw MASError.invalidURL(.init(describing: urlComponents)) } _ = try await url.open() diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index a1fc64607..915b33792 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -6,8 +6,6 @@ // internal import ArgumentParser -private import JSON -private import JSONAST extension MAS { /// Outputs a list of installed apps which have updates available to be diff --git a/Sources/mas/Commands/Seller.swift b/Sources/mas/Commands/Seller.swift index 3d09edee4..61b60f3c0 100644 --- a/Sources/mas/Commands/Seller.swift +++ b/Sources/mas/Commands/Seller.swift @@ -43,7 +43,7 @@ extension MAS { private func run(sellerURLStrings: [String]) async { await sellerURLStrings.forEach(attemptTo: "open") { sellerURLString in guard let url = URL(string: sellerURLString) else { - throw MASError.unparsableURL(sellerURLString) + throw MASError.invalidURL(sellerURLString) } _ = try await url.open() diff --git a/Sources/mas/Models/CatalogApp.swift b/Sources/mas/Models/CatalogApp.swift index 02b66aa35..af56ce7b7 100644 --- a/Sources/mas/Models/CatalogApp.swift +++ b/Sources/mas/Models/CatalogApp.swift @@ -18,54 +18,53 @@ struct CatalogApp { let minimumOSVersion: String let name: String let sellerURLString: String? - let supportsMacDesktop: Bool let version: String - private let jsonObjectRaw: JSON.Object private let json: Lazy - fileprivate var asMacDesktopApp: Self? { - get async { - guard - supportsMacDesktop, - let minimumOSVersion = try? await URL(string: appStorePageURLString) - .flatMap({ url in - try SwiftSoup.parse(try await Dependencies.current.dataFrom(url).0, appStorePageURLString) - .getElementById("serialized-server-data")? // swiftformat:disable:this acronyms - .data() - .query( - string: """ - $.data[0].data.shelfMapping.information.items[?(@.title == 'Compatibility')].items[?(@.heading == 'Mac')].text - """, - )? - .firstMatch(of: minimumOSVersionRegex) - .map { String($0.version) } - }) - else { - return nil - } + fileprivate init?(macDesktopAppObject object: JSON.Object) async throws { + guard try object["supportedDevices"]?.decode(to: [String]?.self)?.contains("MacDesktop-MacDesktop") == true else { + return nil + } - return minimumOSVersion == self.minimumOSVersion - ? self // swiftformat:disable:this indent - : { - var jsonObjectRaw = jsonObjectRaw - if let index = jsonObjectRaw.fields.firstIndex(where: { $0.key == minimumOSVersionKey }) { - jsonObjectRaw.fields[index] = (minimumOSVersionKey, .string(minimumOSVersion)) - } else { - jsonObjectRaw.fields.append((minimumOSVersionKey, .string(minimumOSVersion))) - } - return .init( - adamID: adamID, - appStorePageURLString: appStorePageURLString, - minimumOSVersion: minimumOSVersion, - name: name, - sellerURLString: sellerURLString, - supportsMacDesktop: supportsMacDesktop, - version: version, - jsonObjectRaw: jsonObjectRaw, - ) - }() + let appStorePageURLString: String = try object["trackViewUrl"]?.decode() ?? "" + guard + let minimumOSVersion = try? await URL(string: appStorePageURLString) + .flatMap({ url in + try SwiftSoup.parse(try await Dependencies.current.dataFrom(url).0, appStorePageURLString) + .getElementById("serialized-server-data")? // swiftformat:disable:this acronyms + .data() + .query( + string: """ + $.data[0].data.shelfMapping.information.items[?(@.title == 'Compatibility')].items[?(@.heading == 'Mac')].text + """, + )? + .firstMatch(of: minimumOSVersionRegex) + .map { String($0.version) } + }) + else { + return nil } + + var jsonObject = object + let jsonMinimumOSVersion = try jsonObject[minimumOSVersionKey]?.decode() ?? "" + if jsonMinimumOSVersion != minimumOSVersion { + if let index = jsonObject.fields.firstIndex(where: { $0.key == minimumOSVersionKey }) { + jsonObject.fields[index] = (minimumOSVersionKey, .string(minimumOSVersion)) + } else { + jsonObject.fields.append((minimumOSVersionKey, .string(minimumOSVersion))) + } + } + + self.init( + adamID: try jsonObject["trackId"]?.decode() ?? 0, + appStorePageURLString: appStorePageURLString, + minimumOSVersion: minimumOSVersion, + name: try jsonObject["trackName"]?.decode() ?? "", + sellerURLString: try jsonObject["sellerUrl"]?.decode(), + version: try jsonObject["version"]?.decode() ?? "", + jsonObject: jsonObject, + ) } private init( @@ -74,19 +73,16 @@ struct CatalogApp { minimumOSVersion: String, name: String, sellerURLString: String?, - supportsMacDesktop: Bool, version: String, - jsonObjectRaw: JSON.Object, + jsonObject: JSON.Object, ) { self.adamID = adamID self.appStorePageURLString = appStorePageURLString self.minimumOSVersion = minimumOSVersion self.name = name self.sellerURLString = sellerURLString - self.supportsMacDesktop = supportsMacDesktop self.version = version - self.jsonObjectRaw = jsonObjectRaw - json = .init(.init(jsonObjectRaw.mappingKeys)) + json = .init(.init(jsonObject.normalized)) } } @@ -99,30 +95,32 @@ extension CatalogApp: CustomStringConvertible { extension CatalogApp: JSONDecodable { fileprivate init(json: JSON.Node) throws { guard case let .object(object) = json else { - throw MASError.unparsableJSON(.init(json)) + throw MASError.invalidJSON(.init(json)) } + try self.init(object: object) + } + + fileprivate init(object: JSON.Object) throws { self.init( adamID: try object["trackId"]?.decode() ?? 0, appStorePageURLString: try object["trackViewUrl"]?.decode() ?? "", - minimumOSVersion: try object["minimumOsVersion"]?.decode() ?? "", + minimumOSVersion: try object[minimumOSVersionKey]?.decode() ?? "", name: try object["trackName"]?.decode() ?? "", sellerURLString: try object["sellerUrl"]?.decode(), - supportsMacDesktop: // swiftformat:disable:next indent - try object["supportedDevices"]?.decode(to: [String]?.self)?.contains("MacDesktop-MacDesktop") ?? false, version: try object["version"]?.decode() ?? "", - jsonObjectRaw: object, + jsonObject: object, ) } } private extension JSON.Node { - var mappingKeys: Self { + var normalized: Self { switch self { case let .object(object): - .object(object.mappingKeys) + .object(object.normalized) case let .array(array): - .array(.init(array.elements.map(\.mappingKeys))) + .array(.init(array.elements.map(\.normalized))) default: self } @@ -130,18 +128,18 @@ private extension JSON.Node { } private extension JSON.Object { - var mappingKeys: Self { + var normalized: Self { .init( fields - .map { (.init(rawValue: $0.rawValue.mappingKey), $1.mappingKeys) } + .map { ($0.normalized, $1.normalized) } .sorted(using: KeyPathComparator(\.0.rawValue, comparator: NumericStringComparator.forward)), ) } } -private extension String { - var mappingKey: Self { - switch self { +private extension JSON.Key { + var normalized: Self { + switch rawValue { case "appletvScreenshotUrls": "appleTVScreenshotURLs" case "artistId": @@ -166,8 +164,8 @@ private extension String { "iPadScreenshotURLs" case "isVppDeviceBasedLicensingEnabled": "isVPPDeviceBasedLicensingEnabled" - case "minimumOsVersion": - minimumOSVersionKey.rawValue + case minimumOSVersionKey.rawValue: + "minimumOSVersion" case "primaryGenreId": "primaryCategoryID" case "primaryGenreName": @@ -213,75 +211,77 @@ private extension String { : // swiftformat:disable:this indent self default: - replacing(artworkURLRegex) { match in - let output = match.output - guard let first = output.0.first else { - return "" - } + .init( + rawValue: rawValue.replacing(artworkURLRegex) { match in + let output = match.output + guard let first = output.0.first else { + return "" + } - return first.isLowercase ? "icon\(output.1)URL" : "Icon\(output.1)URL" - } - .replacing(trackRegex) { match in - func track(_ prefix: Self) -> Self { - output.3.first.map { $0.isUppercase ? $0.lowercased() : "\(prefix)\(output.2)\($0)" } - ?? "\(prefix)\(output.2)" // swiftformat:disable:this indent + return first.isLowercase ? "icon\(output.1)URL" : "Icon\(output.1)URL" } + .replacing(trackRegex) { match in // swiftformat:disable indent + func track(_ prefix: String) -> String { + output.3.first.map { $0.isUppercase ? $0.lowercased() : "\(prefix)\(output.2)\($0)" } + ?? "\(prefix)\(output.2)" + } - let output = match.output - return switch output.1 { - case "track": - track("app") - case "Track": - track("App") - case "trackId": - "adamID\(output.2)\(output.3)" - case "TrackId": - "ADAMID\(output.2)\(output.3)" - default: - Self(output.0) - } - } - .replacing(manyRegex) { match in - let output = match.output - return switch output.1 { - case "appletv": - "appleTV\(output.2)" - case "Appletv": - "AppleTV\(output.2)" - case "artist": - "developer\(output.2)" - case "Artist": - "Developer\(output.2)" - case "artwork": - "icon\(output.2)" - case "Artwork": - "Icon\(output.2)" - case "genre": - output.2.isEmpty ? "category" : "categories" - case "Genre": - output.2.isEmpty ? "Category" : "Categories" - case "Id": - "ID\(output.2)" - case "ipad": - "iPad\(output.2)" - case "Ipad": - "IPad\(output.2)" - case "Os": - output.2.isEmpty ? "OS" : .init(output.0) - case "releaseDate": - "originalVersionReleaseDate\(output.2)" - case "Url": - "URL\(output.2)" - case "view": - "appStorePage\(output.2)" - case "View": - "AppStorePage\(output.2)" - case "Vpp": - "VPP\(output.2)" - default: - Self(output.0) - } - } + let output = match.output + return switch output.1 { + case "track": + track("app") + case "Track": + track("App") + case "trackId": + "adamID\(output.2)\(output.3)" + case "TrackId": + "ADAMID\(output.2)\(output.3)" + default: + String(output.0) + } + } + .replacing(manyRegex) { match in + let output = match.output + return switch output.1 { + case "appletv": + "appleTV\(output.2)" + case "Appletv": + "AppleTV\(output.2)" + case "artist": + "developer\(output.2)" + case "Artist": + "Developer\(output.2)" + case "artwork": + "icon\(output.2)" + case "Artwork": + "Icon\(output.2)" + case "genre": + output.2.isEmpty ? "category" : "categories" + case "Genre": + output.2.isEmpty ? "Category" : "Categories" + case "Id": + "ID\(output.2)" + case "ipad": + "iPad\(output.2)" + case "Ipad": + "IPad\(output.2)" + case "Os": + output.2.isEmpty ? "OS" : .init(output.0) + case "releaseDate": + "originalVersionReleaseDate\(output.2)" + case "Url": + "URL\(output.2)" + case "view": + "appStorePage\(output.2)" + case "View": + "AppStorePage\(output.2)" + case "Vpp": + "VPP\(output.2)" + default: + String(output.0) + } + }, + ) // swiftformat:enable indent } } } @@ -297,15 +297,12 @@ private func lookup(appID: AppID, inRegion region: Region = appStoreRegion) asyn case let .bundleID(bundleID): URLQueryItem(name: "bundleId", value: bundleID) } - return if // swiftformat:disable:this wrap wrapArguments - let catalogApp = // swiftformat:disable:next indent - try await catalogApps(from: try url("lookup", queryItem, inRegion: region)).first - { - catalogApp + return if let catalogAppJSONObject = try await catalogAppJSONObjects("lookup", queryItem, inRegion: region).first { + try .init(object: catalogAppJSONObject) } else { - try await catalogApps(from: try url("lookup", queryItem, inRegion: region, additionalQueryItems: .init())) + try await catalogAppJSONObjects("lookup", queryItem, inRegion: region, additionalQueryItems: .init()) .first - .flatMap { await $0.asMacDesktopApp } + .flatMap { try await CatalogApp(macDesktopAppObject: $0) } ?? { throw MASError.unknownAppID(appID) }() } } @@ -316,40 +313,48 @@ func search(for searchTerm: String) async throws -> [CatalogApp] { private func search(for searchTerm: String, inRegion region: Region = appStoreRegion) async throws -> [CatalogApp] { let queryItem = URLQueryItem(name: "term", value: searchTerm) - let catalogApps = try await catalogApps(from: try url("search", queryItem, inRegion: region)) + let catalogApps = + try await catalogAppJSONObjects("search", queryItem, inRegion: region).map { try CatalogApp(object: $0) } let adamIDSet = Set(catalogApps.map(\.adamID)) return catalogApps.priorityMerge( - try await mas.catalogApps(from: try url("search", queryItem, inRegion: region, additionalQueryItems: .init())) - .filter { !adamIDSet.contains($0.adamID) } - .concurrentCompactMap { await $0.asMacDesktopApp }, + try await catalogAppJSONObjects("search", queryItem, inRegion: region, additionalQueryItems: .init()) + .concurrentCompactMap { catalogAppJSONObject in + guard + try catalogAppJSONObject["trackId"]?.decode(to: ADAMID?.self).map({ adamIDSet.contains($0) }) == false + else { + return nil + } + + return try await .init(macDesktopAppObject: catalogAppJSONObject) + }, ) { $0.name.similarity(to: searchTerm) } } -private func url( +private func catalogAppJSONObjects( _ action: String, _ queryItem: URLQueryItem, inRegion region: Region, additionalQueryItems: [URLQueryItem] = [.init(name: "entity", value: "desktopSoftware")], -) throws -> URL { +) async throws -> [JSON.Object] { let urlString = "https://itunes.apple.com/\(action)" - guard let url = URL(string: urlString) else { - throw MASError.unparsableURL(urlString) - } - - return url.appending( - queryItems: [.init(name: "media", value: "software")] - + additionalQueryItems - + [.init(name: "country", value: region), queryItem], - ) -} - -private func catalogApps(from url: URL) async throws -> [CatalogApp] { - try await unsafe Dependencies.current.dataFrom(url).0.withUnsafeBytes { bufferPointer in - try CatalogAppResults(json: .init(parsing: unsafe RawSpan(_unsafeBytes: unsafe bufferPointer))).results + return try await URL(string: urlString).map { url in + try await unsafe Dependencies.current + .dataFrom( + url.appending( + queryItems: [.init(name: "media", value: "software")] + + additionalQueryItems + + [.init(name: "country", value: region), queryItem], + ), + ) + .0 + .withUnsafeBytes { bufferPointer in + try CatalogAppResults(json: .init(parsing: unsafe RawSpan(_unsafeBytes: unsafe bufferPointer))).resultObjects + } } + ?? { throw MASError.invalidURL(urlString) }() // swiftformat:disable:this indent } -private let minimumOSVersionKey = JSON.Key("minimumOSVersion") +private let minimumOSVersionKey = JSON.Key("minimumOsVersion") private let artworkURLRegex = /(?:^artworkUrl|ArtworkUrl)(\d+)/ private let trackRegex = /((?:^track|Track)(?:Id)?)(s?)($|[\d\p{Upper}])/ // editorconfig-checker-disable-next-line diff --git a/Sources/mas/Models/CatalogAppResults.swift b/Sources/mas/Models/CatalogAppResults.swift index d09ab6b18..bea4fa643 100644 --- a/Sources/mas/Models/CatalogAppResults.swift +++ b/Sources/mas/Models/CatalogAppResults.swift @@ -10,14 +10,35 @@ private import JSONDecoding struct CatalogAppResults: JSONDecodable { let resultCount: Int // periphery:ignore - let results: [CatalogApp] + let resultObjects: [JSON.Object] + + private let _results: ThrowingLazy<[CatalogApp]> + + var results: [CatalogApp] { // periphery:ignore + get throws { // swiftlint:disable:previous unused_declaration + try _results.value + } + } init(json: JSON.Node) throws { guard case let .object(object) = json else { - throw MASError.unparsableJSON(.init(json)) + throw MASError.invalidJSON(.init(json)) } resultCount = try object["resultCount"]?.decode() ?? 0 - results = try object["results"]?.decode() ?? .init() + resultObjects = if case let .array(array) = object[nodeKey: "results"] { + try array.elements.map { element in + guard case let .object(object) = element else { + throw MASError.invalidJSON(.init(json)) + } + + return object + } + } else { + .init() + } + + let resultObjects = resultObjects + _results = .init(try resultObjects.map { try CatalogApp(json: .object($0)) }) } } diff --git a/Sources/mas/Models/InstalledApp.swift b/Sources/mas/Models/InstalledApp.swift index 4943b0a70..7a8ab2a21 100644 --- a/Sources/mas/Models/InstalledApp.swift +++ b/Sources/mas/Models/InstalledApp.swift @@ -19,11 +19,14 @@ struct InstalledApp { let path: String let version: String - let jsonObject: Lazy - private let jsonObjectRaw: JSON.Object + private let _jsonObject: Lazy private let json: Lazy + var jsonObject: JSON.Object { + _jsonObject.value + } + var isTestFlight: Bool { adamID == 0 } @@ -51,16 +54,16 @@ struct InstalledApp { ?? "" // swiftformat:disable:this indent version = .init(describing: valueByAttribute[NSMetadataItemVersionKey] ?? "") - jsonObjectRaw = .init(valueByAttribute.map { (.init(rawValue: $0.key), jsonNode(for: $0.value)) }) + jsonObjectRaw = .init(valueByAttribute.map { (.init(rawValue: $0.key), .init(for: $0.value)) }) let jsonObjectRaw = jsonObjectRaw let name = name - jsonObject = .init( + _jsonObject = .init( .init( - (jsonObjectRaw.fields.map { (.init(rawValue: $0.rawValue.mappingKey), $1) } + [("name", .string(name))]) + (jsonObjectRaw.fields.map { ($0.normalized, $1) } + [("name", .string(name))]) .sorted(using: KeyPathComparator(\.0.rawValue, comparator: NumericStringComparator.forward)), ), ) - let jsonObject = jsonObject + let jsonObject = _jsonObject json = .init(.init(jsonObject.value)) } @@ -94,42 +97,44 @@ extension [InstalledApp] { } } -private func jsonNode(for value: Any?) -> JSON.Node { - switch value { - case let jsonNode as JSON.Node: - jsonNode - case let number as NSNumber: // swiftlint:disable:this legacy_objc_type - number === kCFBooleanTrue || number === kCFBooleanFalse - ? .bool(number.boolValue) // swiftformat:disable:this indent - : .init(.init(describing: number)) ?? .null - case let date as Date: - .string(date.formatted(.iso8601)) - case let data as Data: - data.isEmpty - ? .string("") // swiftformat:disable:this indent // swiftlint:disable:this void_function_in_ternary - : { - var hex = "0x" - hex.reserveCapacity(2 + data.count * 2) - return .string( - data.reduce(into: hex) { hex, byte in - let byteHex = String(byte, radix: 16) - if byteHex.count < 2 { - hex += "0" - } - hex += byteHex - }, - ) - }() - case let array as [Any?]: - .array(.init(array.map { jsonNode(for: $0) })) - default: - value.map { .string(.init(describing: $0)) } ?? .null // swiftformat:disable:this indent +private extension JSON.Node { + init(for value: Any?) { + self = switch value { + case let jsonNode as JSON.Node: + jsonNode + case let number as NSNumber: // swiftlint:disable:this legacy_objc_type + number === kCFBooleanTrue || number === kCFBooleanFalse + ? .bool(number.boolValue) // swiftformat:disable:this indent + : .init(.init(describing: number)) ?? .null + case let date as Date: + .string(date.formatted(.iso8601)) + case let data as Data: + data.isEmpty // swiftlint:disable:next void_function_in_ternary + ? .string("") // swiftformat:disable:this indent + : { + var hex = "0x" + hex.reserveCapacity(2 + data.count * 2) + return .string( + data.reduce(into: hex) { hex, byte in + let byteHex = String(byte, radix: 16) + if byteHex.count < 2 { + hex += "0" + } + hex += byteHex + }, + ) + }() + case let array as [Any?]: + .array(.init(array.map { .init(for: $0) })) + default: + value.map { .string(.init(describing: $0)) } ?? .null // swiftformat:disable:this indent + } } } -private extension String { - var mappingKey: Self { - switch self { +private extension JSON.Key { + var normalized: Self { + switch rawValue { case NSMetadataItemCFBundleIdentifierKey: "bundleID" case "_kMDItemDisplayNameWithExtensions": @@ -241,10 +246,12 @@ private extension String { case NSMetadataItemVersionKey: "version" default: - replacing(keyRegex) { match in - let output = match.output - return output.1?.isEmpty == false ? "fileSystem" : output.2?.lowercased() ?? "" - } + .init( + rawValue: rawValue.replacing(keyRegex) { match in + let output = match.output + return output.1?.isEmpty == false ? "fileSystem" : output.2?.lowercased() ?? "" + }, + ) } } } diff --git a/Sources/mas/Models/MASError.swift b/Sources/mas/Models/MASError.swift index f4aec272b..624e5e8f1 100644 --- a/Sources/mas/Models/MASError.swift +++ b/Sources/mas/Models/MASError.swift @@ -7,10 +7,10 @@ enum MASError: Error { case error(String, cause: (any Error)? = nil, separatorWhenCause: String = ":\n", separatorWhenNoCause: String = "") + case invalidJSON(String) + case invalidURL(String) case noCatalogAppsFound(for: String) case unknownAppID(AppID) - case unparsableJSON(String? = nil) - case unparsableURL(String) static func error( _ message: String, @@ -32,14 +32,14 @@ extension MASError: CustomStringConvertible { switch self { case let .error(message, cause, separatorWhenCause, separatorWhenNoCause): "\(message)\(cause.map { "\(separatorWhenCause)\($0)" } ?? separatorWhenNoCause)" + case let .invalidJSON(string): + "Invalid JSON:\n\(string)" + case let .invalidURL(string): + "Invalid URL: \(string)" case let .noCatalogAppsFound(searchTerm): "No apps found in the App Store for search term: \(searchTerm)" case let .unknownAppID(appID): "No apps found in the App Store for \(appID)" - case let .unparsableJSON(string): - string.map { "Failed to parse JSON from:\n\($0)" } ?? "Failed to parse JSON" - case let .unparsableURL(string): - "Failed to parse URL from \(string)" } } } diff --git a/Sources/mas/Models/OutdatedApp.swift b/Sources/mas/Models/OutdatedApp.swift index e5cbef67d..31c4fcca8 100644 --- a/Sources/mas/Models/OutdatedApp.swift +++ b/Sources/mas/Models/OutdatedApp.swift @@ -16,7 +16,7 @@ struct OutdatedApp { init(installedApp: InstalledApp, newVersion: String) { self.installedApp = installedApp self.newVersion = newVersion - var jsonObjectInstalled = installedApp.jsonObject.value + var jsonObjectInstalled = installedApp.jsonObject jsonObjectInstalled.fields.insert( (newVersionKey, .string(newVersion)), at: jsonObjectInstalled.fields diff --git a/Sources/mas/Models/Region.swift b/Sources/mas/Models/Region.swift index a7dfe2b0d..0edba709b 100644 --- a/Sources/mas/Models/Region.swift +++ b/Sources/mas/Models/Region.swift @@ -10,9 +10,9 @@ private import Foundation typealias Region = String private extension Region { - var appStoreRegion: Self { - switch self { // swiftlint:disable switch_case_on_newline - case "AD": "ES" // Andorra > Spain // swiftformat:disable wrapCaseBodies + var appStoreRegion: Self { // swiftlint:disable switch_case_on_newline + switch self { // swiftformat:disable wrapCaseBodies + case "AD": "ES" // Andorra > Spain case "AQ": "NO" // Antarctica > Norway case "AS": "US" // American Samoa > United States case "AW": "NL" // Aruba > Netherlands diff --git a/Sources/mas/Utilities/JSON/JSON.Object.swift b/Sources/mas/Utilities/JSON/JSON.Object.swift index 337297c65..30d73c101 100644 --- a/Sources/mas/Utilities/JSON/JSON.Object.swift +++ b/Sources/mas/Utilities/JSON/JSON.Object.swift @@ -9,11 +9,15 @@ internal import JSONAST internal import JSONDecoding extension JSON.Object { + subscript(nodeKey key: JSON.Key) -> JSON.Node? { + fields.first { $0.key == key }?.value + } + subscript(key: JSON.Key) -> JSON.OptionalDecoder { - .init(key: key, value: fields.first { $0.key == key }?.value) + .init(key: key, value: self[nodeKey: key]) } // periphery:ignore subscript(key: JSON.Key) -> JSON.FieldDecoder? { - (fields.first { $0.key == key }?.value).map { .init(key: key, value: $0) } + self[nodeKey: key].map { .init(key: key, value: $0) } } } diff --git a/Sources/mas/Utilities/Swift/Collection.swift b/Sources/mas/Utilities/Swift/Collection.swift index 8a14637c4..15e88cb5c 100644 --- a/Sources/mas/Utilities/Swift/Collection.swift +++ b/Sources/mas/Utilities/Swift/Collection.swift @@ -33,10 +33,10 @@ extension Collection where Element: Sendable { await concurrentCompactTransform(maxConcurrentTaskCount: maxConcurrentTaskCount, transform) } - func concurrentCompactMap( // swiftlint:disable:this unused_declaration + func concurrentCompactMap( maxConcurrentTaskCount: Int = defaultMaxConcurrentTaskCount, _ transform: @escaping @Sendable (Element) async throws -> T?, - ) async rethrows -> [T] { // periphery:ignore + ) async rethrows -> [T] { try await concurrentCompactTransform(maxConcurrentTaskCount: maxConcurrentTaskCount, transform) } diff --git a/Sources/mas/Utilities/Swift/Lazy.swift b/Sources/mas/Utilities/Swift/Lazy.swift index 398d6c89f..4a007a53a 100644 --- a/Sources/mas/Utilities/Swift/Lazy.swift +++ b/Sources/mas/Utilities/Swift/Lazy.swift @@ -8,25 +8,21 @@ private import os final class Lazy: Sendable { - private struct Storage { - var value = Value?.none - var initializer = (@Sendable () -> Value)?.none - } - - private let state: OSAllocatedUnfairLock + private let state: OSAllocatedUnfairLock<(value: Value?, initializer: (@Sendable () -> Value)?)> var value: Value { - state.withLock { storage in - storage.value ?? { - storage.value = storage.initializer!() // swiftlint:disable:this force_unwrapping - storage.initializer = nil - return storage.value! // swiftlint:disable:this force_unwrapping + state.withLock { state in + state.value ?? { + let value = state.initializer!() // swiftlint:disable:this force_unwrapping + state.value = value + state.initializer = nil + return value }() } } - init(_ initializer: @escaping @Sendable () -> Value) { - state = .init(initialState: Storage(value: nil, initializer: initializer)) + init(_ initializer: @escaping @Sendable () -> Value) { // swiftlint:disable:this unneeded_escaping + state = .init(initialState: (value: nil, initializer: initializer)) } convenience init(_ initializer: @autoclosure @escaping @Sendable () -> Value) { diff --git a/Sources/mas/Utilities/Swift/String.swift b/Sources/mas/Utilities/Swift/String.swift index 0c4acefad..1c3b79704 100644 --- a/Sources/mas/Utilities/Swift/String.swift +++ b/Sources/mas/Utilities/Swift/String.swift @@ -8,8 +8,8 @@ private import Foundation extension String { - var capitalizingFirstCharacter: Self { - prefix(1).capitalized + dropFirst() + var uppercasingFirst: Self { + prefix(1).uppercased() + dropFirst() } var quoted: Self { diff --git a/Sources/mas/Utilities/Swift/ThrowingLazy.swift b/Sources/mas/Utilities/Swift/ThrowingLazy.swift new file mode 100644 index 000000000..ee7e3ca50 --- /dev/null +++ b/Sources/mas/Utilities/Swift/ThrowingLazy.swift @@ -0,0 +1,37 @@ +// +// ThrowingLazy.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +private import os + +final class ThrowingLazy: Sendable { + private let state: OSAllocatedUnfairLock<(value: Value?, initializer: (@Sendable () throws -> Value)?)> + + var value: Value { + get throws { + try state.withLock { state in + try state.value ?? { + let value = try state.initializer!() // swiftlint:disable:this force_unwrapping + state.value = value + state.initializer = nil + return value + }() + } + } + } + + init(_ initializer: @escaping @Sendable () throws -> Value) { // swiftlint:disable:this unneeded_escaping + state = .init(initialState: (value: nil, initializer: initializer)) + } + + convenience init(_ initializer: @autoclosure @escaping @Sendable () throws -> Value) { + self.init(initializer) + } + + deinit { + // Empty + } +} diff --git a/Tests/MASTests/Models/MASTests+CatalogApp.swift b/Tests/MASTests/Models/MASTests+CatalogApp.swift index 45cffa8c5..81458f63b 100644 --- a/Tests/MASTests/Models/MASTests+CatalogApp.swift +++ b/Tests/MASTests/Models/MASTests+CatalogApp.swift @@ -48,7 +48,6 @@ private extension MASTests { && catalogApp.minimumOSVersion == "10.9" && catalogApp.name == "Slack" && catalogApp.sellerURLString == "https://slack.com" - && !catalogApp.supportsMacDesktop && catalogApp.version == "3.3.3", ) // swiftformat:enable indent } diff --git a/Tests/MASTests/Utilities/Resources.swift b/Tests/MASTests/Utilities/Resources.swift index 627d963b4..36e675c35 100644 --- a/Tests/MASTests/Utilities/Resources.swift +++ b/Tests/MASTests/Utilities/Resources.swift @@ -5,8 +5,7 @@ // Copyright © 2026 mas-cli. All rights reserved. // -internal import Foundation -private import JSONAST +private import Foundation internal import JSONDecoding private import JSONParsing @testable private import mas