diff --git a/.gitignore b/.gitignore index 0b715be3..a0afc9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ /.idea/ /.swiftpm/ /.vscode/ +/libexec/bin/mas .DS_Store *~ diff --git a/.swiftlint.yml b/.swiftlint.yml index 98ad6339..4e8e434d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -115,8 +115,8 @@ type_contents_order: - type_method - view_life_cycle_method - ib_action - - other_method - subscript + - other_method unneeded_override: affect_initializers: true unused_import: diff --git a/Package.resolved b/Package.resolved index ed3a1efb..e0afdc60 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fe336dc5a91893be96812ec91baba97112f3407b3c398b918ac3eeebc5bc9781", + "originHash" : "ece2543e322834455e09c620ad89bd4a2ee3adca1eeaa84753101e614956db76", "pins" : [ { "identity" : "bigint", @@ -19,6 +19,15 @@ "version" : "0.1.14" } }, + { + "identity" : "gram", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rarestype/gram", + "state" : { + "revision" : "2b8fe53177582c7e41eecaaf08a14ad94888759c", + "version" : "1.0.0" + } + }, { "identity" : "hitch", "kind" : "remoteSourceControl", @@ -73,6 +82,14 @@ "version" : "1.4.1" } }, + { + "identity" : "swift-json", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mas-cli/swift-json.git", + "state" : { + "revision" : "564110e7f56a573aafed49da379368a65faf0eb7" + } + }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 5d0d17b8..0310cd27 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,7 @@ private let swiftSettings = [ .enableUpcomingFeature("InternalImportsByDefault"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .strictMemorySafety(), .treatAllWarnings(as: .error), ] @@ -23,6 +24,7 @@ _ = Package( .package(url: "https://github.com/apple/swift-atomics.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.4.1"), .package(url: "https://github.com/attaswift/BigInt.git", from: "5.7.0"), + .package(url: "https://github.com/mas-cli/swift-json.git", revision: "564110e7f56a573aafed49da379368a65faf0eb7"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.13.2"), ], targets: [ @@ -33,6 +35,7 @@ _ = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Atomics", package: "swift-atomics"), + .product(name: "JSON", package: "swift-json"), .product(name: "OrderedCollections", package: "swift-collections"), "BigInt", "PrivateFrameworks", diff --git a/Scripts/mas b/Scripts/mas new file mode 100755 index 00000000..a89a91e1 --- /dev/null +++ b/Scripts/mas @@ -0,0 +1,117 @@ +#!/bin/zsh -Ndefgku + +# Copyright © 2026 mas-cli. All rights reserved. + +setopt pipefail + +path_or_simple_command() { + # shellcheck disable=SC2139 + [[ -x "${1}" ]] && printf %s "${1}" || printf %s "${1:t}" +} + +# shellcheck disable=SC2311 +mas="$(path_or_simple_command "${0:A:h}/../libexec/bin/mas")" +readonly mas +# shellcheck disable=SC2311 +column="$(path_or_simple_command /usr/bin/column)" +readonly column +# shellcheck disable=SC2311 +jq="$(path_or_simple_command /usr/bin/jq)" +readonly jq + +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 ("\u001B[4;31mError:\u001B[0m Invalid data from mas: \(.)\n" | halt_error(1)) +' | "${column}" -ts $'\u001f' + } 4>&1 5>&2 + ;; +list|search) + { + { exec "${mas}" "${@}" 3>&1 1>&4 2>&5 } | "${jq}" -sr ' +try ( + (map(.adamID | tostring | length) | max) as $max_adam_id_length | + .[] | + (.adamID | tostring) as $adam_id | + [ + " " * ($max_adam_id_length - ($adam_id | length)) + $adam_id, + .name, + "(" + (.version // "") + ")" + ] | + join("\u001f") +) catch ("\u001B[4;31mError:\u001B[0m Invalid data from mas: \(.)\n" | halt_error(1)) +' | "${column}" -ts $'\u001f' + } 4>&1 5>&2 + ;; +lookup|info) + { + { exec "${mas}" "${@}" 3>&1 1>&4 2>&5 } | "${jq}" -sr ' +def numberCommas($n): + ($n | abs | round | tostring) as $s | + if $n < 0 then "-" else "" end + ([$s | while(length > 0; .[:-3]) | .[-3:]] | reverse | join(",")) +; +try ( + { + "name": "App", + "version": "Version", + "formattedPrice": "Price", + "sellerName": "By", + "currentVersionReleaseDate": "Released", + "minimumOSVersion": "Minimum OS", + "fileSizeBytes": "Size", + "appStorePageURL": "From" + } as $key_map | + ($key_map | values | map(length) | max) as $max_key_length | + [ + .[]? as $in | + [ + ($key_map | keys_unsorted)[] | + select($in[.] != null) as $k | + "\($key_map[$k]) \("▁" * ($max_key_length - ($key_map[$k] | length) + 1)) \( + $in[$k] | + if $k == "fileSizeBytes" then + numberCommas(tonumber? / 1e6 // 0) + " MB" + elif $k == "currentVersionReleaseDate" then + (fromdateiso8601? | strflocaltime("%Y-%m-%d")) // . + else + . + end + )" + ] | + join("\n") + ] | + map(select(length > 0)) | if length > 0 then join("\n\n") else empty end +) catch ("\u001B[4;31mError:\u001B[0m Invalid data from mas: \(.)\n" | halt_error(1)) +' + } 4>&1 5>&2 + ;; +config) + { + { exec "${mas}" "${@}" 3>&1 1>&4 2>&5 } | "${jq}" -r ' +try ( + (keys_unsorted | map(length) | max) as $max_key_length | + to_entries[] | + "\(.key) \("▁" * ($max_key_length - (.key | length) + 1)) \(.value)" +) catch ("\u001B[4;31mError:\u001B[0m Invalid data from mas: \(.)\n" | halt_error(1)) +' + } 4>&1 5>&2 + ;; +*) + exec "${mas}" "${@}" + ;; +esac diff --git a/Scripts/package b/Scripts/package index 03dad559..701aaa15 100755 --- a/Scripts/package +++ b/Scripts/package @@ -28,19 +28,21 @@ swift package generate-manual mkdir -p "${installation_staging_folder}/bin" mkdir -p "${installation_staging_folder}/etc/bash_completion.d" +mkdir -p "${installation_staging_folder}/libexec/bin" mkdir -p "${installation_staging_folder}/share/fish/vendor_completions.d" mkdir -p "${installation_staging_folder}/share/man/man1" mkdir -p "${usr_local_bin_staging_folder}" -cp LICENSE README.md "${installation_staging_folder}" -cp contrib/completion/mas-completion.bash "${installation_staging_folder}/etc/bash_completion.d/mas" -cp contrib/completion/mas.fish "${installation_staging_folder}/share/fish/vendor_completions.d/mas.fish" -ln -f "$(swift build -c release --show-bin-path "${@:2}")/mas" "${installation_staging_folder}/bin/mas" -ln -f .build/plugins/GenerateManual/outputs/mas/mas.1 "${installation_staging_folder}/share/man/man1/mas.1" +cp -c "$(swift build -c release --show-bin-path "${@:2}")/mas" "${installation_staging_folder}/libexec/bin/mas" +cp -c .build/plugins/GenerateManual/outputs/mas/mas.1 "${installation_staging_folder}/share/man/man1/mas.1" +cp -c LICENSE README.md "${installation_staging_folder}" +cp -c Scripts/mas "${installation_staging_folder}/bin/mas" +cp -c contrib/completion/mas-completion.bash "${installation_staging_folder}/etc/bash_completion.d/mas" +cp -c contrib/completion/mas.fish "${installation_staging_folder}/share/fish/vendor_completions.d/mas.fish" ln -fs "${installation_folder}/bin/mas" "${usr_local_bin_staging_folder}/mas" -archs=("${(s: :n)$(lipo -archs "${installation_staging_folder}/bin/mas")}") +archs=("${(s: :n)$(lipo -archs "${installation_staging_folder}/libexec/bin/mas")}") # shellcheck disable=SC2034 readonly -a archs diff --git a/Scripts/setup_libexec b/Scripts/setup_libexec new file mode 100755 index 00000000..f31415e5 --- /dev/null +++ b/Scripts/setup_libexec @@ -0,0 +1,15 @@ +#!/bin/zsh -Ndefgku +# +# Scripts/setup_libexec +# mas +# +# Copyright © 2026 mas-cli. All rights reserved. +# +# Copies executable to libexec/bin/mas. +# + +. "${0:A:h}/_setup_script" + +mkdir -p libexec/bin +# shellcheck disable=SC1036,SC2086,SC2225 +cp -c .build/${1:-(debug|release)}/mas(om[1]) libexec/bin/mas diff --git a/Sources/mas/Commands/Config.swift b/Sources/mas/Commands/Config.swift index 1292cf78..bcc28176 100644 --- a/Sources/mas/Commands/Config.swift +++ b/Sources/mas/Commands/Config.swift @@ -8,6 +8,9 @@ internal import ArgumentParser private import Darwin private import Foundation +private import JSON +private import JSONAST +private import JSONEncoding extension MAS { /// Outputs mas config & related system info. @@ -16,24 +19,30 @@ extension MAS { abstract: "Output mas config & related system info", ) + @OptionGroup + private var outputFormatOptionGroup: OutputFormatOptionGroup + func run() { - printer.info( - """ - mas ▁▁▁▁ \(version) - slice ▁▁ \(runningSliceArchitecture) - slices ▁ \(supportedSliceArchitectures.joined(separator: " ")) - dist ▁▁▁ \(distribution) - origin ▁ \(gitOrigin) - rev ▁▁▁▁ \(gitRevision) - swift ▁▁ \(swiftVersion) - driver ▁ \(swiftDriverVersion) - store ▁▁ \(appStoreRegion) - region ▁ \(macRegion) - macos ▁▁ \(macOSVersion) - mac ▁▁▁▁ \(configStringValue("hw.product")) - cpu ▁▁▁▁ \(configStringValue("machdep.cpu.brand_string")) - arch ▁▁▁ \(configStringValue("hw.machine")) - """, + outputFormatOptionGroup.info( + JSON.encode( + KeyValuePairs( + dictionaryLiteral: + ("mas", version), + ("slice", runningSliceArchitecture), + ("slices", supportedSliceArchitectures.joined(separator: " ")), + ("dist", distribution), + ("origin", gitOrigin), + ("rev", gitRevision), + ("swift", swiftVersion), + ("driver", swiftDriverVersion), + ("store", appStoreRegion), + ("region", macRegion), + ("macos", macOSVersion), + ("mac", configStringValue("hw.product")), + ("cpu", configStringValue("machdep.cpu.brand_string")), + ("arch", configStringValue("hw.machine")), + ), + ), ) } } @@ -78,8 +87,10 @@ private var supportedSliceArchitectures: [String] { ?? [] // swiftformat:disable:this indent } -private var macOSVersion: Substring { - ProcessInfo.processInfo.operatingSystemVersionString.dropFirst(8).replacing("Build ", with: "", maxReplacements: 1) +private var macOSVersion: String { + String( + ProcessInfo.processInfo.operatingSystemVersionString.dropFirst(8).replacing("Build ", with: "", maxReplacements: 1), + ) } private func configStringValue(_ name: String) -> String { diff --git a/Sources/mas/Commands/List.swift b/Sources/mas/Commands/List.swift index 17679924..b2f58b39 100644 --- a/Sources/mas/Commands/List.swift +++ b/Sources/mas/Commands/List.swift @@ -15,6 +15,8 @@ extension MAS { abstract: "List apps installed from the App Store", ) + @OptionGroup + private var outputFormatOptionGroup: OutputFormatOptionGroup @OptionGroup private var installedAppIDsOptionGroup: InstalledAppIDsOptionGroup @@ -24,10 +26,7 @@ extension MAS { func run(installedApps: [InstalledApp]) { let installedApps = installedApps.filter(for: installedAppIDsOptionGroup.appIDs) - guard - let maxADAMIDLength = installedApps.map({ String(describing: $0.adamID).count }).max(), - let maxNameLength = installedApps.map(\.name.count).max() - else { + guard !installedApps.isEmpty else { printer.warning( """ No installed apps found @@ -48,18 +47,7 @@ extension MAS { return } - let format = "%\(maxADAMIDLength)lu %@ (%@)" - printer.info( - installedApps.map { installedApp in - String( - format: format, - installedApp.adamID, - installedApp.name.padding(toLength: maxNameLength, withPad: " ", startingAt: 0), - installedApp.version, - ) - } - .joined(separator: "\n"), - ) + outputFormatOptionGroup.info(installedApps.map(String.init(describing:)).joined(separator: "\n")) } } } diff --git a/Sources/mas/Commands/Lookup.swift b/Sources/mas/Commands/Lookup.swift index b946cfac..1fb85931 100644 --- a/Sources/mas/Commands/Lookup.swift +++ b/Sources/mas/Commands/Lookup.swift @@ -6,7 +6,6 @@ // internal import ArgumentParser -private import Foundation extension MAS { /// Outputs app information from the App Store. @@ -20,6 +19,8 @@ extension MAS { aliases: ["info"], ) + @OptionGroup + private var outputFormatOptionGroup: OutputFormatOptionGroup @OptionGroup private var catalogAppIDsOptionGroup: CatalogAppIDsOptionGroup @@ -28,32 +29,7 @@ extension MAS { } func run(catalogApps: [CatalogApp]) { - printer.info( - catalogApps.map { catalogApp in - """ - \(catalogApp.name) \(catalogApp.version) [\(catalogApp.displayPrice)] - By: \(catalogApp.sellerName) - Released: \(catalogApp.releaseDate.isoCalendarDate) - Minimum OS: \(catalogApp.minimumOSVersion) - Size: \(catalogApp.fileSizeBytes.humanReadableSize) - From: \(catalogApp.appStorePageURLString) - - """ - } - .joined(separator: "\n"), - terminator: "", - ) + outputFormatOptionGroup.info(catalogApps.map { "\($0)\n" }.joined(), terminator: "") } } } - -private extension String { - var humanReadableSize: Self { - Int64(self).map { $0.formatted(.byteCount(style: .file, allowedUnits: .mb, spellsOutZero: false)) } ?? self - } - - var isoCalendarDate: Self { - (try? Date(self, strategy: .iso8601).formatted(Date.ISO8601FormatStyle(timeZone: .current).year().month().day())) - ?? self // swiftformat:disable:this indent - } -} diff --git a/Sources/mas/Commands/OptionGroups/OutputFormatOptionGroup.swift b/Sources/mas/Commands/OptionGroups/OutputFormatOptionGroup.swift new file mode 100644 index 00000000..d14e348a --- /dev/null +++ b/Sources/mas/Commands/OptionGroups/OutputFormatOptionGroup.swift @@ -0,0 +1,26 @@ +// +// OutputFormatOptionGroup.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +private import ArgumentParser +private import Foundation + +struct OutputFormatOptionGroup: ParsableArguments { + @Flag(name: .customLong("json"), help: "Output JSON") + private var shouldOutputJSON = false + + func info(_ items: Any..., separator: String = " ", terminator: String = "\n") { + var stat = stat() + MAS.printer.info( + items, + separator: separator, + terminator: terminator, + to: unsafe shouldOutputJSON || fstat(3, &stat) != 0 || (stat.st_mode & S_IFMT) != S_IFIFO + ? .standardOutput // swiftformat:disable:this indent + : FileHandle(fileDescriptor: 3), + ) + } +} diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index 31c890aa..2d57e9b4 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -7,6 +7,8 @@ internal import ArgumentParser private import Foundation +private import JSONAST +private import JSONParsing extension MAS { /// Outputs a list of installed apps which have updates available to be @@ -16,6 +18,8 @@ extension MAS { abstract: "List pending app updates from the App Store", ) + @OptionGroup + private var outputFormatOptionGroup: OutputFormatOptionGroup @OptionGroup private var outdatedAppOptionGroup: OutdatedAppOptionGroup @OptionGroup @@ -39,24 +43,24 @@ extension MAS { } private func run(outdatedApps: [OutdatedApp]) { - guard - let maxADAMIDLength = outdatedApps.map({ String(describing: $0.installedApp.adamID).count }).max(), - let maxNameLength = outdatedApps.map(\.installedApp.name.count).max(), - let maxVersionLength = outdatedApps.map(\.installedApp.version.count).max() - else { + guard !outdatedApps.isEmpty else { return } - let format = "%\(maxADAMIDLength)lu %@ (%@ -> %@)" - printer.info( - outdatedApps.map { installedApp, newVersion in - String( - format: format, - installedApp.adamID, - installedApp.name.padding(toLength: maxNameLength, withPad: " ", startingAt: 0), - installedApp.version.padding(toLength: maxVersionLength, withPad: " ", startingAt: 0), - newVersion, - ) + outputFormatOptionGroup.info( + outdatedApps.compactMap { installedApp, newVersion in + do { + let newVersionKey = "newVersion" + var json = try JSON.Object(parsing: String(describing: installedApp)) + json.fields.insert( + (JSON.Key(rawValue: newVersionKey), JSON.Node.string(JSON.Literal(newVersion))), + at: json.fields.enumerated().first { newVersionKey < $1.key.rawValue }?.offset ?? json.fields.count, + ) // swiftlint:disable:previous unused_enumerated + return String(json) + } catch { + printer.error("Failed to parse outdated app JSON", installedApp, error: error, separator: "\n") + return nil + } } .joined(separator: "\n"), ) diff --git a/Sources/mas/Commands/Search.swift b/Sources/mas/Commands/Search.swift index fe42886d..107f6375 100644 --- a/Sources/mas/Commands/Search.swift +++ b/Sources/mas/Commands/Search.swift @@ -19,8 +19,11 @@ extension MAS { abstract: "Search for apps in the App Store", ) - @Flag(help: "Output the price of each app") - private var price = false + @OptionGroup + private var outputFormatOptionGroup: OutputFormatOptionGroup + // periphery:ignore + @Flag(help: "Output the price of each app") // swiftformat:disable:next unusedPrivateDeclarations + private var price = false // swiftlint:disable:this unused_declaration @OptionGroup private var searchTermOptionGroup: SearchTermOptionGroup @@ -31,26 +34,11 @@ extension MAS { } func run(catalogApps: [CatalogApp]) throws { - guard - let maxADAMIDLength = catalogApps.map({ String(describing: $0.adamID).count }).max(), - let maxNameLength = catalogApps.map(\.name.count).max() - else { + guard !catalogApps.isEmpty else { throw MASError.noCatalogAppsFound(for: searchTermOptionGroup.searchTerm) } - let format = "%\(maxADAMIDLength)lu %@ (%@)\(price ? " %@" : "")" - printer.info( - catalogApps.map { catalogApp in - String( - format: format, - catalogApp.adamID, - catalogApp.name.padding(toLength: maxNameLength, withPad: " ", startingAt: 0), - catalogApp.version, - catalogApp.displayPrice, - ) - } - .joined(separator: "\n"), - ) + outputFormatOptionGroup.info(catalogApps.map(String.init(describing:)).joined(separator: "\n")) } } } diff --git a/Sources/mas/Controllers/CatalogApp+ITunesSearch.swift b/Sources/mas/Controllers/CatalogApp+ITunesSearch.swift deleted file mode 100644 index 7cf2f9ae..00000000 --- a/Sources/mas/Controllers/CatalogApp+ITunesSearch.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// CatalogApp+ITunesSearch.swift -// mas -// -// Copyright © 2018 mas-cli. All rights reserved. -// - -internal import Foundation -private import Sextant -private import SwiftSoup - -func lookup(appID: AppID) async throws -> CatalogApp { - try await lookup(appID: appID, inRegion: appStoreRegion) -} - -/// Look up app details from the App Store catalog via the iTunes Search API. -/// -/// https://performance-partners.apple.com/search-api -/// -/// - Parameters: -/// - appID: App ID. -/// - region: The ISO 3166-1 alpha-2 region of the storefront in which to -/// lookup apps. -/// - Returns: A `CatalogApp` for the given `appID` if `appID` is valid. -/// - Throws: A `MASError.unknownAppID(appID)` if `appID` is invalid. -/// Some other `Error` if any other problem occurs. -func lookup(appID: AppID, inRegion region: Region = appStoreRegion) async throws -> CatalogApp { - let queryItem = switch appID { - case let .adamID(adamID): - URLQueryItem(name: "id", value: .init(adamID)) - case let .bundleID(bundleID): - URLQueryItem(name: "bundleId", value: bundleID) - } - return if // swiftformat:disable:this wrap wrapArguments - let catalogApp = // swiftformat:disable:next indent - try await getCatalogApps(from: try url("lookup", queryItem, inRegion: region)).first - { - catalogApp - } else { - try await getCatalogApps(from: try url("lookup", queryItem, inRegion: region, additionalQueryItems: [])) - .first // swiftformat:disable indent - .flatMap { catalogApp in - catalogApp.supportedDevices?.contains("MacDesktop-MacDesktop") == true - ? catalogApp.with(minimumOSVersion: await catalogApp.minimumOSVersionFromAppStorePage) - : nil - } - ?? { throw MASError.unknownAppID(appID) }() - } // swiftformat:enable indent -} - -private extension CatalogApp { - var minimumOSVersionFromAppStorePage: String { - get async { - do { - return try await URL(string: appStorePageURLString) - .flatMap { url in // swiftformat:disable indent - try unsafe SwiftSoup.parse(try await Dependencies.current.dataFrom(url).0, appStorePageURLString) - .select("#serialized-server-data") - .first()? - .data() - .query( - string: - "$.data[0].data.shelfMapping.information.items[?(@.title == 'Compatibility')].items[?(@.heading == 'Mac')].text", - )? - .firstMatch(of: minimumOSVersionRegex)? - .version - } - .map(String.init(_:)) ?? minimumOSVersion // swiftformat:enable indent - } catch { - return minimumOSVersion - } - } - } -} - -func search(for searchTerm: String) async throws -> [CatalogApp] { - try await search(for: searchTerm, inRegion: appStoreRegion) -} - -/// Search for app details from the App Store catalog via the iTunes Search API. -/// -/// https://performance-partners.apple.com/search-api -/// -/// - Parameters: -/// - searchTerm: Term for which to search. -/// - region: The ISO 3166-1 alpha-2 region of the storefront in which to -/// search for apps. -/// - Returns: A `[CatalogApp]` matching `searchTerm`. -/// - Throws: An `Error` if any problem occurs. -func search(for searchTerm: String, inRegion region: Region = appStoreRegion) async throws -> [CatalogApp] { - let queryItem = URLQueryItem(name: "term", value: searchTerm) - let catalogApps = try await getCatalogApps(from: try url("search", queryItem, inRegion: region)) - let adamIDSet = Set(catalogApps.map(\.adamID)) - return catalogApps.priorityMerge( // swiftformat:disable indent - try await getCatalogApps(from: try url("search", queryItem, inRegion: region, additionalQueryItems: [])) - .filter { ($0.supportedDevices?.contains("MacDesktop-MacDesktop") == true) && !adamIDSet.contains($0.adamID) } - .concurrentMap { $0.with(minimumOSVersion: await $0.minimumOSVersionFromAppStorePage) }, - ) { $0.name.similarity(to: searchTerm) } // swiftformat:enable indent -} - -private func url( - _ action: String, - _ queryItem: URLQueryItem, - inRegion region: Region, - additionalQueryItems: [URLQueryItem] = [URLQueryItem(name: "entity", value: "desktopSoftware")], -) throws -> URL { - let urlString = "https://itunes.apple.com/\(action)" - guard let url = URL(string: urlString) else { - throw MASError.unparsableURL(urlString) - } - - return url.appending( - queryItems: [URLQueryItem(name: "media", value: "software")] - + additionalQueryItems // swiftformat:disable indent - + [ - URLQueryItem(name: "country", value: region), - queryItem, - ], - ) // swiftformat:enable indent -} - -private func getCatalogApps(from url: URL) async throws -> [CatalogApp] { - let (data, _) = try await Dependencies.current.dataFrom(url) - do { - return try JSONDecoder().decode(CatalogAppResults.self, from: data).results - } catch { - throw MASError.error("Failed to parse JSON from response \(url)", error: .init(data: data, encoding: .utf8) ?? "") - } -} - -private nonisolated(unsafe) let minimumOSVersionRegex = /macOS\s*(?\S+)/ diff --git a/Sources/mas/Controllers/InstalledApp+Spotlight.swift b/Sources/mas/Controllers/InstalledApp+Spotlight.swift deleted file mode 100644 index 0286590e..00000000 --- a/Sources/mas/Controllers/InstalledApp+Spotlight.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// InstalledApp+Spotlight.swift -// mas -// -// Copyright © 2025 mas-cli. All rights reserved. -// - -private import Atomics -private import Foundation -private import ObjectiveC - -private extension URL { - var installedAppURLs: [URL] { - FileManager.default // swiftformat:disable indent - .enumerator(at: self, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) - .map { enumerator in - enumerator.compactMap { item in - guard - let url = item as? URL, - (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true, - url.pathExtension == "app" - else { - return nil as URL? - } - - enumerator.skipDescendants() - return try? url.appending(path: "Contents/_MASReceipt/receipt", directoryHint: .notDirectory) - .resourceValues(forKeys: [.fileSizeKey]) - .fileSize - .flatMap { $0 > 0 ? url : nil } - } - } - ?? [] - } // swiftformat:enable indent -} - -var installedApps: [InstalledApp] { - get async throws { - try await mas.installedApps(matching: "kMDItemAppStoreAdamID LIKE '*'") - } -} - -func installedApps(withADAMID adamID: ADAMID) async throws -> [InstalledApp] { - try await installedApps(matching: "kMDItemAppStoreAdamID = \(adamID)") -} - -@MainActor -func installedApps(matching metadataQuery: String) async throws -> [InstalledApp] { - var observer = (any NSObjectProtocol)?.none - defer { - if let observer { - NotificationCenter.default.removeObserver(observer) - } - } - - let query = NSMetadataQuery() - query.predicate = NSPredicate(format: metadataQuery) - query.searchScopes = applicationsFolderURLs - - return try await withCheckedThrowingContinuation { continuation in - let alreadyResumed = ManagedAtomic(false) - observer = NotificationCenter.default.addObserver( - forName: .NSMetadataQueryDidFinishGathering, - object: query, - queue: nil, - ) { notification in - guard !alreadyResumed.exchange(true, ordering: .acquiringAndReleasing) else { - return - } - guard let query = notification.object as? NSMetadataQuery else { - continuation.resume( - throwing: MASError.error( - "Notification Center returned a \(type(of: notification.object)) instead of a NSMetadataQuery", - ), - ) - return - } - - query.stop() - - let installedApps = query.results - .compactMap { result in // swiftformat:disable indent - (result as? NSMetadataItem).map { item in - InstalledApp( - adamID: item.value(forAttribute: "kMDItemAppStoreAdamID") as? ADAMID ?? 0, - bundleID: item.value(forAttribute: NSMetadataItemCFBundleIdentifierKey) as? String ?? "", - name: (item.value(forAttribute: "_kMDItemDisplayNameWithExtensions") as? String ?? "") - .removingSuffix(".app"), - path: item.value(forAttribute: NSMetadataItemPathKey) as? String ?? "", - version: item.value(forAttribute: NSMetadataItemVersionKey) as? String ?? "", - ) - } - } - .sorted(using: KeyPathComparator(\.name, comparator: .localizedStandard)) // swiftformat:enable indent - - if !["1", "true", "yes"].contains(ProcessInfo.processInfo.environment["MAS_NO_AUTO_INDEX"]?.lowercased()) { - let installedAppPathSet = Set(installedApps.map(\.path)) - for installedAppURL in applicationsFolderURLs.flatMap(\.installedAppURLs) - where !installedAppPathSet.contains(installedAppURL.filePath) { // swiftformat:disable:this indent - MAS.printer.warning( - "Found a likely App Store app that is not indexed in Spotlight in ", - installedAppURL.filePath, - """ - - - Indexing now, which will not complete until sometime after mas exits - - Disable auto-indexing via: export MAS_NO_AUTO_INDEX=1 - """, - separator: "", - ) - Task { - do { - _ = try await run( - "/usr/bin/mdimport", - installedAppURL.filePath, - errorMessage: "Failed to index the Spotlight data for \(installedAppURL.filePath)", - ) - } catch { - MAS.printer.error(error: error) - } - } - } - } - - continuation.resume(returning: installedApps) - } - - query.start() - } -} diff --git a/Sources/mas/Errors/MASError.swift b/Sources/mas/Errors/MASError.swift index ef93791e..94cff55b 100644 --- a/Sources/mas/Errors/MASError.swift +++ b/Sources/mas/Errors/MASError.swift @@ -9,6 +9,7 @@ enum MASError: Error { case error(String, error: (any Error)? = nil, separator: String = ":\n", separatorAndErrorReplacement: String = "") case noCatalogAppsFound(for: String) case unknownAppID(AppID) + case unparsableJSON(String? = nil) case unparsableURL(String) static func error( @@ -35,6 +36,8 @@ extension MASError: CustomStringConvertible { "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/CatalogApp.swift b/Sources/mas/Models/CatalogApp.swift index 12b02978..269f190f 100644 --- a/Sources/mas/Models/CatalogApp.swift +++ b/Sources/mas/Models/CatalogApp.swift @@ -5,83 +5,342 @@ // Copyright © 2018 mas-cli. All rights reserved. // +private import Foundation +private import JSONAST +private import JSONDecoding +private import JSONParsing +private import Sextant +private import SwiftSoup + struct CatalogApp { let adamID: ADAMID let appStorePageURLString: String - let bundleID: String - let fileSizeBytes: String - let formattedPrice: String? let minimumOSVersion: String let name: String - let releaseDate: String - let sellerName: String let sellerURLString: String? - let supportedDevices: [String]? // swiftlint:disable:this discouraged_optional_collection + let supportsMacDesktop: Bool let version: String - var displayPrice: String { - formattedPrice ?? "?" - } + private let json: String - init( - adamID: ADAMID = 0, - appStorePageURLString: String = "", - bundleID: String = "", - fileSizeBytes: String = "?", - formattedPrice: String? = "?", - minimumOSVersion: String = "", - name: String = "", - releaseDate: String = "", - sellerName: String = "", - sellerURLString: String? = nil, - supportedDevices: [String]? = nil, // swiftlint:disable:this discouraged_optional_collection - version: String = "", - ) { - self.adamID = adamID - self.appStorePageURLString = appStorePageURLString - self.bundleID = bundleID - self.fileSizeBytes = fileSizeBytes - self.formattedPrice = formattedPrice - self.minimumOSVersion = minimumOSVersion - self.name = name - self.releaseDate = releaseDate - self.sellerName = sellerName - self.sellerURLString = sellerURLString - self.supportedDevices = supportedDevices - self.version = version + fileprivate var minimumOSVersionFromAppStorePage: String { + get async { + do { + return try await URL(string: appStorePageURLString) + .flatMap { url in // swiftformat:disable indent + try unsafe SwiftSoup.parse(try await Dependencies.current.dataFrom(url).0, appStorePageURLString) + .select("#serialized-server-data") + .first()? + .data() + .query( + string: + "$.data[0].data.shelfMapping.information.items[?(@.title == 'Compatibility')].items[?(@.heading == 'Mac')].text", + )? + .firstMatch(of: minimumOSVersionRegex)? + .version + } + .map(String.init(_:)) ?? minimumOSVersion // swiftformat:enable indent + } catch { + return minimumOSVersion + } + } } - func with(minimumOSVersion: String) -> Self { + fileprivate func with(minimumOSVersion: String) -> Self { .init( adamID: adamID, appStorePageURLString: appStorePageURLString, - bundleID: bundleID, - fileSizeBytes: fileSizeBytes, - formattedPrice: formattedPrice, minimumOSVersion: minimumOSVersion, name: name, - releaseDate: releaseDate, - sellerName: sellerName, sellerURLString: sellerURLString, - supportedDevices: supportedDevices, + supportsMacDesktop: supportsMacDesktop, version: version, + json: json, ) } } -extension CatalogApp: Decodable { - enum CodingKeys: String, CodingKey { - case adamID = "trackId" - case appStorePageURLString = "trackViewUrl" - case bundleID = "bundleId" - case fileSizeBytes - case formattedPrice - case minimumOSVersion = "minimumOsVersion" - case name = "trackName" - case releaseDate = "currentVersionReleaseDate" - case sellerName - case sellerURLString = "sellerUrl" - case supportedDevices - case version +extension CatalogApp: CustomStringConvertible { + var description: String { + json + } +} + +extension CatalogApp: JSONDecodable { + fileprivate init(json: JSON.Node) throws { + guard case let .object(object) = json else { + throw MASError.unparsableJSON(String(describing: json)) + } + + adamID = try object["trackId"]?.decode() ?? 0 + appStorePageURLString = try object["trackViewUrl"]?.decode() ?? "" + minimumOSVersion = try object["minimumOsVersion"]?.decode() ?? "" + name = try object["trackName"]?.decode() ?? "" + sellerURLString = try object["sellerUrl"]?.decode() + supportsMacDesktop = try object["supportedDevices"]?.decode(to: [String]?.self)?.contains("MacDesktop-MacDesktop") + ?? false // swiftformat:disable:this indent + version = try object["version"]?.decode() ?? "" + self.json = String(describing: json.mappingKeys) + } +} + +private extension JSON.Node { + var mappingKeys: Self { + switch self { + case let .object(object): + .object( + JSON.Object( + object.fields + .map { (JSON.Key(rawValue: $0.rawValue.keyMapped), $1.mappingKeys) } // swiftformat:disable:this indent + .sorted(using: KeyPathComparator(\.0.rawValue, comparator: NumericStringComparator.forward)), + ), // swiftformat:disable:previous indent + ) + case let .array(array): + .array(JSON.Array(array.elements.map(\.mappingKeys))) + default: + self + } } } + +private extension String { + var keyMapped: Self { + switch self { + case "appletvScreenshotUrls": + "appleTVScreenshotURLs" + case "artistId": + "developerID" + case "artistName": + "developerName" + case "artistViewUrl": + "developerAppStorePageURL" + case "artworkUrl60": + "icon60URL" + case "artworkUrl100": + "icon100URL" + case "artworkUrl512": + "icon512URL" + case "bundleId": + "bundleID" + case "genreIds": + "categoryIDs" + case "genres": + "categories" + case "ipadScreenshotUrls": + "iPadScreenshotURLs" + case "isVppDeviceBasedLicensingEnabled": + "isVPPDeviceBasedLicensingEnabled" + case "minimumOsVersion": + "minimumOSVersion" + case "primaryGenreId": + "primaryCategoryID" + case "primaryGenreName": + "primaryCategoryName" + case "releaseDate": + "originalVersionReleaseDate" + case "screenshotUrls": + "screenshotURLs" + case "sellerUrl": + "sellerURL" + case "trackCensoredName": + "censoredName" + case "trackContentRating": + "contentRating" + case "trackId": + "adamID" + case "trackName": + "name" + case "trackViewUrl": + "appStorePageURL" + case + "advisories", + "averageUserRating", + "averageUserRatingForCurrentVersion", + "contentAdvisoryRating", + "currency", + "currentVersionReleaseDate", + "description", + "features", + "fileSizeBytes", + "formattedPrice", + "isGameCenterEnabled", + "kind", + "languageCodesISO2A", + "price", + "releaseNotes", + "sellerName", + "supportedDevices", + "userRatingCount", + "userRatingCountForCurrentVersion", + "version", + "wrapperType" + : // swiftformat:disable:this indent + self + default: + replacing(unsafe artworkURLRegex) { match in // swiftformat:disable indent + let output = match.output + guard let first = output.0.first else { + return "" + } + + return first.isLowercase ? "icon\(output.1)URL" : "Icon\(output.1)URL" + } + .replacing(unsafe trackRegex) { match in + func track(_ prefix: Self) -> Self { + 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(unsafe 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" : Self(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) + } + } + } + } // swiftformat:enable indent +} + +func lookup(appID: AppID) async throws -> CatalogApp { + try await lookup(appID: appID, inRegion: appStoreRegion) +} + +/// Look up app details from the App Store catalog via the iTunes Search API. +/// +/// https://performance-partners.apple.com/search-api +/// +/// - Parameters: +/// - appID: App ID. +/// - region: The ISO 3166-1 alpha-2 region of the storefront in which to +/// lookup apps. +/// - Returns: A `CatalogApp` for the given `appID` if `appID` is valid. +/// - Throws: A `MASError.unknownAppID(appID)` if `appID` is invalid. +/// Some other `Error` if any other problem occurs. +private func lookup(appID: AppID, inRegion region: Region = appStoreRegion) async throws -> CatalogApp { + let queryItem = switch appID { + case let .adamID(adamID): + URLQueryItem(name: "id", value: .init(adamID)) + case let .bundleID(bundleID): + URLQueryItem(name: "bundleId", value: bundleID) + } + return if // swiftformat:disable:this wrap wrapArguments + let catalogApp = // swiftformat:disable:next indent + try await getCatalogApps(from: try url("lookup", queryItem, inRegion: region)).first + { + catalogApp + } else { + try await getCatalogApps(from: try url("lookup", queryItem, inRegion: region, additionalQueryItems: [])) + .first // swiftformat:disable indent + .flatMap { $0.supportsMacDesktop ? $0.with(minimumOSVersion: await $0.minimumOSVersionFromAppStorePage) : nil } + ?? { throw MASError.unknownAppID(appID) }() + } // swiftformat:enable indent +} + +func search(for searchTerm: String) async throws -> [CatalogApp] { + try await search(for: searchTerm, inRegion: appStoreRegion) +} + +/// Search for app details from the App Store catalog via the iTunes Search API. +/// +/// https://performance-partners.apple.com/search-api +/// +/// - Parameters: +/// - searchTerm: Term for which to search. +/// - region: The ISO 3166-1 alpha-2 region of the storefront in which to +/// search for apps. +/// - Returns: A `[CatalogApp]` matching `searchTerm`. +/// - Throws: An `Error` if any problem occurs. +private func search(for searchTerm: String, inRegion region: Region = appStoreRegion) async throws -> [CatalogApp] { + let queryItem = URLQueryItem(name: "term", value: searchTerm) + let catalogApps = try await getCatalogApps(from: try url("search", queryItem, inRegion: region)) + let adamIDSet = Set(catalogApps.map(\.adamID)) + return catalogApps.priorityMerge( + try await getCatalogApps(from: try url("search", queryItem, inRegion: region, additionalQueryItems: [])) + .filter { $0.supportsMacDesktop && !adamIDSet.contains($0.adamID) } // swiftformat:disable:this indent + .concurrentMap { $0.with(minimumOSVersion: await $0.minimumOSVersionFromAppStorePage) }, + ) { $0.name.similarity(to: searchTerm) } // swiftformat:disable:previous indent +} + +private func url( + _ action: String, + _ queryItem: URLQueryItem, + inRegion region: Region, + additionalQueryItems: [URLQueryItem] = [URLQueryItem(name: "entity", value: "desktopSoftware")], +) throws -> URL { + let urlString = "https://itunes.apple.com/\(action)" + guard let url = URL(string: urlString) else { + throw MASError.unparsableURL(urlString) + } + + return url.appending( + queryItems: [URLQueryItem(name: "media", value: "software")] + + additionalQueryItems // swiftformat:disable indent + + [ + URLQueryItem(name: "country", value: region), + queryItem, + ], + ) // swiftformat:enable indent +} + +private func getCatalogApps(from url: URL) async throws -> [CatalogApp] { + let (data, _) = try await Dependencies.current.dataFrom(url) + guard let json = String(data: data, encoding: .utf8) else { + throw MASError.error("Failed to decode response from \(url) as UTF-8") + } + + return try CatalogAppResults(json: JSON.Node(parsingFragment: json)).results +} + +private nonisolated(unsafe) let artworkURLRegex = /(?:^artworkUrl|ArtworkUrl)(\d+)/ +private nonisolated(unsafe) let trackRegex = /((?:^track|Track)(?:Id)?)(s?)($|[\d\p{Upper}])/ +private nonisolated(unsafe) let manyRegex = /(^appletv|Appletv|^artist|Artist|^artwork|Artwork|^genre|Genre|Id|^ipad|Ipad|Os|^releaseDate|Url|^view|View|Vpp)(s?)(?=$|[\d\p{Upper}])/ +private nonisolated(unsafe) let minimumOSVersionRegex = /macOS\s*(?\S+)/ diff --git a/Sources/mas/Models/CatalogAppResults.swift b/Sources/mas/Models/CatalogAppResults.swift index 47936e0f..03af43f4 100644 --- a/Sources/mas/Models/CatalogAppResults.swift +++ b/Sources/mas/Models/CatalogAppResults.swift @@ -5,7 +5,19 @@ // Copyright © 2018 mas-cli. All rights reserved. // -struct CatalogAppResults: Decodable { // swiftlint:disable:next unused_declaration +internal import JSONAST +private import JSONDecoding + +struct CatalogAppResults: JSONDecodable { let resultCount: Int // periphery:ignore let results: [CatalogApp] + + init(json: JSON.Node) throws { + guard case let .object(object) = json else { + throw MASError.unparsableJSON(String(describing: json)) + } + + resultCount = try object["resultCount"]?.decode() ?? 0 + results = try object["results"]?.decode() ?? [] + } } diff --git a/Sources/mas/Models/InstalledApp.swift b/Sources/mas/Models/InstalledApp.swift index d7a4407c..a6d51ba5 100644 --- a/Sources/mas/Models/InstalledApp.swift +++ b/Sources/mas/Models/InstalledApp.swift @@ -5,6 +5,13 @@ // Copyright © 2018 mas-cli. All rights reserved. // +private import Atomics +private import Foundation +private import JSON +private import JSONAST +private import ObjectiveC +private import OrderedCollections + struct InstalledApp { let adamID: ADAMID let bundleID: String @@ -12,10 +19,38 @@ struct InstalledApp { let path: String let version: String + private let json: String + var isTestFlight: Bool { adamID == 0 } + fileprivate init(for item: NSMetadataItem) { + let valueByAttribute = item.values(forAttributes: item.attributes + [NSMetadataItemPathKey]) ?? [:] + adamID = valueByAttribute["kMDItemAppStoreAdamID"] as? ADAMID ?? 0 + bundleID = String(describing: valueByAttribute[NSMetadataItemCFBundleIdentifierKey] ?? "") + name = String(describing: valueByAttribute["_kMDItemDisplayNameWithExtensions"] ?? "").removingSuffix(".app") + path = valueByAttribute[NSMetadataItemPathKey].map { pathAny in + let path = String(describing: pathAny) + return (try? URL(filePath: path, directoryHint: .isDirectory).resourceValues(forKeys: [.canonicalPathKey]))? + .canonicalPath // swiftformat:disable:this indent + ?? path // swiftformat:disable:this indent + } + ?? "" // swiftformat:disable:this indent + version = String(describing: valueByAttribute[NSMetadataItemVersionKey] ?? "") + json = String( + describing: JSON.encode( + OrderedDictionary( + uniqueKeysWithValues: ( + valueByAttribute.map { ($0.keyMapped, AnyJSONEncodable(from: $1)) } + + [("name", AnyJSONEncodable(from: name))], // swiftformat:disable:this indent + ) + .sorted(using: KeyPathComparator(\.0, comparator: NumericStringComparator.forward)), + ), // swiftformat:disable:previous indent + ), + ) + } + func matches(_ appID: AppID) -> Bool { switch appID { case let .adamID(adamID): @@ -26,6 +61,12 @@ struct InstalledApp { } } +extension InstalledApp: CustomStringConvertible { + var description: String { + json + } +} + extension [InstalledApp] { func filter(for appIDs: [AppID]) -> [Element] { appIDs.isEmpty @@ -39,3 +80,237 @@ extension [InstalledApp] { } } } + +private extension String { + var keyMapped: Self { + switch self { + case NSMetadataItemCFBundleIdentifierKey: + "bundleID" + case "_kMDItemDisplayNameWithExtensions": + "displayNameWithExtensions" + case "_kMDItemEngagementData": + "engagementData" + case "_kMDItemRecentOutOfSpotlightEngagementDates": + "recentOutOfSpotlightEngagementDates" + case "kMDItemAlternateNames": + "alternateNames" + case "kMDItemAppStoreAdamID": + "adamID" + case "kMDItemAppStoreCategory": + "category" + case "kMDItemAppStoreCategoryType": + "categoryType" + case "kMDItemAppStoreHasMetadataPlist": + "hasMetadataPlist" + case "kMDItemAppStoreHasReceipt": + "hasReceipt" + case "kMDItemAppStoreInstallerVersionID": + "installerVersionID" + case "kMDItemAppStoreIsAppleSigned": + "isAppleSigned" + case "kMDItemAppStoreParentalControls": + "parentalControls" + case "kMDItemAppStorePurchaseDate": + "purchaseDate" + case "kMDItemAppStoreReceiptIsMachineLicensed": + "receiptIsMachineLicensed" + case "kMDItemAppStoreReceiptIsRevoked": + "receiptIsRevoked" + case "kMDItemAppStoreReceiptIsVPPLicensed": + "receiptIsVPPLicensed" + case "kMDItemAppStoreReceiptType": + "receiptType" + case NSMetadataItemContentCreationDateKey: + "contentCreationDate" + case "kMDItemContentCreationDate_Ranking": + "contentCreationDate_Ranking" + case NSMetadataItemContentModificationDateKey: + "contentModificationDate" + case NSMetadataItemContentTypeKey: + "contentType" + case NSMetadataItemContentTypeTreeKey: + "contentTypeTree" + case NSMetadataItemCopyrightKey: + "copyright" + case NSMetadataItemDateAddedKey: + "dateAdded" + case NSMetadataItemDescriptionKey: + "description" + case NSMetadataItemDisplayNameKey: + "displayName" + case "kMDItemDocumentIdentifier": + "documentIdentifier" + case NSMetadataItemExecutableArchitecturesKey: + "executableArchitectures" + case NSMetadataItemExecutablePlatformKey: + "executablePlatform" + case NSMetadataItemFSContentChangeDateKey: + "fileSystemContentChangeDate" + case NSMetadataItemFSCreationDateKey: + "fileSystemCreationDate" + case "kMDItemFSCreatorCode": + "fileSystemCreatorCode" + case "kMDItemFSFinderFlags": + "fileSystemFinderFlags" + case "kMDItemFSHasCustomIcon": + "fileSystemHasCustomIcon" + case "kMDItemFSInvisible": + "fileSystemInvisible" + case "kMDItemFSIsExtensionHidden": + "fileSystemIsExtensionHidden" + case "kMDItemFSIsStationery": + "fileSystemIsStationery" + case "kMDItemFSLabel": + "fileSystemLabel" + case NSMetadataItemFSNameKey: + "fileSystemName" + case "kMDItemFSNodeCount": + "fileSystemNodeCount" + case "kMDItemFSOwnerGroupID": + "fileSystemOwnerGroupID" + case "kMDItemFSOwnerUserID": + "fileSystemOwnerUserID" + case NSMetadataItemFSSizeKey: + "fileSystemSize" + case "kMDItemFSTypeCode": + "fileSystemTypeCode" + case "kMDItemInterestingDate_Ranking": + "interestingDate_Ranking" + case NSMetadataItemKeywordsKey: + "keywords" + case NSMetadataItemKindKey: + "kind" + case NSMetadataItemLastUsedDateKey: + "lastUsedDate" + case "kMDItemLastUsedDate_Ranking": + "lastUsedDate_Ranking" + case "kMDItemLogicalSize": + "logicalSize" + case "kMDItemPhysicalSize": + "physicalSize" + case "kMDItemUseCount": + "useCount" + case "kMDItemUsedDates": + "usedDates" + case NSMetadataItemVersionKey: + "version" + default: + replacing(unsafe keyRegex) { match in + let output = match.output + return output.1?.isEmpty == false ? "fileSystem" : output.2?.lowercased() ?? "" + } + } + } +} + +private extension URL { + var installedAppURLs: [URL] { + FileManager.default // swiftformat:disable indent + .enumerator(at: self, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) + .map { enumerator in + enumerator.compactMap { item in + guard + let url = item as? URL, + (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true, + url.pathExtension == "app" + else { + return nil as URL? + } + + enumerator.skipDescendants() + return try? url.appending(path: "Contents/_MASReceipt/receipt", directoryHint: .notDirectory) + .resourceValues(forKeys: [.fileSizeKey]) + .fileSize + .flatMap { $0 > 0 ? url : nil } + } + } + ?? [] + } // swiftformat:enable indent +} + +var installedApps: [InstalledApp] { + get async throws { + try await mas.installedApps(matching: "kMDItemAppStoreAdamID LIKE '*'") + } +} + +func installedApps(withADAMID adamID: ADAMID) async throws -> [InstalledApp] { + try await installedApps(matching: "kMDItemAppStoreAdamID = \(adamID)") +} + +@MainActor +func installedApps(matching metadataQuery: String) async throws -> [InstalledApp] { + var observer = (any NSObjectProtocol)?.none + defer { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + + let query = NSMetadataQuery() + query.predicate = NSPredicate(format: metadataQuery) + query.searchScopes = applicationsFolderURLs + + return try await withCheckedThrowingContinuation { continuation in + let alreadyResumed = ManagedAtomic(false) + observer = NotificationCenter.default.addObserver( + forName: .NSMetadataQueryDidFinishGathering, + object: query, + queue: nil, + ) { notification in + guard !alreadyResumed.exchange(true, ordering: .acquiringAndReleasing) else { + return + } + guard let query = notification.object as? NSMetadataQuery else { + continuation.resume( + throwing: MASError.error( + "Notification Center returned a \(type(of: notification.object)) instead of a NSMetadataQuery", + ), + ) + return + } + + query.stop() + + let installedApps = query.results + .compactMap { ($0 as? NSMetadataItem).map(InstalledApp.init(for:)) } // swiftformat:disable:this indent + .sorted(using: KeyPathComparator(\.name, comparator: .localizedStandard)) // swiftformat:disable:this indent + + if !["1", "true", "yes"].contains(ProcessInfo.processInfo.environment["MAS_NO_AUTO_INDEX"]?.lowercased()) { + let installedAppPathSet = Set(installedApps.map(\.path)) + for installedAppURL in applicationsFolderURLs.flatMap(\.installedAppURLs) + where !installedAppPathSet.contains(installedAppURL.filePath) { // swiftformat:disable:this indent + MAS.printer.warning( + "Found a likely App Store app that is not indexed in Spotlight in ", + installedAppURL.filePath, + """ + + + Indexing now, which will not complete until sometime after mas exits + + Disable auto-indexing via: export MAS_NO_AUTO_INDEX=1 + """, + separator: "", + ) + Task { + do { + _ = try await run( + "/usr/bin/mdimport", + installedAppURL.filePath, + errorMessage: "Failed to index the Spotlight data for \(installedAppURL.filePath)", + ) + } catch { + MAS.printer.error(error: error) + } + } + } + } + + continuation.resume(returning: installedApps) + } + + query.start() + } +} + +private nonisolated(unsafe) let keyRegex = /^_?kMDItem(?:(FS)|(?:AppStore)?(\p{Upper}(?=\p{Lower})|\p{Upper}+(?=$|\p{Upper}\p{Lower}))?)?/ diff --git a/Sources/mas/Utilities/JSON/AnyJSONEncodable.swift b/Sources/mas/Utilities/JSON/AnyJSONEncodable.swift new file mode 100644 index 00000000..5972ad91 --- /dev/null +++ b/Sources/mas/Utilities/JSON/AnyJSONEncodable.swift @@ -0,0 +1,43 @@ +// +// AnyJSONEncodable.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +private import Foundation +internal import JSONAST +internal import JSONEncoding + +struct AnyJSONEncodable: JSONEncodable { + private let encodeBase: (inout JSON) -> Void + + init(_ base: some JSONEncodable) { + encodeBase = { json in + base.encode(to: &json) + } + } + + init?(from value: Any?) { + guard let value else { + return nil + } + + self = switch value { + case let jsonEncodable as any JSONEncodable: + Self(jsonEncodable) + case let array as [Any?]: + Self(array.map(Self.init(from:))) + case let data as Data: + Self(data) + case let date as Date: + Self(date) + default: + Self(String(describing: value)) + } + } + + func encode(to json: inout JSON) { + encodeBase(&json) + } +} diff --git a/Sources/mas/Utilities/JSON/Data+JSONEncodable.swift b/Sources/mas/Utilities/JSON/Data+JSONEncodable.swift new file mode 100644 index 00000000..b07f8be5 --- /dev/null +++ b/Sources/mas/Utilities/JSON/Data+JSONEncodable.swift @@ -0,0 +1,30 @@ +// +// Data+JSONEncodable.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +public import Foundation +public import JSONAST +public import JSONEncoding + +extension Data: @retroactive JSONEncodable { + public func encode(to json: inout JSON) { + json += JSON.Literal( + isEmpty + ? "" // swiftformat:disable:this indent + : { + var hex = "0x" + hex.reserveCapacity(2 + count * 2) + return reduce(into: hex) { hex, byte in + let byteHex = String(byte, radix: 16) + if byteHex.count < 2 { + hex += "0" + } + hex += byteHex + } + }(), + ) + } +} diff --git a/Sources/mas/Utilities/JSON/Date+JSONEncodable.swift b/Sources/mas/Utilities/JSON/Date+JSONEncodable.swift new file mode 100644 index 00000000..c4beb206 --- /dev/null +++ b/Sources/mas/Utilities/JSON/Date+JSONEncodable.swift @@ -0,0 +1,16 @@ +// +// Date+JSONEncodable.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +public import Foundation +public import JSONAST +public import JSONEncoding + +extension Date: @retroactive JSONEncodable { + public func encode(to json: inout JSON) { + json += JSON.Literal(formatted(.iso8601)) + } +} diff --git a/Sources/mas/Utilities/JSON/JSON.Object.swift b/Sources/mas/Utilities/JSON/JSON.Object.swift new file mode 100644 index 00000000..acf188bb --- /dev/null +++ b/Sources/mas/Utilities/JSON/JSON.Object.swift @@ -0,0 +1,20 @@ +// +// JSON.Object.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +internal import JSONAST +internal import JSONDecoding + +extension JSON.Object { + // periphery:ignore + subscript(key: JSON.Key) -> JSON.OptionalDecoder { + JSON.OptionalDecoder(key: key, value: fields.first { $0.key == key }?.value) + } + + subscript(key: JSON.Key) -> JSON.FieldDecoder? { + (fields.first { $0.key == key }?.value).map { JSON.FieldDecoder(key: key, value: $0) } + } +} diff --git a/Sources/mas/Utilities/JSON/KeyValuePairs+JSONObjectEncodable.swift b/Sources/mas/Utilities/JSON/KeyValuePairs+JSONObjectEncodable.swift new file mode 100644 index 00000000..26800c9f --- /dev/null +++ b/Sources/mas/Utilities/JSON/KeyValuePairs+JSONObjectEncodable.swift @@ -0,0 +1,19 @@ +// +// KeyValuePairs+JSONObjectEncodable.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +public import JSONAST +public import JSONEncoding + +extension KeyValuePairs: @retroactive JSONEncodable where Key: CustomStringConvertible, Value: JSONEncodable {} + +extension KeyValuePairs: @retroactive JSONObjectEncodable where Key: CustomStringConvertible, Value: JSONEncodable { + public func encode(to json: inout JSON.ObjectEncoder) { + for (key, value) in self { + json[JSON.Key(rawValue: String(describing: key))] = value + } + } +} diff --git a/Sources/mas/Utilities/JSON/NSNumber+JSONEncodable.swift b/Sources/mas/Utilities/JSON/NSNumber+JSONEncodable.swift new file mode 100644 index 00000000..deee1652 --- /dev/null +++ b/Sources/mas/Utilities/JSON/NSNumber+JSONEncodable.swift @@ -0,0 +1,27 @@ +// +// NSNumber+JSONEncodable.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +private import CoreFoundation +public import Foundation +public import JSONAST +public import JSONEncoding +private import ObjectiveC + +extension NSNumber: @retroactive JSONEncodable { // swiftlint:disable:this legacy_objc_type + public func encode(to json: inout JSON) { + guard self !== kCFBooleanTrue else { + json.utf8 += "true".utf8 + return + } + guard self !== kCFBooleanFalse else { + json.utf8 += "false".utf8 + return + } + + json.utf8 += description.utf8 + } +} diff --git a/Sources/mas/Utilities/JSON/OrderedDictionary+JSONObjectEncodable.swift b/Sources/mas/Utilities/JSON/OrderedDictionary+JSONObjectEncodable.swift new file mode 100644 index 00000000..2384bf13 --- /dev/null +++ b/Sources/mas/Utilities/JSON/OrderedDictionary+JSONObjectEncodable.swift @@ -0,0 +1,20 @@ +// +// OrderedDictionary+JSONObjectEncodable.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +public import JSONAST +public import JSONEncoding +public import OrderedCollections + +extension OrderedDictionary: @retroactive JSONEncodable where Key: CustomStringConvertible, Value: JSONEncodable {} + +extension OrderedDictionary: @retroactive JSONObjectEncodable where Key: CustomStringConvertible, Value: JSONEncodable { + public func encode(to json: inout JSON.ObjectEncoder) { + for (key, value) in self { + json[JSON.Key(rawValue: String(describing: key))] = value + } + } +} diff --git a/Sources/mas/Utilities/NumericStringComparator.swift b/Sources/mas/Utilities/NumericStringComparator.swift new file mode 100644 index 00000000..fd56d59f --- /dev/null +++ b/Sources/mas/Utilities/NumericStringComparator.swift @@ -0,0 +1,35 @@ +// +// NumericStringComparator.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +internal import Foundation + +enum NumericStringComparator: SortComparator { + case forward + case reverse + + typealias Compared = String + + var order: SortOrder { + get { + self == .forward ? .forward : .reverse + } + set { + self = newValue == .forward ? .forward : .reverse + } + } + + func compare(_ lhs: String, _ rhs: String) -> ComparisonResult { + switch lhs.compare(rhs, options: .numeric) { + case .orderedAscending: + order == .forward ? .orderedAscending : .orderedDescending + case .orderedDescending: + order == .forward ? .orderedDescending : .orderedAscending + case .orderedSame: + .orderedSame + } + } +} diff --git a/Sources/mas/Utilities/Printer.swift b/Sources/mas/Utilities/Printer.swift index 06ef553f..dae3be7d 100644 --- a/Sources/mas/Utilities/Printer.swift +++ b/Sources/mas/Utilities/Printer.swift @@ -22,15 +22,25 @@ struct Printer { errorCounter.store(0, ordering: .releasing) // swiftlint:disable:previous unused_declaration } - /// Prints to `stdout`. + /// Prints to `fileHandle`. @_disfavoredOverload - func info(_ items: Any..., separator: String = " ", terminator: String = "\n") { - info(items, separator: separator, terminator: terminator) + func info( + _ items: Any..., + separator: String = " ", + terminator: String = "\n", + to fileHandle: FileHandle = .standardOutput, + ) { + info(items, separator: separator, terminator: terminator, to: fileHandle) } - /// Prints to `stdout`. - func info(_ items: [Any], separator: String = " ", terminator: String = "\n") { - print(items.map(String.init(describing:)), separator: separator, terminator: terminator, to: .standardOutput) + /// Prints to `fileHandle`. + func info( + _ items: [Any], + separator: String = " ", + terminator: String = "\n", + to fileHandle: FileHandle = .standardOutput, + ) { + print(items.map(String.init(describing:)), separator: separator, terminator: terminator, to: fileHandle) } /// Prints to `stdout`, prefixed with "==> "; if connected to a terminal, the diff --git a/Tests/MASTests/Commands/MASTests+Lookup.swift b/Tests/MASTests/Commands/MASTests+Lookup.swift index 9bac1699..a304c969 100644 --- a/Tests/MASTests/Commands/MASTests+Lookup.swift +++ b/Tests/MASTests/Commands/MASTests+Lookup.swift @@ -20,33 +20,73 @@ private extension MASTests { @Test func `outputs app info`() { let actual = consequencesOf( - try MAS.main(try MAS.Lookup.parse(["1"])) { command in - command.run( - catalogApps: [ - CatalogApp( - adamID: 1, - appStorePageURLString: "https://awesome.app", - fileSizeBytes: "1000000", - formattedPrice: "$2.00", - minimumOSVersion: "10.14", - name: "Awesome App", - releaseDate: "2019-01-07T18:53:13Z", - sellerName: "Awesome Dev", - version: "1.0", - ), - ], - ) + try MAS.main(try MAS.Lookup.parse(["--json", "1472954003"])) { command in + command.run(catalogApps: [try decode(CatalogApp.self, fromResource: "things-lookup")]) }, ) let expected = Consequences( nil, """ - Awesome App 1.0 [$2.00] - By: Awesome Dev - Released: 2019-01-07 - Minimum OS: 10.14 - Size: 1 MB - From: https://awesome.app + {\ + "adamID":1472954003,\ + "appStorePageURL":"https://apps.apple.com/us/app/things-that-go-bump/id1472954003?mt=12&uo=4",\ + "averageUserRating":0,\ + "averageUserRatingForCurrentVersion":0,\ + "bundleID":"uikitformac.com.tinybop.thingamabops",\ + "categories":[\ + "Games",\ + "Action",\ + "Family"\ + ],\ + "categoryIDs":[\ + "6014",\ + "7001",\ + "7009"\ + ],\ + "censoredName":"Things That Go Bump",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2020-03-18T17:39:23Z",\ + "description":"Have you ever heard something go bump in the night? \\nPerhaps you’ve caught wind of a spirit or sprite. \\nWhen the house is asleep,\\nand there’s dark all around, \\nspirits from objects awake and abound. \\n\\nThe spirits are crafty and like to cause trouble. \\nThey're called yōkai and together, they double. \\nMixing and mashing, they join to fight. \\nCan you help them conquer this mysterious night? \\n\\nPlay with one person, two, three or four. \\nFirst you’ll need to escape the dark junk drawer. \\n\\n. . . . . . . . . . . . . . . . . . . .\\nIn Things That go Bump, familiar objects and rooms come to life every night, and nothing looks quite as does in the day. Create your creature, and battle your friends, but beware the house spirits! They can destroy and they can give life. Battle, create, and make your way through the rooms of the house, and slowly you will unravel the secret of Things that Go Bump. \\n\\nFeatures:\\n * Spirits wake up objects and create yōkai (spirit creatures)\\n * Combine everyday objects like umbrellas, staplers, cheese graters and more to create everchanging characters \\n * Connect to other players via Game Center and face-off against other spirit creatures and house spirits\\n * Add or swap objects to give your spirit creature new skills\\n * Gain energy by making mischief, defeating other yōkai, and conquering the house spirits\\n * Advance through the house (new rooms will be added throughout the year)\\n * Test your curiosity and creativity with new challenges in every room\\n * Play with 1-4 players across iPads, iPhones, iPods, AppleTVs and Macs\\n * Fun and challenging for the whole family\\n * Intuitive, safe, hilarious kid-friendly design\\n * New levels introduced roughly every 2 months\\n * Original artwork by Adrian Fernandez\\n * Original sound design\\n\\nTinybop, Inc. is a Brooklyn-based studio of designers, engineers, and artists. We make toys for tomorrow. We’re all over the internet.\\n\\n Visit us: www.tinybop.com\\n Follow us: twitter.com/tinybop\\n Like us: facebook.com/tinybop\\n Peek behind the scenes: instagram.com/tinybop\\n\\nWe love hearing your stories! If you have ideas, or something isn’t working as you expect it to, please contact us: support@tinybop.com.\\n\\nPsst! It's not Tiny Bop, or Tiny Bob, or Tiny Pop. It's Tinybop. :)",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/tinybop-inc/id682046582?mt=12&uo=4",\ + "developerID":682046582,\ + "developerName":"Tinybop Inc.",\ + "fileSizeBytes":"12345678",\ + "formattedPrice":"$0.99",\ + "icon60URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/d4/b9/74/d4b974d7-0c4c-1515-49ec-ecedec84c5a0/source/60x60bb.png",\ + "icon100URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/d4/b9/74/d4b974d7-0c4c-1515-49ec-ecedec84c5a0/source/100x100bb.png",\ + "icon512URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/d4/b9/74/d4b974d7-0c4c-1515-49ec-ecedec84c5a0/source/512x512bb.png",\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"mac-software",\ + "languageCodesISO2A":[\ + "EN"\ + ],\ + "minimumOSVersion":"10.15.0",\ + "name":"Things That Go Bump",\ + "originalVersionReleaseDate":"2019-10-18T07:00:00Z",\ + "price":0.99,\ + "primaryCategoryID":6014,\ + "primaryCategoryName":"Games",\ + "releaseNotes":"* BOOM *, this is a BIG update. The house spawns a game room, complete with video games you can ENTER INTO. It's fun and a little bit weird! Try it! \\n»-(¯`·.·´¯)->",\ + "screenshotURLs":[\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple123/v4/36/fe/ff/36feffbc-a07b-e61e-f0e5-88dcc4455871/pr_source.png/800x500bb.jpg",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple113/v4/c6/85/09/c68509b2-c2c8-3000-bf85-4ead056b26f3/pr_source.png/800x500bb.jpg",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple113/v4/18/42/aa/1842aab5-0500-b08b-b9a5-fc364f83fbdb/pr_source.png/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple113/v4/de/b9/99/deb99962-f1d0-a7ad-0fc8-ef4bf906515b/pr_source.png/800x500bb.jpg",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple123/v4/41/70/7d/41707d88-8ba1-5a28-1f2f-0f2e43a73706/pr_source.png/800x500bb.jpg",\ + "https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/be/a3/a2/bea3a233-d82f-34bf-b0cd-38f262b04939/pr_source.png/800x500bb.jpg",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple113/v4/e5/41/b4/e541b49d-06ed-9ec6-1544-3df88c8dc340/pr_source.png/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple124/v4/8f/08/49/8f0849f4-7d20-567f-47e6-ef1bfb901619/pr_source.png/800x500bb.jpg",\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple123/v4/7d/74/8a/7d748af9-50fa-e009-39a8-b5eb7774b2be/pr_source.png/800x500bb.jpg"\ + ],\ + "sellerName":"Tinybop Inc.",\ + "sellerURL":"https://tinybop.com",\ + "userRatingCount":0,\ + "userRatingCountForCurrentVersion":0,\ + "version":"1.3.0",\ + "wrapperType":"software"\ + } """, ) diff --git a/Tests/MASTests/Commands/MASTests+Search.swift b/Tests/MASTests/Commands/MASTests+Search.swift index 1057b37e..e98db1eb 100644 --- a/Tests/MASTests/Commands/MASTests+Search.swift +++ b/Tests/MASTests/Commands/MASTests+Search.swift @@ -11,13 +11,884 @@ internal import Testing private extension MASTests { @Test - func `searches for slack`() { + func `searches for slack`() { // swiftlint:disable:this function_body_length let actual = consequencesOf( - try MAS.main(try MAS.Search.parse(["slack"])) { command in - try command.run(catalogApps: [CatalogApp(adamID: 1, name: "slack", version: "0.0")]) + try MAS.main(try MAS.Search.parse(["--json", "things"])) { command in + try command.run(catalogApps: try decode(CatalogAppResults.self, fromResource: "things").results) }, ) - let expected = Consequences(nil, "1 slack (0.0)\n") + let expected = Consequences( + nil, + """ + {\ + "adamID":904280696,\ + "appStorePageURL":"https://apps.apple.com/us/app/things-3/id904280696?mt=12&uo=4",\ + "averageUserRating":0,\ + "averageUserRatingForCurrentVersion":0,\ + "bundleID":"com.culturedcode.ThingsMac",\ + "categories":[\ + "Productivity",\ + "Business"\ + ],\ + "categoryIDs":[\ + "6007",\ + "6000"\ + ],\ + "censoredName":"Things 3",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2020-08-04T07:57:44Z",\ + "description":"Get things done! The award-winning Things app helps you plan your day, manage your projects, and make real progress toward your goals.\\n\\nBest of all, it’s easy to use. Within the hour, you’ll have everything off your mind and neatly organized—from routine tasks to your biggest life goals—and you can start focusing on what matters today.\\n\\n“Things offers the best combination of design and functionality of any app we tested, with nearly all the features of other power user applications and a delightful interface that never gets in the way of your work.”\\n—Wirecutter, The New York Times\\n\\n\\nKEY FEATURES\\n\\n• Your To-Dos\\nYour basic building block is the almighty To-Do—each a small step toward a great accomplishment. You can add notes, tag it, schedule it, and break it down into smaller steps.\\n\\n• Your Projects\\nCreate a Project for any big goal, then add the to-dos to reach it. Use headings to structure your list as you outline your plan. There’s also a place to jot down your notes, and a deadline to keep you on schedule.\\n\\n• Your Areas\\nCreate an Area for each sphere of your life, such as Work, Family, Finance, and so on. This keeps everything neatly organized, and helps you see the big picture as you set your plans in motion.\\n\\n• Your Plan\\nEverything on your schedule is neatly laid out in the Today and Upcoming lists, which show your to-dos and calendar events. Each morning, see what you planned for Today and decide what you want to do. The rest is down to you :)\\n\\n\\nMORE THINGS TO LOVE\\n\\nAs you dive deeper, you’ll find Things packed with helpful features. Here are just a few:\\n\\n• Reminders — set a time and Things will remind you.\\n• Repeaters — automatically repeat to-dos on a schedule you set.\\n• This Evening — a special place for your evening plans.\\n• Calendar integration — see your events and to-dos together.\\n• Tags — categorize your to-dos and quickly filter lists.\\n• Quick Entry — create to-dos from anywhere, as soon as the thought hits you.\\n• Quick Find — instantly locate to-dos, headings, or tags.\\n• Type Travel — jump from list to list with your keyboard; just start typing!\\n• Mail to Things — forward an email to Things; now it’s a to-do.\\n• And much more!\\n\\n\\nMADE FOR MAC\\n\\nThings is tailored to the Mac with deep system integrations as well. A great example is Quick Entry with Autofill: a shortcut that grabs content from other apps and adds it to Things for you, such as a link to a website or an email you want to get back to.\\n\\nYou can also enjoy a beautiful dark mode at sunset, connect your calendars, enable a Things widget, use your Mac’s Touch Bar, import from Reminders—Things can do it all! There’s even AppleScript support if you need powerful automation.\\n\\n\\nSTAY PRODUCTIVE ON THE GO\\n\\nThings has full-featured apps for iPhone, iPad, and Apple Watch as well (sold separately). All your devices sync seamlessly via our free Things Cloud service. It’s great to have everything at your fingertips when you need it!\\n\\n\\nAWARD-WINNING DESIGN\\n\\nMade in Stuttgart, with two Apple Design Awards to its name, Things is a fine example of German engineering: designed, not only to look fantastic, but to be perfectly functional as well. Every detail is thoughtfully considered, then polished to perfection.\\n\\n“It’s like the unicorn of productivity tools: deep enough for serious work, surprisingly easy to use, and gorgeous enough to enjoy staring at.”\\n—Apple\\n\\n\\nGET THINGS TODAY\\n\\nWhatever it is you want to accomplish in life, Things can help you get there. Install the app today and see what you can do!\\n\\nVisit our website now and get a free 15-day trial for your Mac: thingsapp.com\\n\\nIf you have any questions, please get in touch. We provide professional support and will be glad to help you!",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/cultured-code-gmbh-co-kg/id284971784?mt=12&uo=4",\ + "developerID":284971784,\ + "developerName":"Cultured Code GmbH & Co. KG",\ + "fileSizeBytes":"17474797",\ + "formattedPrice":"$49.99",\ + "icon60URL":"https://is5-ssl.mzstatic.com/image/thumb/Purple114/v4/69/3b/12/693b12e6-67d5-8252-7607-3438e420bbaa/source/60x60bb.png",\ + "icon100URL":"https://is5-ssl.mzstatic.com/image/thumb/Purple114/v4/69/3b/12/693b12e6-67d5-8252-7607-3438e420bbaa/source/100x100bb.png",\ + "icon512URL":"https://is5-ssl.mzstatic.com/image/thumb/Purple114/v4/69/3b/12/693b12e6-67d5-8252-7607-3438e420bbaa/source/512x512bb.png",\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"mac-software",\ + "languageCodesISO2A":[\ + "EN",\ + "FR",\ + "DE",\ + "IT",\ + "JA",\ + "RU",\ + "ZH",\ + "ES",\ + "ZH"\ + ],\ + "minimumOSVersion":"10.13.0",\ + "name":"Things 3",\ + "originalVersionReleaseDate":"2017-05-18T16:42:04Z",\ + "price":49.99,\ + "primaryCategoryID":6007,\ + "primaryCategoryName":"Productivity",\ + "releaseNotes":"• Moved the database file to a new location (now at /Library/Group Containers/).\\n• Increased the clickable area of items in the sidebar.\\n• Improved the formatting of years in Japanese.\\n• Fixed some crashes that could occur when hitting Cmd+[ or ] in Quick Entry while the When popover was visible.\\n• Updated the crash reporter.\\n• Some sync improvements.\\n\\n\\nNEW IN 3.12\\n\\nWe’re excited to release Things 3.12 – a big update for our Watch app!\\n\\nWe’ve entirely rebuilt its foundation to allow it to sync and operate without your phone being nearby. We’ve also taken this opportunity to add some often-requested features to the app. For more information about this release, please visit our blog: thingsapp.com\\n\\nThere are no huge changes in this release for Mac, but there’s one great new feature you should know about: you can now edit the Tags or Deadlines of collapsed to-dos – even for multiple to-dos at once – by hitting Cmd+Shift+T or D. It’s super convenient :)",\ + "screenshotURLs":[\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple123/v4/6f/4c/e5/6f4ce5d6-7caa-d1eb-bbc9-86558e97d2ba/pr_source.png/800x500bb.jpg",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple113/v4/92/b4/f8/92b4f8f5-f133-abd8-db17-135ac27bb1fa/pr_source.png/800x500bb.jpg",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple113/v4/72/63/63/726363b9-45ff-f93e-975c-fb69836eaf1a/pr_source.png/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple123/v4/29/fa/63/29fa63e3-3cb2-8b8a-8541-31fa9b7ef27f/pr_source.png/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple113/v4/da/17/5f/da175f95-c2cd-e5df-8cbc-d800d6770c64/pr_source.png/800x500bb.jpg",\ + "https://is1-ssl.mzstatic.com/image/thumb/Purple113/v4/f1/82/37/f182376c-4f25-6dbb-c6a8-5e6c1c617620/pr_source.png/800x500bb.jpg"\ + ],\ + "sellerName":"Cultured Code GmbH & Co. KG",\ + "sellerURL":"https://culturedcode.com/things/",\ + "userRatingCount":0,\ + "userRatingCountForCurrentVersion":0,\ + "version":"3.12.6",\ + "wrapperType":"software"\ + } + {\ + "adamID":966085870,\ + "appStorePageURL":"https://apps.apple.com/us/app/ticktick-things-tasks-to-do/id966085870?mt=12&uo=4",\ + "averageUserRating":0,\ + "averageUserRatingForCurrentVersion":0,\ + "bundleID":"com.TickTick.task.mac",\ + "categories":[\ + "Productivity"\ + ],\ + "categoryIDs":[\ + "6007"\ + ],\ + "censoredName":"TickTick: Things & Tasks To Do",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2020-08-27T01:27:34Z",\ + "description":"Design exclusively for macOS, TickTick is your daily must-have to-do & task list to get all things done.\\nTickTick can be accessed on more than 10 different platforms including Mac, iPhone, iPad, Apple Watch which enables you to manage tasks on all your devices/Web.\\n\\nKey features: \\n- Add task via shortcut (Command+Shift+A)\\n- Instant reminder\\n- Set priority levels to tasks\\n- Set flexible recurring tasks \\n- Create checklists within tasks \\n- Sort tasks by order/date/name/priority \\n- Sync all your tasks across all devices \\n\\nTickTick is free but you can also upgrade to Premium account for full access of premium features for $2.99 a month or $27.99 a year through an auto-renewing subscription.\\n\\nPremium Features: \\n- Grid view and Timeline view of calendar\\n- Duration\\n- Custom Smart List\\n- Description for checklist\\n- Reminders for sub-tasks\\n- More lists and tasks (299 lists, 999 tasks in each list, 199 subtasks in each task)\\n- Add at most 5 reminders to each task\\n- Share a task list up to 19 members for better task collaboration\\n- Upload up to 99 attachments every day\\n\\nSubscriptions for Premium account will be charged to your credit card through your iTunes account. Your subscription will automatically renew unless cancelled at least 24-hours before the end of the current period. You will not be able to cancel a subscription during the active period. You can manage your subscriptions in the Account Settings after purchase. \\n\\nHow TickTick makes you productive: \\n- Get all things done \\n- Never miss a schedule\\n- Make work more productive \\n- Keep life on track \\n\\nConnect with us: \\nFacebook: https://www.facebook.com/TickTickApp\\nTwitter: https://twitter.com/TickTickTeam @TickTickTeam\\nHelp Center: https://help.ticktick.com/\\n\\nPrivacy Policy: https://www.ticktick.com/about/privacy\\nTerms of Use: https://www.ticktick.com/about/tos",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/appest-limited/id434073155?mt=12&uo=4",\ + "developerID":434073155,\ + "developerName":"Appest Limited",\ + "fileSizeBytes":"24698702",\ + "formattedPrice":"Free",\ + "icon60URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/09/64/61/096461c1-f392-ec7d-13dd-2caa927d8244/source/60x60bb.png",\ + "icon100URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/09/64/61/096461c1-f392-ec7d-13dd-2caa927d8244/source/100x100bb.png",\ + "icon512URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/09/64/61/096461c1-f392-ec7d-13dd-2caa927d8244/source/512x512bb.png",\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"mac-software",\ + "languageCodesISO2A":[\ + "EN",\ + "ZH"\ + ],\ + "minimumOSVersion":"10.12",\ + "name":"TickTick: Things & Tasks To Do",\ + "originalVersionReleaseDate":"2016-03-04T06:37:31Z",\ + "price":0.00,\ + "primaryCategoryID":6007,\ + "primaryCategoryName":"Productivity",\ + "releaseNotes":"- Bug fixes and improvements.\\n\\nRecent Updates:\\n- Customizable Section in List View.\\n- Tag names can be capitalized.\\n- The number of Pomos can now be estimated beforehand.\\n- Lists under different folders can share the same name.\\n- New city themes! Los Angeles and Cairo.\\n\\nThanks for using TickTick! We'll bring regular updates to give you more pleasant experience with performance and stability.\\nWe'll read all reviews in App Store and evaluate your feedbacks carefully. Any issues encountered during the use, you may write to us via Avatar -> Feedback & Suggestions -> Submit feedback, we will get back to you asap.\\nTickTick team with love.",\ + "screenshotURLs":[\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple123/v4/c9/5d/af/c95daf17-c405-56f0-90f5-9411828e44d2/pr_source.jpg/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple113/v4/94/32/3b/94323b37-f81b-7ba8-a280-b951e7e840de/pr_source.jpg/800x500bb.jpg",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple123/v4/82/f1/35/82f1356d-1e68-8f9d-3967-566e256f9265/pr_source.jpg/800x500bb.jpg",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple123/v4/dc/ad/83/dcad839b-7705-e4c0-180a-2f97cb68054d/pr_source.jpg/800x500bb.jpg",\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple123/v4/21/50/6a/21506a09-c48c-d25e-dfa5-c0d6aa4cdd9d/pr_source.jpg/800x500bb.jpg"\ + ],\ + "sellerName":"Appest Limited",\ + "sellerURL":"https://ticktick.com",\ + "userRatingCount":0,\ + "userRatingCountForCurrentVersion":0,\ + "version":"3.7.11",\ + "wrapperType":"software"\ + } + {\ + "adamID":846599902,\ + "appStorePageURL":"https://apps.apple.com/us/app/simple-antnotes/id846599902?mt=12&uo=4",\ + "averageUserRating":0,\ + "averageUserRatingForCurrentVersion":0,\ + "bundleID":"ua.com.AntLogic.SimpleAntnotes",\ + "categories":[\ + "Productivity",\ + "Utilities"\ + ],\ + "categoryIDs":[\ + "6007",\ + "6002"\ + ],\ + "censoredName":"Simple Antnotes",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2016-09-24T17:06:52Z",\ + "description":"Antnotes are like paper notes: they are glued to your monitor, but from the other side of the screen.\\n\\nThis nice and handy application lives in the menu bar for faster access and has the following features:\\n\\n- customizable background, font and text color\\n- pin note to desktop to make it stay atop of other windows\\n- translucent notes\\n- make new notes by dragging text, images and files to the menu bar icon\\n- drag images and sounds to note contents\\n- automatically hide notes when inactive\\n- quick access via menu bar icon\\n- configurable global shortcuts to create new note or show/hide all notes\\n- integration with services: create new note from any text in any application\\n- snap to screen bounds and other notes\\n- archive with all closed notes - do not lose your information by accidentally closing a note\\n- smart position choosing for different display configurations\\n\\nWant more features? Let us know, or check out our Antnotes application!\\n\\nVisit our support forums: https://www.antlogic.com/forum/",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/antlogic/id364746702?mt=12&uo=4",\ + "developerID":364746702,\ + "developerName":"AntLogic",\ + "fileSizeBytes":"1002100",\ + "formattedPrice":"Free",\ + "icon60URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple71/v4/ff/4d/6b/ff4d6b03-2f12-e12d-9bb3-b3607bcd8ad8/source/60x60bb.png",\ + "icon100URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple71/v4/ff/4d/6b/ff4d6b03-2f12-e12d-9bb3-b3607bcd8ad8/source/100x100bb.png",\ + "icon512URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple71/v4/ff/4d/6b/ff4d6b03-2f12-e12d-9bb3-b3607bcd8ad8/source/512x512bb.png",\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"mac-software",\ + "languageCodesISO2A":[\ + "EN",\ + "DE",\ + "RU",\ + "UK"\ + ],\ + "minimumOSVersion":"10.6",\ + "name":"Simple Antnotes",\ + "originalVersionReleaseDate":"2014-03-28T12:49:14Z",\ + "price":0.00,\ + "primaryCategoryID":6007,\ + "primaryCategoryName":"Productivity",\ + "releaseNotes":"- added option to disable gradient background\\n- added option to create new notes in bottom left/right corners\\n- changed delay for close/options buttons showing\\n- some minor compatibility and UI fixes\\n- fixed German localisation",\ + "screenshotURLs":[\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple1/v4/79/5c/63/795c63aa-698c-1c6c-b6da-e7ebba718d01/pr_source.png/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple30/v4/15/4d/40/154d4071-4a6f-dcd7-0d15-2e495f6f4710/mzm.mvtkjcyn.png/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple2/v4/e0/31/dc/e031dc74-ce06-afe3-fd8e-8693f6c7c50c/pr_source.png/800x500bb.jpg",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple1/v4/fc/8d/23/fc8d2367-725d-11dd-6da9-816a7780a1d9/pr_source.png/800x500bb.jpg"\ + ],\ + "sellerName":"Mykola Olshevskyi",\ + "sellerURL":"https://www.antlogic.com/apps/antnotes",\ + "userRatingCount":0,\ + "userRatingCountForCurrentVersion":0,\ + "version":"1.6.1",\ + "wrapperType":"software"\ + } + {\ + "adamID":1128190780,\ + "advisories":[],\ + "appStorePageURL":"https://apps.apple.com/us/app/random-lists-decision-maker/id1128190780?uo=4",\ + "appleTVScreenshotURLs":[],\ + "averageUserRating":4.6104900000000004212097337585873901844024658203125,\ + "averageUserRatingForCurrentVersion":4.6104900000000004212097337585873901844024658203125,\ + "bundleID":"com.yahenskyi.random",\ + "categories":[\ + "Lifestyle",\ + "Family",\ + "Games",\ + "Board"\ + ],\ + "categoryIDs":[\ + "6012",\ + "7009",\ + "6014",\ + "7004"\ + ],\ + "censoredName":"Random: Lists & Decision Maker",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2020-08-29T19:21:51Z",\ + "description":"Need a random number? Or can’t you decide what to do? Random is a powerful app that will solve all such problems.\\n\\nFeatures:\\n• Number generator (from a range 0 - 999999999)\\n• Letter generator\\n• Dice roller (roll up to 4 regular dices in one go)\\n• A custom item from a list generator\\n• Yes or No \\n• Coin flipper\\n• Card generator\\n• Rock-Paper-Scissors\\n• Map Point\\n\\nGenerate a new random number simply by tapping a ​randomize button or by touching the Apple Watch screen. For those who want a bit of additional exercise, shaking your iOS device will also result in a new random response.\\n\\nUse Force Touch for setting the minimum or maximum values in your Apple Watch app. Same for the number of dices​, cards, and selection of lists.\\n\\nRandom Premium subscription benefits:\\n• Sync: Get access to your data from all your devices.\\n• Themes: Customize the app with various themes and background images.\\n• No advertising.\\n\\nIf you decide to get Random Premium subscription, your purchase will be charged to your iTunes account. 1 month costs $2.99 and 1 year costs $11.99. Active subscriptions will be auto-renewed 24 hours before the expiry date. You can manage subscriptions from Account in iTunes after subscribing, you’ll also be able to cancel the auto-renewing subscription from there at any time. Any unused portion of the free trial period will be forfeited if you purchase a subscription to Random Premium before your trial expires.\\n\\nTerms & Conditions: https://yahenskyi.dev/terms-conditions/\\nPrivacy Policy: https://yahenskyi.dev/privacy-policy/",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/volodymyr-yahenskyi/id961335645?uo=4",\ + "developerID":961335645,\ + "developerName":"Volodymyr Yahenskyi",\ + "features":[\ + "iosUniversal"\ + ],\ + "fileSizeBytes":"76392448",\ + "formattedPrice":"Free",\ + "iPadScreenshotURLs":[\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple123/v4/3f/23/5e/3f235e16-c049-8ee8-ebdc-3d52f25f2636/pr_source.png/552x414bb.png",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple123/v4/80/48/1d/80481dff-e404-721c-920e-4688f860cf27/pr_source.png/552x414bb.png",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple123/v4/58/a2/c9/58a2c970-1bd3-6f4d-1bdc-502f75faaa6a/pr_source.png/552x414bb.png",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple123/v4/2c/a6/06/2ca606eb-8b40-219a-34c5-626f79b7e593/pr_source.png/552x414bb.png",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple113/v4/c7/d7/04/c7d70441-51bd-1417-c7bf-a5d2702380e4/pr_source.png/552x414bb.png",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple123/v4/87/e0/75/87e075fd-a979-6151-5744-56ab76ac8f18/pr_source.png/552x414bb.png"\ + ],\ + "icon60URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/b3/ce/e9/b3cee939-9c28-6e05-f600-2e1b9419e0d2/source/60x60bb.jpg",\ + "icon100URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/b3/ce/e9/b3cee939-9c28-6e05-f600-2e1b9419e0d2/source/100x100bb.jpg",\ + "icon512URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/b3/ce/e9/b3cee939-9c28-6e05-f600-2e1b9419e0d2/source/512x512bb.jpg",\ + "isGameCenterEnabled":false,\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"software",\ + "languageCodesISO2A":[\ + "EN",\ + "RU",\ + "UK"\ + ],\ + "minimumOSVersion":"11.0",\ + "name":"Random: Lists & Decision Maker",\ + "originalVersionReleaseDate":"2016-07-05T22:00:04Z",\ + "price":0.00,\ + "primaryCategoryID":6012,\ + "primaryCategoryName":"Lifestyle",\ + "releaseNotes":"• Fixed crash when adding items to a new list\\n• Fixed lists sync on Apple Watch\\n\\nThanks for using the Random!\\nThis release also contains bug fixes and performance improvements.",\ + "screenshotURLs":[\ + "https://is1-ssl.mzstatic.com/image/thumb/Purple113/v4/2b/ce/8f/2bce8ffa-545b-050c-1dd9-2aeef532facd/pr_source.png/406x228bb.png",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple113/v4/fb/36/14/fb36142e-17ba-fdab-90c6-e8f9d3c080ef/pr_source.png/406x228bb.png",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple113/v4/4e/e2/de/4ee2de74-d0ef-010b-19f6-63755aa0175c/pr_source.png/406x228bb.png",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple113/v4/46/8e/bd/468ebdd3-73a9-ec6a-b4da-6931ce887cff/pr_source.png/406x228bb.png",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple113/v4/a4/9c/fa/a49cfa14-69e0-f1cf-3924-6ff878027b2d/pr_source.png/406x228bb.png",\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple123/v4/c1/94/cc/c194ccb6-c15a-c0a5-47a6-3ddd625fd98d/pr_source.png/406x228bb.png"\ + ],\ + "sellerName":"Volodymyr Yahenskyi",\ + "sellerURL":"https://yahenskyi.dev/random/",\ + "supportedDevices":[\ + "iPadMini4-iPadMini4",\ + "iPadProSecondGen-iPadProSecondGen",\ + "iPhone11-iPhone11",\ + "iPad71-iPad71",\ + "iPadMiniRetinaCellular-iPadMiniRetinaCellular",\ + "iPhone8Plus-iPhone8Plus",\ + "iPhone6sPlus-iPhone6sPlus",\ + "iPadMini5-iPadMini5",\ + "iPadProFourthGen-iPadProFourthGen",\ + "iPhoneXS-iPhoneXS",\ + "iPadAir3Cellular-iPadAir3Cellular",\ + "iPadAir3-iPadAir3",\ + "iPadMini4Cellular-iPadMini4Cellular",\ + "iPadProCellular-iPadProCellular",\ + "MacDesktop-MacDesktop",\ + "iPadMini3-iPadMini3",\ + "iPhoneXR-iPhoneXR",\ + "iPhoneSE-iPhoneSE",\ + "iPad611-iPad611",\ + "iPhone7-iPhone7",\ + "iPad73-iPad73",\ + "iPad812-iPad812",\ + "iPadAir2Cellular-iPadAir2Cellular",\ + "iPhoneX-iPhoneX",\ + "iPadMini5Cellular-iPadMini5Cellular",\ + "iPadPro97-iPadPro97",\ + "iPad834-iPad834",\ + "iPadProSecondGenCellular-iPadProSecondGenCellular",\ + "iPhone5s-iPhone5s",\ + "iPad75-iPad75",\ + "iPadMini3Cellular-iPadMini3Cellular",\ + "iPad878-iPad878",\ + "iPhone6-iPhone6",\ + "iPadAir-iPadAir",\ + "iPadPro97Cellular-iPadPro97Cellular",\ + "iPadSeventhGen-iPadSeventhGen",\ + "iPodTouchSixthGen-iPodTouchSixthGen",\ + "iPhoneXSMax-iPhoneXSMax",\ + "iPad612-iPad612",\ + "iPadPro-iPadPro",\ + "iPodTouchSeventhGen-iPodTouchSeventhGen",\ + "iPhone11ProMax-iPhone11ProMax",\ + "iPadMiniRetina-iPadMiniRetina",\ + "iPad76-iPad76",\ + "iPadProFourthGenCellular-iPadProFourthGenCellular",\ + "iPadSeventhGenCellular-iPadSeventhGenCellular",\ + "iPhoneSESecondGen-iPhoneSESecondGen",\ + "iPad74-iPad74",\ + "iPhone6s-iPhone6s",\ + "iPhone7Plus-iPhone7Plus",\ + "iPadAir2-iPadAir2",\ + "iPad72-iPad72",\ + "iPhone6Plus-iPhone6Plus",\ + "iPadAirCellular-iPadAirCellular",\ + "Watch4-Watch4",\ + "iPhone8-iPhone8",\ + "iPad856-iPad856",\ + "iPhone11Pro-iPhone11Pro"\ + ],\ + "userRatingCount":1525,\ + "userRatingCountForCurrentVersion":1525,\ + "version":"2.2.10",\ + "wrapperType":"software"\ + } + {\ + "adamID":1063681909,\ + "appStorePageURL":"https://apps.apple.com/us/app/task-planner-to-do-list/id1063681909?mt=12&uo=4",\ + "averageUserRating":0,\ + "averageUserRatingForCurrentVersion":0,\ + "bundleID":"com.newtechnologies.iPlanTasksinapp",\ + "categories":[\ + "Business",\ + "Productivity"\ + ],\ + "categoryIDs":[\ + "6000",\ + "6007"\ + ],\ + "censoredName":"Task Planner - To Do List",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2020-07-17T23:48:12Z",\ + "description":"Plan Your Tasks is a productivity tool that allows you to capture your ideas and duties in one place. \\nManage everything you have to do while working with many different tasks!\\n\\nEasy task management - create, organize, and prioritize tasks;\\n- Set notifications;\\n- Add comments;\\n- Sort tasks by categories;\\n- Track due dates.\\n\\nNew approach to agenda\\n- Build-in calendar;\\n- Coherent tutorial mode;\\n- Magic Trackpad 2 support.\\n\\nCapture all your flash ideas and duties in the calendar and manage your to dos while working with many tasks more effectively.\\n\\n\\nPrivacy Policy: https://anycasesolutions.com/privacy\\nTerms Of Use: https://anycasesolutions.com/tos",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/any-case-solutions/id1396419026?mt=12&uo=4",\ + "developerID":1396419026,\ + "developerName":"Any Case Solutions",\ + "fileSizeBytes":"27930644",\ + "formattedPrice":"Free",\ + "icon60URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/b0/7b/ed/b07bed5e-d977-6655-7a6a-d35a90901fba/source/60x60bb.png",\ + "icon100URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/b0/7b/ed/b07bed5e-d977-6655-7a6a-d35a90901fba/source/100x100bb.png",\ + "icon512URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/b0/7b/ed/b07bed5e-d977-6655-7a6a-d35a90901fba/source/512x512bb.png",\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"mac-software",\ + "languageCodesISO2A":[\ + "EN",\ + "FR",\ + "DE",\ + "IT",\ + "JA",\ + "KO",\ + "PT",\ + "RU",\ + "ZH",\ + "ES"\ + ],\ + "minimumOSVersion":"10.10",\ + "name":"Task Planner - To Do List",\ + "originalVersionReleaseDate":"2016-01-07T00:04:36Z",\ + "price":0.00,\ + "primaryCategoryID":6000,\ + "primaryCategoryName":"Business",\ + "releaseNotes":"We’ve updated the app! In the new version:\\n- less bugs;\\n- minor changes in the interface;\\n- some general improvements.\\nYour opinion is important to us! Please, leave your feedback - we will gladly consider all your wishes and suggestions.",\ + "screenshotURLs":[\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple71/v4/0e/74/bb/0e74bb9a-5ac2-5d5f-516a-5f1c12e95328/pr_source.jpg/800x500bb.jpg",\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple71/v4/36/54/f0/3654f064-4013-95e6-2683-c89ab8e51102/pr_source.jpg/800x500bb.jpg",\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple71/v4/1a/75/86/1a758637-9db5-007c-595b-b724e9083321/pr_source.jpg/800x500bb.jpg"\ + ],\ + "sellerName":"Any Case Solutions, OOO",\ + "userRatingCount":0,\ + "userRatingCountForCurrentVersion":0,\ + "version":"2.1.2",\ + "wrapperType":"software"\ + } + {\ + "adamID":504544917,\ + "appStorePageURL":"https://apps.apple.com/us/app/clear-tasks-reminders-to-do-lists/id504544917?mt=12&uo=4",\ + "averageUserRating":0,\ + "averageUserRatingForCurrentVersion":0,\ + "bundleID":"com.realmacsoftware.clear.mac",\ + "categories":[\ + "Productivity",\ + "Lifestyle"\ + ],\ + "categoryIDs":[\ + "6007",\ + "6012"\ + ],\ + "censoredName":"Clear – Tasks, Reminders & To-Do Lists",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2015-08-19T14:05:32Z",\ + "description":"Over 2.5 million people de-clutter their lives with Clear, so stop stalling and start organizing your daily routine.\\n\\nClear is the revolutionary to-do and reminders app that makes you more productive. Just start typing to add to-dos, and once you start organizing your life with Clear you’ll wonder how you ever managed without it.\\n\\n- Simple gestural design that allows you to focus on your to-dos. Designed for the Magic Trackpad, but works great with a mouse too!\\n- Full keyboard navigation. Just start typing to create to-dos.\\n- Use separate lists to organize every aspect of your life.\\n- iCloud sync built-in so you can be productive everywhere.\\n- Set reminders so you’ll never forget important tasks.\\n- Personalize your Clear lists with themes and make them your own.\\n- Syncs with Clear for iOS (available separately on the App Store).\\n\\nClear is built by a small team, dedicated to bringing you frequent free feature updates. We’d love to know how we can make you even more productive, so get in touch via the App Store “Support” link, or tweet us @UseClear.\\n\\nClear for Mac and Clear for iOS are not affiliated with or endorsed by CLEAR Wireless.",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/realmac-software/id310591643?mt=12&uo=4",\ + "developerID":310591643,\ + "developerName":"Realmac Software",\ + "fileSizeBytes":"13109875",\ + "formattedPrice":"$9.99",\ + "icon60URL":"https://is5-ssl.mzstatic.com/image/thumb/Purple69/v4/ac/6e/9a/ac6e9aea-8f4b-66bd-6046-c1735f27806f/source/60x60bb.png",\ + "icon100URL":"https://is5-ssl.mzstatic.com/image/thumb/Purple69/v4/ac/6e/9a/ac6e9aea-8f4b-66bd-6046-c1735f27806f/source/100x100bb.png",\ + "icon512URL":"https://is5-ssl.mzstatic.com/image/thumb/Purple69/v4/ac/6e/9a/ac6e9aea-8f4b-66bd-6046-c1735f27806f/source/512x512bb.png",\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"mac-software",\ + "languageCodesISO2A":[\ + "EN"\ + ],\ + "minimumOSVersion":"10.10",\ + "name":"Clear – Tasks, Reminders & To-Do Lists",\ + "originalVersionReleaseDate":"2012-11-08T08:00:00Z",\ + "price":9.99,\ + "primaryCategoryID":6007,\ + "primaryCategoryName":"Productivity",\ + "releaseNotes":"Thanks for using Clear! Just two small enhancements in today’s update:\\n\\n- We’ve tweaked (increased) the delay before “Click to Clear” appears.\\n- We’ve ensured compatibility with OS X El Capitan.\\n\\nStay productive, and follow @realmacsoftware on Twitter for the latest news!",\ + "screenshotURLs":[\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple3/v4/69/a0/58/69a0583d-02fd-1d37-cb33-19b80578e9e5/pr_source.jpg/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple3/v4/09/be/29/09be2981-4d08-a021-423a-29cc212c1b59/pr_source.jpg/800x500bb.jpg",\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple7/v4/28/5a/f4/285af4d8-37e6-118a-ff28-a4211eeb1122/pr_source.jpg/800x500bb.jpg",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple3/v4/50/4d/52/504d520c-4f8c-b011-d1e0-18addb5700a8/pr_source.jpg/800x500bb.jpg",\ + "https://is1-ssl.mzstatic.com/image/thumb/Purple3/v4/f2/6e/07/f26e0760-efb1-0353-3ebd-9fd2803f4b3d/pr_source.jpg/800x500bb.jpg"\ + ],\ + "sellerName":"Realmac Software Limited",\ + "sellerURL":"https://impending.com",\ + "userRatingCount":0,\ + "userRatingCountForCurrentVersion":0,\ + "version":"1.1.7",\ + "wrapperType":"software"\ + } + {\ + "adamID":1258530160,\ + "appStorePageURL":"https://apps.apple.com/us/app/focus-to-do-pomodoro-tasks/id1258530160?mt=12&uo=4",\ + "averageUserRating":0,\ + "averageUserRatingForCurrentVersion":0,\ + "bundleID":"com.macpomodoro",\ + "categories":[\ + "Productivity",\ + "Utilities"\ + ],\ + "categoryIDs":[\ + "6007",\ + "6002"\ + ],\ + "censoredName":"Focus To-Do: Pomodoro & Tasks",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2020-05-03T04:38:29Z",\ + "description":"Focus To-Do combines Pomodoro Timer with Task Management, it is a science-based app that will motivate you to stay focused and get things done. \\n\\nIt brings Pomodoro Technique and To Do List into one place, you can capture and organize tasks into your todo lists, start focus timer and focus on work & study, set reminders for important tasks and errands, check the time spent at work. \\n\\nIt's the ultimate app for managing Tasks, Reminders, Lists, Calendar events, Grocery lists, checklist, helping you focus on work & study and tracking your working hours.\\n\\nFocus To-Do syncs between your phone and computer, so you can access your lists from anywhere.\\n\\nHow it works:\\n 1. Pick a task you need to accomplish.\\n 2. Set a timer for 25 minutes, keep focused and start working.\\n 3. When the pomodoro timer rings, take a 5 minute break.\\n \\nKey Features:\\n\\n- Pomodoro Timer:Stay focused and get more things done.\\n Pause and resume Pomodoro\\n Customizable pomodoro/breaks lengths\\n Notification before the end of a Pomodoro\\n Support for short and long breaks\\n Skip a break after the end of a Pomodoro\\n Continuous Mode\\n \\n- Tasks Management: Task Organizer, Schedule Planner, Reminder, Habit Tracker, Time Tracker\\n Tasks and projects: Organise your day with Focus To-Do and complete your to do, study, work, homework or housework you need to get done.\\n Recurring tasks: Build lasting habits with powerful recurring due dates like \\"Every Monday\\".\\n Reminders: Setting a Reminder ensures you never forget important things ever again, you can set up recurring due dates to remind you each and every time. \\n Sub-tasks: Break down your task into smaller, actionable items or add a checklist .\\n Task Priority: Highlight your day’s most important To-Do with color-coded priority levels.\\n Estimated Pomodoro Number: Estimate the workload or set a goal.\\n Note: Record more detailed about the task.\\n\\n- Report: Detailed statistics of your time distribution, tasks completed.\\n Support the calculation of the total time of Focus Time.\\n Gantt Chart of the Focus Time.\\n Statistics on completed To Do. \\n Statistics on time distribution of project.\\n Trend chart of the completed To Do and the focus time.\\n\\n- All-Platform synchronization: View and manage your goals wherever you are for better goal achieving.\\n Support seamless synchronization within iPhone、Mac、iPad、Apple Watch and other platforms.\\n \\n- Various Reminding:\\n Focus Timer finished alarm, vibration reminding.\\n Various white noise to help you focus on work & study.\\n\\nContact Us: focustodo@163.com, reply within 24 hours.\\nWebsite: https://www.focustodo.cn\\nPomodoro ™ and Pomodoro Technique ® are registered trademarks of Francesco Cirillo. This app is not affiliated with Francesco Cirillo.\\n\\nUsers have been focused on our app for 200 million hours, join us and we help you to be focused and increase your productivity, reduce procrastination and anxiety.",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/shenzhen-tomato-software-technology-co-ltd/id966057212?mt=12&uo=4",\ + "developerID":966057212,\ + "developerName":"Shenzhen Tomato Software Technology Co., Ltd.",\ + "fileSizeBytes":"12135791",\ + "formattedPrice":"Free",\ + "icon60URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple113/v4/6b/f0/58/6bf058c1-90ab-5bdf-7c06-18de305efd6d/source/60x60bb.png",\ + "icon100URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple113/v4/6b/f0/58/6bf058c1-90ab-5bdf-7c06-18de305efd6d/source/100x100bb.png",\ + "icon512URL":"https://is1-ssl.mzstatic.com/image/thumb/Purple113/v4/6b/f0/58/6bf058c1-90ab-5bdf-7c06-18de305efd6d/source/512x512bb.png",\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"mac-software",\ + "languageCodesISO2A":[\ + "CS",\ + "EN",\ + "FR",\ + "DE",\ + "ID",\ + "IT",\ + "JA",\ + "KO",\ + "PL",\ + "PT",\ + "RO",\ + "RU",\ + "ZH",\ + "ES",\ + "ZH",\ + "TR",\ + "VI"\ + ],\ + "minimumOSVersion":"10.12",\ + "name":"Focus To-Do: Pomodoro & Tasks",\ + "originalVersionReleaseDate":"2017-08-02T03:45:26Z",\ + "price":0.00,\ + "primaryCategoryID":6007,\ + "primaryCategoryName":"Productivity",\ + "releaseNotes":"1.Support new languages\\n2.Bug fix",\ + "screenshotURLs":[\ + "https://is1-ssl.mzstatic.com/image/thumb/Purple113/v4/cd/bd/44/cdbd44af-06eb-21d6-a793-43dae1077c47/pr_source.jpg/800x500bb.jpg",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple113/v4/e9/8f/17/e98f17c6-787b-b180-6f8d-fb8385ceedd3/pr_source.jpg/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple123/v4/43/12/a2/4312a25b-f773-9c1a-ddd4-2515d948cc27/pr_source.jpg/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple123/v4/97/0d/b4/970db444-bbd9-ed77-439e-001bce006e17/pr_source.jpg/800x500bb.jpg"\ + ],\ + "sellerName":"Shenzhen Tomato Software Technology Co., Ltd.",\ + "userRatingCount":0,\ + "userRatingCountForCurrentVersion":0,\ + "version":"6.3",\ + "wrapperType":"software"\ + } + {\ + "adamID":1289070327,\ + "advisories":[],\ + "appStorePageURL":"https://apps.apple.com/us/app/planny-3-smart-to-do-list/id1289070327?uo=4",\ + "appleTVScreenshotURLs":[],\ + "averageUserRating":4.3897300000000001318767317570745944976806640625,\ + "averageUserRatingForCurrentVersion":4.3897300000000001318767317570745944976806640625,\ + "bundleID":"com.kevinreutter.Callisto",\ + "categories":[\ + "Productivity",\ + "Utilities"\ + ],\ + "categoryIDs":[\ + "6007",\ + "6002"\ + ],\ + "censoredName":"Planny 3 - Smart To Do List",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2020-07-30T17:37:31Z",\ + "description":"++ Planny was part of Apples favorite Apps from October ++\\n\\nPlanny is all new and has been rethought from the ground up.\\n\\nPlanny is your new friend helping you to be more productive. Planny learned everything important from common to do list apps but combines them with intelligence and gamification. In the morning and during the day Planny intelligently recommends tasks and also reminds you if you tend to forget them. You earn productivity points for adding and completing tasks, and also lose them if you shift tasks or forget them. Users can compare their productivity with friends over the week. \\n\\nPlanny also features all the important features like deadlines, lists / projects, tagging, location based reminders, notes and attachments, routines and more. \\n\\nKey features\\n• Daily list to focus on today's tasks\\n• Assistant for creating a productive daily plan\\n• Daily review of the last day\\n• Routines to train your habits\\n• Deadlines and reminders\\n• Smart reminders if you tend to forget your tasks\\n• Notes for your tasks\\n• Weekly productivity ranking of your contacts\\n• Rewards\\n• Dark mode\\n• Lists\\n• Siri support\\n• Advanced Apple Watch app\\n\\nPlanny Premium offers additional features like:\\n• Calendar view\\n• Teamwork with your friends\\n• Add Photos from your library to tasks\\n• Add Photos from your camera to tasks\\n• Location based reminders\\n• iCloud sync\\n• iCloud backup \\n• FaceID Unlock\\n• More than 2 lists\\n• Printing\\n• Sketches\\n• Review your recent days\\n• Tagging\\n\\n+++ Planny Premium - Unlock all features and use Planny on iPhone, iPad and Apple Watch (Mac soon) - And get free feature updates over time! +++ \\n\\nA Planny Premium subscription unlocks all features. Note that iCloud features require an iCloud-Account. \\n\\nPlanny offers two auto-renewing subscriptions\\n\\nPremium 3 Months\\n$6,99 / 3 Months (may differ in your country & currency)\\n\\nPremium Annual\\n$19,99 / Year (may differ in your country & currency)\\n\\nPayment will be charged to iTunes Account at confirmation of purchase\\nSubscription automatically renews unless auto-renew is turned off at least 24-hours before the end of the current period\\nAccount will be charged for renewal within 24-hours prior to the end of the current period, and identify the cost of the renewal\\n\\nSubscriptions may be managed by the user and auto-renewal may be turned off by going to the user's Account Settings after purchase\\n\\nWhen your subscription is cancelled and expires, all the features of Planny Pro won't be available any longer. Any unused portion of a free trial period, if offered, will be forfeited when the user purchases a subscription to that publication, where applicable.\\n\\nPrivacy policy for Planny: https://kevinreutter.de/privacy\\nTerms of use / Conditions: https://kevinreutter.de/privacy",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/kevin-reutter/id1273424431?uo=4",\ + "developerID":1273424431,\ + "developerName":"Kevin Reutter",\ + "features":[\ + "gameCenter",\ + "iosUniversal"\ + ],\ + "fileSizeBytes":"47687680",\ + "formattedPrice":"Free",\ + "iPadScreenshotURLs":[\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple114/v4/43/19/bb/4319bb4b-5700-0f6b-2c19-7bd386bf186c/pr_source.jpg/552x414bb.jpg",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple114/v4/5d/51/1a/5d511a30-7fab-fd18-6967-c0caf9674d55/pr_source.jpg/552x414bb.jpg"\ + ],\ + "icon60URL":"https://is4-ssl.mzstatic.com/image/thumb/Purple124/v4/42/50/53/425053d8-2b26-c28a-72db-40323cc62aeb/source/60x60bb.jpg",\ + "icon100URL":"https://is4-ssl.mzstatic.com/image/thumb/Purple124/v4/42/50/53/425053d8-2b26-c28a-72db-40323cc62aeb/source/100x100bb.jpg",\ + "icon512URL":"https://is4-ssl.mzstatic.com/image/thumb/Purple124/v4/42/50/53/425053d8-2b26-c28a-72db-40323cc62aeb/source/512x512bb.jpg",\ + "isGameCenterEnabled":true,\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"software",\ + "languageCodesISO2A":[\ + "EN",\ + "FR",\ + "DE",\ + "IT",\ + "RU",\ + "ZH",\ + "ES",\ + "TR"\ + ],\ + "minimumOSVersion":"13.0",\ + "name":"Planny 3 - Smart To Do List",\ + "originalVersionReleaseDate":"2017-10-13T19:16:40Z",\ + "price":0.00,\ + "primaryCategoryID":6007,\ + "primaryCategoryName":"Productivity",\ + "releaseNotes":"Stay tuned! Planny 4 ships in a few week and will be a free update with many great features!\\n\\n• SwiftUI \\nNow Planny uses SwiftUI in some parts of the app. SwiftUI is an innovative, exceptionally simple way to build user interfaces across all Apple platforms with the power of Swift. Over time more and more of the app will be created with SwiftUI to avoid crashes and improve performance. \\n\\n• Advanced Cursor Support\\nWhen using a Trackpad on iPadOS or on the Mac, specific Elements become larger when you come closer to make clicking easier\\n\\n• Alternative App icons\\nChoose the icon color you’d like in settings (iOS for iPhone only)\\n\\n• New Onboarding Experience\\nA new tutorial shows the key features \\n\\n• New Purchase View\\nThe purchase view is now much simpler. Feel free to subscribe :) \\n\\n• Fixed deadlines on macOS\\n• Direct Deadlines now support days and time \\n• Fixed issues with overdue tasks \\n\\nDo you have any wishes for Planny 4? Feel free to submit ideas on the website!",\ + "screenshotURLs":[\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple114/v4/95/33/5f/95335f94-26d3-3567-93ac-77d60ab821dd/pr_source.png/392x696bb.png",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple114/v4/03/53/b9/0353b9b1-ef2a-7ff1-a1b8-4124867af41b/pr_source.png/392x696bb.png",\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/ef/63/ce/ef63ce41-371a-2508-b101-fb99e9c7758f/pr_source.png/392x696bb.png"\ + ],\ + "sellerName":"Kevin Reutter",\ + "sellerURL":"https://www.kevinreutter.de/planny-3/",\ + "supportedDevices":[\ + "iPadMini4-iPadMini4",\ + "iPadProSecondGen-iPadProSecondGen",\ + "iPhone11-iPhone11",\ + "iPad71-iPad71",\ + "iPadMiniRetinaCellular-iPadMiniRetinaCellular",\ + "iPhone8Plus-iPhone8Plus",\ + "iPhone6sPlus-iPhone6sPlus",\ + "iPadMini5-iPadMini5",\ + "iPadProFourthGen-iPadProFourthGen",\ + "iPhoneXS-iPhoneXS",\ + "iPadAir3Cellular-iPadAir3Cellular",\ + "iPadAir3-iPadAir3",\ + "iPadMini4Cellular-iPadMini4Cellular",\ + "iPadProCellular-iPadProCellular",\ + "MacDesktop-MacDesktop",\ + "iPadMini3-iPadMini3",\ + "iPhoneXR-iPhoneXR",\ + "iPhoneSE-iPhoneSE",\ + "iPad611-iPad611",\ + "iPhone7-iPhone7",\ + "iPad73-iPad73",\ + "iPad812-iPad812",\ + "iPadAir2Cellular-iPadAir2Cellular",\ + "iPhoneX-iPhoneX",\ + "iPadMini5Cellular-iPadMini5Cellular",\ + "iPadPro97-iPadPro97",\ + "iPad834-iPad834",\ + "iPadProSecondGenCellular-iPadProSecondGenCellular",\ + "iPhone5s-iPhone5s",\ + "iPad75-iPad75",\ + "iPadMini3Cellular-iPadMini3Cellular",\ + "iPad878-iPad878",\ + "iPhone6-iPhone6",\ + "iPadAir-iPadAir",\ + "iPadPro97Cellular-iPadPro97Cellular",\ + "iPadSeventhGen-iPadSeventhGen",\ + "iPodTouchSixthGen-iPodTouchSixthGen",\ + "iPhoneXSMax-iPhoneXSMax",\ + "iPad612-iPad612",\ + "iPadPro-iPadPro",\ + "iPodTouchSeventhGen-iPodTouchSeventhGen",\ + "iPhone11ProMax-iPhone11ProMax",\ + "iPadMiniRetina-iPadMiniRetina",\ + "iPad76-iPad76",\ + "iPadProFourthGenCellular-iPadProFourthGenCellular",\ + "iPadSeventhGenCellular-iPadSeventhGenCellular",\ + "iPhoneSESecondGen-iPhoneSESecondGen",\ + "iPad74-iPad74",\ + "iPhone6s-iPhone6s",\ + "iPhone7Plus-iPhone7Plus",\ + "iPadAir2-iPadAir2",\ + "iPad72-iPad72",\ + "iPhone6Plus-iPhone6Plus",\ + "iPadAirCellular-iPadAirCellular",\ + "Watch4-Watch4",\ + "iPhone8-iPhone8",\ + "iPad856-iPad856",\ + "iPhone11Pro-iPhone11Pro"\ + ],\ + "userRatingCount":331,\ + "userRatingCountForCurrentVersion":331,\ + "version":"3.4.2",\ + "wrapperType":"software"\ + } + {\ + "adamID":416993121,\ + "appStorePageURL":"https://apps.apple.com/us/app/to-do-lists/id416993121?mt=12&uo=4",\ + "averageUserRating":0,\ + "averageUserRatingForCurrentVersion":0,\ + "bundleID":"ua.com.AntLogic.ToDoLists",\ + "categories":[\ + "Productivity",\ + "Business"\ + ],\ + "categoryIDs":[\ + "6007",\ + "6000"\ + ],\ + "censoredName":"To-do Lists",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2015-04-16T19:07:37Z",\ + "description":"To-do Lists provides simple but powerful interface for tasks management.\\n\\nTo-do Lists features:\\n- Quick, one-click tasks addition/removal.\\n- Rich-text editing, in-text links support.\\n- Seamless iCloud Reminders synchronization.\\n- DropBox synchronization between computers and To-do Lists Mobile for iOS\\n- Import/export of to-do lists via text files.\\n- Printing of to-do lists or mailing them directly from the application.\\n- Backup and restore of whole to-do database.\\n- Full drag'n'drop support (make new to-do from web link, file, document, e-mail, or any other text by simply dropping them on to-do list).\\n- System services support (make new to-do from any text in any application).\\n- Rolled-up, translucent or floating to-do lists.\\n- Customized background color, text color, font and checkbox appearance.\\n- Reminders.\\n- Quick-access icon in system menu.\\n\\nTo-do Lists usage video:\\nhttps://www.youtube.com/watch?v=5KB-4sYcelo (or https://www.youtube.com/AntlogicCompany )\\n\\nFor more information, visit our site at https://www.antlogic.com\\nor Facebook page:\\nhttps://www.facebook.com/AntlogicCompany\\n\\nIf you have any problems or questions using To-do Lists - visit our support forums at https://www.antlogic.com/#contact-us",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/antlogic/id364746702?mt=12&uo=4",\ + "developerID":364746702,\ + "developerName":"AntLogic",\ + "fileSizeBytes":"2095731",\ + "formattedPrice":"$4.99",\ + "icon60URL":"https://is3-ssl.mzstatic.com/image/thumb/Purple3/v4/6b/67/f2/6b67f2d4-2603-ec03-504c-fd408d3577d7/source/60x60bb.png",\ + "icon100URL":"https://is3-ssl.mzstatic.com/image/thumb/Purple3/v4/6b/67/f2/6b67f2d4-2603-ec03-504c-fd408d3577d7/source/100x100bb.png",\ + "icon512URL":"https://is3-ssl.mzstatic.com/image/thumb/Purple3/v4/6b/67/f2/6b67f2d4-2603-ec03-504c-fd408d3577d7/source/512x512bb.png",\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"mac-software",\ + "languageCodesISO2A":[\ + "EN",\ + "FR",\ + "DE",\ + "RU",\ + "UK"\ + ],\ + "minimumOSVersion":"10.6.6",\ + "name":"To-do Lists",\ + "originalVersionReleaseDate":"2011-03-01T03:09:22Z",\ + "price":4.99,\ + "primaryCategoryID":6007,\ + "primaryCategoryName":"Productivity",\ + "releaseNotes":"fixed accidentally broken compatibility for Mac OS 10.6-10.7",\ + "screenshotURLs":[\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple/v4/4f/ff/f9/4ffff968-2932-48af-431f-fd1b086026cf/mzl.srudbvwp.png/800x500bb.jpg",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple4/v4/2a/4f/9f/2a4f9fad-c1a7-fa80-56d7-fea2d3beaa0a/mzl.dcyubghz.png/800x500bb.jpg",\ + "https://is1-ssl.mzstatic.com/image/thumb/Purple6/v4/56/3a/a7/563aa771-8288-e21a-cfe8-e28e77ffad83/mzl.lzjpfyct.png/800x500bb.jpg",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple4/v4/1e/8d/4e/1e8d4eaa-17f7-5295-1f36-975b62164d19/mzl.yufjavxy.png/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple4/v4/fb/9b/85/fb9b851d-6ef5-792f-0945-ff2f1a78ce7a/mzl.gycjiioz.png/800x500bb.jpg"\ + ],\ + "sellerName":"Mykola Olshevskyi",\ + "sellerURL":"https://www.antlogic.com/#to-do-lists",\ + "userRatingCount":0,\ + "userRatingCountForCurrentVersion":0,\ + "version":"1.7.7",\ + "wrapperType":"software"\ + } + {\ + "adamID":1346203938,\ + "appStorePageURL":"https://apps.apple.com/us/app/omnifocus-3/id1346203938?mt=12&uo=4",\ + "averageUserRating":0,\ + "averageUserRatingForCurrentVersion":0,\ + "bundleID":"com.omnigroup.OmniFocus3.MacAppStore",\ + "categories":[\ + "Productivity",\ + "Business"\ + ],\ + "categoryIDs":[\ + "6007",\ + "6000"\ + ],\ + "censoredName":"OmniFocus 3",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2020-08-27T17:54:49Z",\ + "description":"Two-week free trial! OmniFocus Standard and Pro are in-app purchases, with discounts for people who bought earlier versions of OmniFocus for Mac through the Mac App Store. Or you can get OmniFocus for iOS, Mac, and web for just one price with the OmniFocus Subscription. Download the app for details.\\n\\nUse OmniFocus to accomplish more every day. Create projects and tasks, organize them with tags, focus on what you can do right now — and get stuff done.\\n\\nOmniFocus — now celebrating 10 years as the trusted, gold-standard to-do list app — brings unrivaled power and flexibility to your Mac, making it easy to work the way you want to work.\\n\\nOmniFocus manages everything in your busy life. Use projects to organize tasks naturally, and then add tags to organize across projects. Easily enter tasks when you’re on the go, and process them when you have time. Tap the Forecast view — which shows both tasks and calendar events — to get a handle on your day. Use the Review perspective to keep your projects and tasks on track.\\n\\nThen let our free syncing system make sure your data is the same on every Mac. (And on OmniFocus for iOS and Web, available separately.) Because your data is encrypted, it’s safe in the cloud.\\n\\nSTANDARD FEATURES (VIA IN-APP PURCHASE)\\n\\n• NEW: Tags add a powerful additional organizing tool. Create tags for people, energy levels, priorities, locations, and more.\\n• NEW: The Forecast view shows your tasks and calendar events in order, so you can better see what’s coming up in your day.\\n• NEW: Enhanced repeating tasks are easier than ever to set up — and they work with real-world examples such as the first weekday of the month.\\n• NEW: The Modern, fresh-but-familiar design helps you focus on your content.\\n• Inbox is where you quickly add tasks — save them when you think of them, and organize them later.\\n• Syncing supports end-to-end encryption so that your data is safe wherever it’s stored, on our server or yours.\\n• Notes can be attached to your tasks, so you have all the information you need.\\n• Attachments — graphics, video, audio, whatever you want — add richness to your tasks.\\n• View Options let you customize each perspective by deciding what it should show and how it should filter your tasks.\\n• The Review perspective takes you through your projects and tasks — so you stay on track.\\n• OmniFocus Mail Drop adds tasks via email and works with services like IFTTT and Zapier (if you’re using our free syncing server).\\n• The Today Widget shows you your most important items — you don’t even have to switch to the app to know what’s up.\\n• Support for TaskPaper Text and omnifocus:///add and /paste lets you automate using URLs.\\n\\nPro features make OmniFocus even more powerful:\\n\\nPRO FEATURES (VIA IN-APP PURCHASE)\\n\\n• Custom perspectives help you create new ways to see your data by filtering and grouping projects and tags. NEW: The filtering rules are simpler to use while being more powerful than ever, letting you combine rules with “all,” “any,” and “none.” You can also choose any image to use as your custom perspective’s icon, and a custom tint color to go with it.\\n• NEW: Today’s Forecast can include items with a specific tag, and you can reorder those tasks however you choose, so you can plan your day better.\\n• The customizable sidebar lets you organize your perspectives the way you want to, for super-fast access.\\n• The Today Widget shows a perspective of your choice in Notification Center.\\n• AppleScript support opens up a world of automation, using Apple’s Mac scripting language.\\n\\nDownload OmniFocus right now and start your free trial! The app includes a manual, and there’s plenty more documentation on the website.\\n\\nSUPPORT\\n\\nIf you have feedback or questions, our Support Humans would love to hear from you! Send email to omnifocus@omnigroup.com, call us at at 1-800-315-6664 or +1-206-523-4152, or reach us on Twitter at @omnifocus.\\n\\n\\nSubscription Terms of Service: https://www.omnigroup.com/legal",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/the-omni-group/id281731738?mt=12&uo=4",\ + "developerID":281731738,\ + "developerName":"The Omni Group",\ + "fileSizeBytes":"64931473",\ + "formattedPrice":"Free",\ + "icon60URL":"https://is3-ssl.mzstatic.com/image/thumb/Purple124/v4/71/6f/f0/716ff030-f8ec-536c-41ca-f5116ae1f497/source/60x60bb.png",\ + "icon100URL":"https://is3-ssl.mzstatic.com/image/thumb/Purple124/v4/71/6f/f0/716ff030-f8ec-536c-41ca-f5116ae1f497/source/100x100bb.png",\ + "icon512URL":"https://is3-ssl.mzstatic.com/image/thumb/Purple124/v4/71/6f/f0/716ff030-f8ec-536c-41ca-f5116ae1f497/source/512x512bb.png",\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"mac-software",\ + "languageCodesISO2A":[\ + "NL",\ + "EN",\ + "FR",\ + "DE",\ + "IT",\ + "JA",\ + "KO",\ + "PT",\ + "RU",\ + "ZH",\ + "ES"\ + ],\ + "minimumOSVersion":"10.14",\ + "name":"OmniFocus 3",\ + "originalVersionReleaseDate":"2018-09-24T12:28:36Z",\ + "price":0.00,\ + "primaryCategoryID":6007,\ + "primaryCategoryName":"Productivity",\ + "releaseNotes":"OmniFocus 3.9.2 is a minor update focused on bug fixes.\\n\\n• Omni Automation — OmniFocus now recognizes simple plug-ins that use the .omnifocusjs file extention.\\n• First Run — Improved reliability of the first run flow.\\n• Notice Bar — Fixed bugs related to the Trial Mode & Free Viewer notice bars.\\n\\nIf you have any feedback or questions, we’d love to hear from you! The Omni Group offers free tech support; you can email omnifocus@omnigroup.com, call 1–800–315–6664 or 1–206–523–4152, or tweet @OmniFocus.\\n\\nIf OmniFocus empowers you, we would appreciate an App Store review. Your review will help other people find OmniFocus and make them more productive too.",\ + "screenshotURLs":[\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple113/v4/a8/e9/42/a8e942ec-8eea-03b3-ea37-cc6e2837fb5e/pr_source.png/800x500bb.jpg",\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple113/v4/5f/62/5c/5f625c38-c559-3b8d-5042-94e241735ef1/pr_source.png/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple113/v4/aa/a1/e7/aaa1e746-e660-2b6e-6833-d751e7879752/pr_source.png/800x500bb.jpg",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple113/v4/c0/6f/7d/c06f7de4-17b9-7475-8778-22a97c13cdce/pr_source.png/800x500bb.jpg",\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple113/v4/84/3d/8d/843d8de0-6257-7bf0-6a66-6f3ce41af803/pr_source.png/800x500bb.jpg",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple123/v4/ad/0d/28/ad0d28c6-ff1d-c394-266a-fdff0b8e9cc6/pr_source.png/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple123/v4/36/28/a3/3628a3e9-6073-0ce4-17d7-9d9a5f479c64/pr_source.png/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple113/v4/a9/ac/b9/a9acb983-76e2-d76d-fbc8-c35388dcee48/pr_source.png/800x500bb.jpg",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple113/v4/8f/41/90/8f4190f5-ebb8-370b-c8a6-afb47c56140d/pr_source.png/800x500bb.jpg",\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple123/v4/09/37/83/09378396-de11-2185-24f4-360be20dbcac/pr_source.png/800x500bb.jpg"\ + ],\ + "sellerName":"The Omni Group",\ + "sellerURL":"https://www.omnigroup.com/omnifocus/",\ + "userRatingCount":0,\ + "userRatingCountForCurrentVersion":0,\ + "version":"3.9.2",\ + "wrapperType":"software"\ + } + {\ + "adamID":777233759,\ + "appStorePageURL":"https://apps.apple.com/us/app/focus-time-management/id777233759?mt=12&uo=4",\ + "averageUserRating":0,\ + "averageUserRatingForCurrentVersion":0,\ + "bundleID":"com.malteundjan.focus-osx",\ + "categories":[\ + "Productivity",\ + "Education"\ + ],\ + "categoryIDs":[\ + "6007",\ + "6017"\ + ],\ + "censoredName":"Focus - Time Management",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2020-02-12T20:37:50Z",\ + "description":"Meet Focus: the best time manager for iPhone, iPad, Apple Watch and Mac. Focus is the most elegant and professional way to get more wore done, working in highly efficient work sessions, one task at a time.\\n\\n“[…] a tool that can genuinely make people more productive\\" – MacStories.net\\n\\n“[…] a must-have for anyone who finds themselves easily getting distracted or forgetting to take occasional breaks.\\" – iDownloadBlog.com\\n\\n======================\\nFEATURES\\n======================\\n\\nFOCUS SESSIONS\\nFocus Sessions are a highly efficient way to work. Focus for 25 minutes, then take a short break to relax your mind. After four sessions, take a 15 to 20 minute break. This method maximizes energy, stimulates creativity and promotes a sense of achievement.\\n\\nTASK MANAGER\\nFocus includes a lightweight task manager that lets you organize the things you want to work on intuitively. By working on one task at a time, you won’t be distracted and can focus all your attention towards completing that goal. That way you’ll be perfectly organized on your path to success.\\n\\nIN-DEPTH STATISTICS\\nCheck what you’ve already done! Focus keeps track of your work and offers in-depth and motivating statistics. See your daily, weekly and monthly activity so you don’t lose sight of the big picture. \\n\\nFOCUS EVERYWHERE\\nSeamlessly use Focus on your Mac, iPad, iPhone, and Apple Watch. Sync across your devices using iCloud; use Handoff to pick up your current work on another device and get up-to-the-second data with iCloud Push. You can also use the Today widget to quickly glance at your progress, import tasks using the handy Action extension, and more.\\n\\nFOCUS & APPLE WATCH: A PERFECT FIT\\nUsing Focus on your wrist is a natural fit. The independent Apple Watch app is made for for easy and lightweight interactions that lets you control sessions and track your progress throughout the day. With the Focus complication, you can customize your watch face to see your current progress at a glance.\\n\\nBEAUTIFUL INTERFACE\\nThe name says it all: Focus draws your attention to the most important things. It’s designed to be unobtrusive, accessible and easy-to-use. You’ll intuitively master its collection of features just by using them.\\n\\n======================\\nSUBSCRIPTION PRICING\\n======================\\n\\nFocus offers two subscription options: \\nFocus Monthly at $4.99/ month \\nFocus Yearly at $39.99/ year\\n\\nThe subscription unlocks all features on all devices (Mac, iPhone, iPad and Apple Watch).\\n\\nTRY IT FREE \\nFocus Monthly comes with a 3-day free trial period, Focus Yearly with a 7-day free trial period. If you cancel before the end of the trial, you will not be charged for the subscription.\\n\\nSUBSCRIPTION TERMS\\nPayment will be charged to your Apple ID account at the confirmation of purchase or after the free trial period if offered. \\n\\nYou subscription will automatically renew unless it is canceled at least 24 hours before the end of the current period. Your account will be charged 24 hours prior to the end of the current period. \\n\\nYou can manage and cancel your subscriptions by going to your account settings in the App Store after purchase. Any unused portion of a free trial will be forfeited when you purchase a subscription\\n\\n======================\\nCONTACT\\n======================\\n\\nIf you have any questions or ideas, please write us at hello@masterbuilders.io\\n\\nTwitter: @focusappio\\nhttps://www.masterbuilders.io\\n\\n\\n\\nPrivacy Policy: https://www.masterbuilders.io/privacy\\nTerms of Service: https://www.masterbuilders.io/terms",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/masterbuilders/id896347016?mt=12&uo=4",\ + "developerID":896347016,\ + "developerName":"Masterbuilders",\ + "fileSizeBytes":"24637530",\ + "formattedPrice":"Free",\ + "icon60URL":"https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/5a/cf/6c/5acf6c83-c496-d5fb-2445-96ef44f13a82/source/60x60bb.png",\ + "icon100URL":"https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/5a/cf/6c/5acf6c83-c496-d5fb-2445-96ef44f13a82/source/100x100bb.png",\ + "icon512URL":"https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/5a/cf/6c/5acf6c83-c496-d5fb-2445-96ef44f13a82/source/512x512bb.png",\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"mac-software",\ + "languageCodesISO2A":[\ + "EN",\ + "FR",\ + "DE",\ + "JA",\ + "ZH",\ + "ES"\ + ],\ + "minimumOSVersion":"10.14",\ + "name":"Focus - Time Management",\ + "originalVersionReleaseDate":"2013-12-19T19:16:50Z",\ + "price":0.00,\ + "primaryCategoryID":6007,\ + "primaryCategoryName":"Productivity",\ + "releaseNotes":"Subscription status is now properly unlocked on all devices.",\ + "screenshotURLs":[\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/1a/72/1d/1a721d98-fbc4-ed9e-2aae-ef9d5b538693/pr_source.png/800x500bb.jpg",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple124/v4/a2/b1/10/a2b110fd-aa90-286a-658b-2abd85bd1c68/mzl.menowpkq.png/800x500bb.jpg",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/26/f3/32/26f3322f-8ef9-6171-2864-715f571300e6/mzl.qxibkqwt.png/800x500bb.jpg",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple124/v4/a4/1b/12/a41b12dc-6e1d-74ff-7ad3-1d6888c31462/mzl.fdlcjqnh.png/800x500bb.jpg",\ + "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/83/83/24/83832412-85d1-78ff-a9ab-86a37b31121d/mzl.ateekpxr.png/800x500bb.jpg",\ + "https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/e6/7c/c7/e67cc703-d274-a1fc-88af-b5a8ce9cbfd8/mzl.grtjmgef.png/800x500bb.jpg",\ + "https://is4-ssl.mzstatic.com/image/thumb/Purple124/v4/18/a8/f2/18a8f211-61b5-7b7e-b343-784b260de31d/mzl.ulsghntx.png/800x500bb.jpg"\ + ],\ + "sellerName":"Masterbuilders",\ + "sellerURL":"https://www.focusapp.io",\ + "userRatingCount":0,\ + "userRatingCountForCurrentVersion":0,\ + "version":"6.2.3",\ + "wrapperType":"software"\ + } + {\ + "adamID":969210610,\ + "appStorePageURL":"https://apps.apple.com/us/app/1focus-website-app-blocker/id969210610?mt=12&uo=4",\ + "averageUserRating":0,\ + "averageUserRatingForCurrentVersion":0,\ + "bundleID":"com.onefocusapp.OneFocus",\ + "categories":[\ + "Productivity",\ + "Education"\ + ],\ + "categoryIDs":[\ + "6007",\ + "6017"\ + ],\ + "censoredName":"1Focus: Website & App Blocker",\ + "contentAdvisoryRating":"4+",\ + "contentRating":"4+",\ + "currency":"USD",\ + "currentVersionReleaseDate":"2020-07-18T23:51:25Z",\ + "description":"1Focus creates an oasis for focused work by disabling access to specific websites and apps. Use it to schedule a bit of automated self-restraint when you find yourself clicking away from what really needs to get done. Ideal for students, freelancers and writers.\\n\\n\\"If you find yourself on Facebook or checking your email every five minutes, you need 1Focus.\\" – Pagoda Technologies\\n\\n\\"1Focus is one of the best apps for tuning out the diversions that are most distracting for you.\\" – Tyler Horvath, CEO of Tyton Media\\n\\n\\nFREE FEATURES\\n\\n• Block websites in Google Chrome, Safari, Opera, Microsoft Edge and Brave\\n• Block apps (e.g. email, games)\\n• Block internet access by blocking web browsers\\n• You cannot cancel active blocks once you close the 1Focus window\\n• Create your own task presets (up to 2)\\n• Dark Mode\\n\\n\\n1FOCUS PRO\\n\\n• Schedule recurring block events (e.g. Mon - Fri)\\n• Work break timer\\n• Unlimited task presets\\n• Block all websites/apps except specific ones\\n• Suspend blocking for a limited time\\n• Block URL keywords (e.g. *gaming*)\\n• Block popular websites by category (e.g. Social Media)\\n\\nTry it free for 14 days. $1.99/month or $9.99/year after.\\n\\nPrices may vary by location. Subscriptions are charged to your iTunes Account. They automatically renew unless you cancel them in your Account Settings at least 24 hours before the end of the current period. Your Account is charged for renewal within 24 hours prior to the end of the current period. Terms of use: https://onefocusapp.com/terms\\n\\n\\nCUSTOMER SUPPORT\\n\\nDo you have any questions or suggestions?\\nonefocusapp.com/support",\ + "developerAppStorePageURL":"https://apps.apple.com/us/developer/niklas-behrens/id969210609?mt=12&uo=4",\ + "developerID":969210609,\ + "developerName":"Niklas Behrens",\ + "fileSizeBytes":"8338821",\ + "formattedPrice":"Free",\ + "icon60URL":"https://is3-ssl.mzstatic.com/image/thumb/Purple114/v4/12/30/fb/1230fb0c-42fd-1a80-9379-29be0ba0f612/source/60x60bb.png",\ + "icon100URL":"https://is3-ssl.mzstatic.com/image/thumb/Purple114/v4/12/30/fb/1230fb0c-42fd-1a80-9379-29be0ba0f612/source/100x100bb.png",\ + "icon512URL":"https://is3-ssl.mzstatic.com/image/thumb/Purple114/v4/12/30/fb/1230fb0c-42fd-1a80-9379-29be0ba0f612/source/512x512bb.png",\ + "isVPPDeviceBasedLicensingEnabled":true,\ + "kind":"mac-software",\ + "languageCodesISO2A":[\ + "EN",\ + "FR",\ + "DE",\ + "JA",\ + "KO",\ + "RU",\ + "ZH",\ + "ES"\ + ],\ + "minimumOSVersion":"10.10",\ + "name":"1Focus: Website & App Blocker",\ + "originalVersionReleaseDate":"2015-03-15T05:54:46Z",\ + "price":0.00,\ + "primaryCategoryID":6007,\ + "primaryCategoryName":"Productivity",\ + "releaseNotes":"- Allows updating 1Focus while blocking is active\\n- Fixed toolbar overflow on macOS High Sierra\\n- Improved status item width\\n- Fixed quick start menu starting wrong task\\n- Other bug fixes",\ + "screenshotURLs":[\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/35/03/5a/35035a62-e2da-2f4b-6ece-63475bd7cd02/pr_source.png/800x500bb.jpg",\ + "https://is3-ssl.mzstatic.com/image/thumb/Purple124/v4/b5/ac/1f/b5ac1fe2-431d-e45d-63e0-57ddbfbd525f/pr_source.png/800x500bb.jpg",\ + "https://is5-ssl.mzstatic.com/image/thumb/Purple114/v4/6c/18/ee/6c18eeff-ca66-2b01-82f3-f81576336ab7/pr_source.png/800x500bb.jpg"\ + ],\ + "sellerName":"Niklas Behrens",\ + "sellerURL":"https://onefocusapp.com",\ + "userRatingCount":0,\ + "userRatingCountForCurrentVersion":0,\ + "version":"3.4.4",\ + "wrapperType":"software"\ + } + + """, + ) #expect(actual == expected) } @@ -28,4 +899,4 @@ private extension MASTests { let expected = Consequences(nil, "", "Error: \(MASError.noCatalogAppsFound(for: searchTerm))\n") #expect(actual == expected) } -} +} // swiftlint:disable:this file_length diff --git a/Tests/MASTests/Controllers/MASTests+CatalogApp+ITunesSearch.swift b/Tests/MASTests/Controllers/MASTests+CatalogApp+ITunesSearch.swift deleted file mode 100644 index c9310cf5..00000000 --- a/Tests/MASTests/Controllers/MASTests+CatalogApp+ITunesSearch.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// MASTests+CatalogApp+ITunesSearch.swift -// mas -// -// Copyright © 2019 mas-cli. All rights reserved. -// - -private import Foundation -@testable private import mas -internal import Testing - -private extension MASTests { - @Test - func `iTunes searches for slack`() async { - let actual = await consequencesOf( - try await Dependencies.$current.withValue(.init { _ in (try Data(fromResource: "slack"), URLResponse()) }) { - try await search(for: "slack").count - }, - ) - let expected = Consequences(39) - #expect(actual == expected) - } - - @Test - func `looks up slack`() async { - let adamID = 803_453_959 as ADAMID - let actual = await consequencesOf( - try await Dependencies.$current.withValue( - .init { _ in (try Data(fromResource: "slack-lookup"), URLResponse()) }, - ) { - try await lookup(appID: .adamID(adamID)) - }, - ) - #expect(actual.error == nil && actual.stdout.isEmpty && actual.stderr.isEmpty) - guard let catalogApp = actual.value else { - #expect(actual.value != nil) - return - } - - #expect( - catalogApp.adamID == adamID // swiftformat:disable indent - && catalogApp.appStorePageURLString == "https://apps.apple.com/us/app/slack-for-desktop/id803453959?mt=12" - && catalogApp.bundleID == "com.tinyspeck.slackmacgap" - && catalogApp.fileSizeBytes == "74398324" - && catalogApp.formattedPrice == "Free" - && catalogApp.minimumOSVersion == "10.9" - && catalogApp.name == "Slack" - && catalogApp.releaseDate == "2018-10-02T23:28:05Z" - && catalogApp.sellerName == "Slack Technologies, Inc." - && catalogApp.sellerURLString == "https://slack.com" - && catalogApp.supportedDevices == nil - && catalogApp.version == "3.3.3", - ) // swiftformat:enable indent - } -} diff --git a/Tests/MASTests/Models/MASTests+CatalogApp.swift b/Tests/MASTests/Models/MASTests+CatalogApp.swift index b4118d2c..c0e700be 100644 --- a/Tests/MASTests/Models/MASTests+CatalogApp.swift +++ b/Tests/MASTests/Models/MASTests+CatalogApp.swift @@ -12,10 +12,46 @@ internal import Testing private extension MASTests { @Test func `parses catalog app from things that go bump JSON`() { - let actual = consequencesOf( - try JSONDecoder().decode(CatalogApp.self, from: .init(fromResource: "things-lookup")).adamID, - ) + let actual = consequencesOf(try decode(CatalogApp.self, fromResource: "things-lookup").adamID) let expected = Consequences(1_472_954_003 as ADAMID) #expect(actual == expected) } + + @Test + func `iTunes searches for slack`() async { + let actual = await consequencesOf( + try await Dependencies.$current.withValue(.init { _ in (try Data(fromResource: "slack"), URLResponse()) }) { + try await search(for: "slack").count + }, + ) + let expected = Consequences(39) + #expect(actual == expected) + } + + @Test + func `looks up slack`() async { + let adamID = 803_453_959 as ADAMID + let actual = await consequencesOf( + try await Dependencies.$current.withValue( + .init { _ in (try Data(fromResource: "slack-lookup"), URLResponse()) }, + ) { + try await lookup(appID: .adamID(adamID)) + }, + ) + #expect(actual.error == nil && actual.stdout.isEmpty && actual.stderr.isEmpty) + guard let catalogApp = actual.value else { + #expect(actual.value != nil) + return + } + + #expect( + catalogApp.adamID == adamID // swiftformat:disable indent + && catalogApp.appStorePageURLString == "https://apps.apple.com/us/app/slack-for-desktop/id803453959?mt=12" + && 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/Models/MASTests+CatalogAppResults.swift b/Tests/MASTests/Models/MASTests+CatalogAppResults.swift index 892a49d0..b6e7ec22 100644 --- a/Tests/MASTests/Models/MASTests+CatalogAppResults.swift +++ b/Tests/MASTests/Models/MASTests+CatalogAppResults.swift @@ -12,16 +12,14 @@ internal import Testing private extension MASTests { @Test func `parses catalog app results from BBEdit JSON`() { - let actual = - consequencesOf(try JSONDecoder().decode(CatalogAppResults.self, from: .init(fromResource: "bbedit")).resultCount) + let actual = consequencesOf(try decode(CatalogAppResults.self, fromResource: "bbedit").resultCount) let expected = Consequences(1) #expect(actual == expected) } @Test func `parses catalog app results from Things JSON`() { - let actual = - consequencesOf(try JSONDecoder().decode(CatalogAppResults.self, from: .init(fromResource: "things")).resultCount) + let actual = consequencesOf(try decode(CatalogAppResults.self, fromResource: "things").resultCount) let expected = Consequences(12) #expect(actual == expected) } diff --git a/Tests/MASTests/Utilities/Resources.swift b/Tests/MASTests/Utilities/Resources.swift new file mode 100644 index 00000000..264366fd --- /dev/null +++ b/Tests/MASTests/Utilities/Resources.swift @@ -0,0 +1,24 @@ +// +// Resources.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +internal import Foundation +private import JSONAST +internal import JSONDecoding +private import JSONParsing +@testable private import mas + +func decode( + _: T.Type = T.self, // swiftlint:disable:this function_default_parameter_at_end + fromResource resource: String, + encoding: String.Encoding = .utf8, +) throws -> T { + guard let json = String(data: try Data(fromResource: resource), encoding: encoding) else { + throw MASError.unparsableJSON() + } + + return try T(json: JSON.Node(parsingFragment: json)) +}