diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index d5fce1ad3..5537e2696 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -25,7 +25,9 @@ jobs: - runner: macos-15-intel xcode: 26.3 - runner: macos-26 + xcode: 26.4 - runner: macos-26-intel + xcode: 26.4 runs-on: ${{matrix.runner}} defaults: run: @@ -47,7 +49,7 @@ jobs: - name: 🛠 Select Xcode version if: matrix.xcode != 'swift' - run: xcodes select ${{matrix.xcode}} + run: sudo xcode-select -s /Applications/Xcode_${{matrix.xcode}}.app - name: 👢 Bootstrap run: Scripts/bootstrap diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 29c39fee7..1151537ad 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -50,7 +50,7 @@ jobs: if: matrix.language == 'swift' shell: bash run: | - xcodes select + sudo xcode-select -s /Applications/Xcode_26.4.app Scripts/build codeql -c release - name: 🔍 Perform CodeQL analysis diff --git a/.github/workflows/tag-pushed.yaml b/.github/workflows/tag-pushed.yaml index 90dbe4718..c77ad0f18 100644 --- a/.github/workflows/tag-pushed.yaml +++ b/.github/workflows/tag-pushed.yaml @@ -62,7 +62,7 @@ jobs: fi - name: 🛠 Select Xcode version - run: xcodes select + run: sudo xcode-select -s /Applications/Xcode_26.4.app - name: 📦 Build Apple & Intel installers run: | diff --git a/.gitignore b/.gitignore index 0b715be33..a0afc9d77 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ /.idea/ /.swiftpm/ /.vscode/ +/libexec/bin/mas .DS_Store *~ diff --git a/.swiftformat b/.swiftformat index f4902d27e..42f609e61 100644 --- a/.swiftformat +++ b/.swiftformat @@ -74,6 +74,7 @@ --timezone utc --type-attributes prev-line --type-body-marks remove +--type-order beforeMarks,nestedType,staticProperty,staticPropertyWithBody,classPropertyWithBody,staticMethod,classMethod,overriddenProperty,swiftUIPropertyWrapper,instanceProperty,computedProperty,instanceLifecycle,swiftUIProperty,swiftUIMethod,overriddenMethod,instanceMethod --wrap-arguments before-first --wrap-collections before-first --wrap-conditions before-first diff --git a/.swiftlint.yml b/.swiftlint.yml index 98ad63395..a8e3770f4 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -107,16 +107,16 @@ type_contents_order: - type_alias - subtype - type_property + - type_method - instance_property - ib_inspectable - ib_outlet - initializer - deinitializer - - type_method - view_life_cycle_method - ib_action - - other_method - subscript + - other_method unneeded_override: affect_initializers: true unused_import: diff --git a/.xcode-version b/.xcode-version index 102074d9f..3e9e908d3 100644 --- a/.xcode-version +++ b/.xcode-version @@ -1 +1 @@ -26.4 +26.4.1 diff --git a/AGENTS.md b/AGENTS.md index cc56368d9..45e51f7d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ - Useful code locations & examples (look here for patterns to follow): - CLI commands: `Sources/mas/Commands/` (e.g. `Install.swift`, `List.swift`) - Models: `Sources/mas/Models/` (e.g. `AppID.swift`, `CatalogApp.swift`) - - Utilities: `Sources/mas/Utilities/` (e.g. `JSON/AnyJSONEncodable.swift`) + - Utilities: `Sources/mas/Utilities/` (e.g. `Output/Printer.swift`) - Tests & test naming: `Tests/MASTests/` (see `MASTests+*.swift` files) - Private framework headers: `Sources/PrivateFrameworks/include/CommerceKit/` & `Sources/PrivateFrameworks/include/StoreFoundation/` (used via the @@ -35,24 +35,29 @@ Do NOT refactor code if doing so makes the caller interface worse. Specifically: -- **Inline a utility function at a call site only if it is single-use**. - Inlining increases verbosity, introduces duplication bugs & makes code harder - to maintain. Keep clean abstractions. Example of what NOT to do: +- **Inline a utility function or computed var at a call site iff it is + single-use**. Inlining multi-use functions or computed vars increases + verbosity, introduces duplication bugs & makes code harder to maintain. Keep + clean abstractions. Example of what NOT to do: ```swift - // ❌ BAD: Inlining capitalizingFirstCharacter at each call site - action.performing.prefix(1).uppercased() + action.performing.dropFirst() + // ❌ BAD: Inlining capitalizingFirstCharacter at multiple call sites + action1.performing.prefix(1).uppercased() + action.performing.dropFirst() + action2.performing.prefix(1).uppercased() + action.performing.dropFirst() // ✅ GOOD: Use the utility function - action.performing.capitalizingFirstCharacter + action1.performing.capitalizingFirstCharacter + action2.performing.capitalizingFirstCharacter ``` - **Never replace a clean, readable abstraction with a verbose closure**. e.g., - if a custom `SortComparator` or similar exists & is used multiple times, keep - it. Only consider inlining if it's used in exactly one place. Example of what - NOT to do: + if a custom `SortComparator` or similar is used multiple times, keep it. + Consider inlining only if the abstraction is used in exactly one place. + Example of what NOT to do: ```swift // ❌ BAD: Replacing a clean comparator with verbose closure - [].sorted { $0.compare($1, options: .numeric) == .orderedAscending } + [6, 9, 3].sorted { $0.compare($1, options: .numeric) == .orderedAscending } + [2, 8, 4].sorted { $0.compare($1, options: .numeric) == .orderedAscending } // ✅ GOOD: Keep the abstraction - [].sorted(using: NumericStringComparator.forward) + [6, 9, 3].sorted(using: NumericStringComparator.forward) + [2, 8, 4].sorted(using: NumericStringComparator.forward) ``` - **Replace a utility call** only when the new calling interface is at least as simple as the current calling interface @@ -227,6 +232,8 @@ Source is organized by concern: Each subsection contains code preferences in descending order. +Within this section & all subsections, `X` is a placeholder for any type name. + #### Naming 1. Standardized name @@ -249,16 +256,20 @@ Each subsection contains code preferences in descending order. #### Typing 1. Inferred type, e.g.: - - `var a = [String]()` - - `var o = Int?.none` - - `var c: String { .init(a.count) }` + - `var a = [X]()` + - `var o = X?.none` + - `var c: X { .init() }` + - `f(array: .init())` + - `f(dictionary: .init())` 2. Cast type, e.g.: - - `var a = [] as [String]` - - `var o = nil as Int?` + - `var a = [] as [X]` + - `var o = nil as X?` 3. Explicit type, e.g.: - - `var a: [String] = .init()` - - `var o: Int? = nil` - - `var c: String { String(a.count) }` + - `var a: [X] = .init()` + - `var o: X? = nil` + - `var c: X { X() }` + - `f(array: [])` + - `f(dictionary: [:])` #### Functional @@ -281,14 +292,14 @@ Each subsection contains code preferences in descending order. 1. Nil-coalescing operator (`??`) 2. Ternary operator -3. `Optional.map`/`flatMap` +3. `Optional.map(_:)` / `Optional.flatMap(_:)` 4. Single `guard` -5. `if`/`else` (no `else if`) +5. `if` / `else` (no `else if`) 6. `switch` 7. Multiple `guard` -8. `if`/`else if`…/`else` +8. `if` / `else if`… / `else` 9. Forced unwrapping (`!` suffix) -10. `fatalError()` +10. `fatalError(_:file:line:)` #### Throwing @@ -321,15 +332,15 @@ Each subsection contains code preferences in descending order. #### Type Syntax 1. Concision: - - Generics: `` - - Optional: `String?` - - Collection: `[String]` - - Dictionary: `[String:String]` + - Generics: `` + - Optional: `X?` + - Collection: `[X]` + - Dictionary: `[X:X]` 2. Verbosity: - - Generics: `where T: String` - - Optional: `Optional` - - Collection: `Array` - - Dictionary: `Dictionary` + - Generics: `where T: X` + - Optional: `Optional` + - Collection: `Array` + - Dictionary: `Dictionary` #### Void Types @@ -343,7 +354,7 @@ Each subsection contains code preferences in descending order. #### Closure Arguments -1. Shorthand argument names (e.g., `$0`) only for one-line closure +1. Shorthand argument names (e.g., `$0`) iff one-line closure 2. Explicit argument names for multi-line closure #### Functional Arguments @@ -366,6 +377,5 @@ Each subsection contains code preferences in descending order. - Derive test file paths from source file paths: - replace the `Sources/mas/` source path folder prefix with `Tests/MASTests/` - prepend `MASTests+` to the source file name - - e.g., `Sources/mas/Models/Foo.swift` → - `Tests/MASTests/Models/MASTests+Foo.swift` + - e.g., `Sources/mas/X.swift` → `Tests/MASTests/MASTests+X.swift` - Use force unwrapping (`!` suffix) diff --git a/Brewfile b/Brewfile index 88ab679a7..f10907897 100644 --- a/Brewfile +++ b/Brewfile @@ -1,12 +1,11 @@ brew "actionlint" # 1.7.12 brew "editorconfig-checker" # 3.6.1 -brew "gh" # 2.90.0 +brew "gh" # 2.91.0 brew "git" # 2.54.0 brew "ipsw" # 3.1.672 -brew "markdownlint-cli2" # 0.22.0 +brew "markdownlint-cli2" # 0.22.1 brew "periphery" if MacOS.version >= :sequoia && `/usr/bin/arch` == "arm64" # 3.7.2 brew "shellcheck" # 0.11.0 brew "swiftformat" # 0.61.0 brew "swiftlint" # 0.63.2 -brew "xcodes" # 1.6.2 brew "yamllint" # 1.38.0 diff --git a/Package.resolved b/Package.resolved index 4b1972844..0fa56e4d2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1e59b315a1259424c1a6805e5b9e9164e4267377e7bf98e1bb3f1c5986eab67d", + "originHash" : "a431011731c9cc16971b9a9760bd3b53ff3c42043a189d0bc6d9079bc46387bd", "pins" : [ { "identity" : "bigint", @@ -19,6 +19,15 @@ "version" : "0.1.14" } }, + { + "identity" : "gram", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rarestype/gram", + "state" : { + "revision" : "d9276e23523174cd04213e945f146dd9a553a571", + "version" : "2.0.0" + } + }, { "identity" : "hitch", "kind" : "remoteSourceControl", @@ -73,6 +82,15 @@ "version" : "1.4.1" } }, + { + "identity" : "swift-json", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mas-cli/swift-json.git", + "state" : { + "revision" : "7f39f41878ed56acccc3634d917022737c82e030", + "version" : "3.3.0" + } + }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 163ca75b7..185031573 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", from: "3.3.0"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.13.4"), ], 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/README.md b/README.md index 4546958bf..5b5cbfc6f 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,12 @@ automation. -| Provider | Method | mas | macOS | -|:---------------------------------------------------------------------------|:-------------------------------|:-------|:------------------| -| [Homebrew](https://brew.sh) [Core](https://formulae.brew.sh/formula/mas) | `brew install mas` | Latest | 14+ (recommended) | -| [Homebrew](https://brew.sh) [Tap](https://github.com/mas-cli/homebrew-tap) | `brew install mas-cli/tap/mas` | Latest | 13+ | -| [MacPorts](https://www.macports.org/install.php) | `sudo port install mas` | Latest | 13+ | -| [GitHub Releases](https://github.com/mas-cli/mas/releases) | Installers & source archives | Any | Release-dependent | +| Provider | Method | mas | macOS | +|:------------------------------------------------------------------------------|:-------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------------| +| [Homebrew](https://brew.sh) [Core](https://github.com/Homebrew/homebrew-core) | `brew install mas` | [![Homebrew Core](https://repology.org/badge/version-for-repo/homebrew/mas-mac-app-store.svg?header=)](https://formulae.brew.sh/formula/mas) | 14+ (recommended) | +| [Homebrew](https://brew.sh) [Tap](https://github.com/mas-cli/homebrew-tap) | `brew install mas-cli/tap/mas` | [![Homebrew Tap](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fmas-cli%2Fhomebrew-tap%2Freleases%2Flatest&query=%24.name&label=&color=4c1)](https://github.com/mas-cli/homebrew-tap/releases/latest) | 13+ | +| [MacPorts](https://www.macports.org/install.php) | `sudo port install mas` | [![MacPorts](https://repology.org/badge/version-for-repo/macports/mas-mac-app-store.svg?header=)](https://ports.macports.org/port/mas/details/) | 13+ | +| [GitHub Releases](https://github.com/mas-cli/mas/releases) | Installers & source archives | All | Release-dependent | @@ -38,28 +38,26 @@ Detailed documentation is available via `man mas` & `mas --help`. -| Command | Functionality | Requires | -|:---------------------------|:----------------------------------------------|:----------------------------------------------------------------------------------------------------------------------| -| `search …` | Search for App Store apps by name | | -| `lookup …` | Output App Store app details | | -| `info …` | `lookup` alias | | -| `list […]` | Output installed apps | [spotlight](#spotlight) | -| `outdated […]` | Output outdated apps | [spotlight](#spotlight), [account](#app-store-apple-account-requirements) for `--accurate` | -| `get …` | [Get free apps](#paid-apps), install any apps | [spotlight](#spotlight), [root](#root-privileges), [account for `get`](#app-store-apple-account-requirements-for-get) | -| `purchase …` | `get` alias | [spotlight](#spotlight), [root](#root-privileges), [account for `get`](#app-store-apple-account-requirements-for-get) | -| `install …` | Install already gotten or purchased apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | -| `lucky …` | Install first app from `search …` | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | -| `update […]` | Update outdated apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | -| `upgrade […]` | `update` alias | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | -| `uninstall (…\|--all)` | Uninstall apps | [spotlight](#spotlight), [root](#root-privileges) | -| `signout` | Sign out Apple Account from App Store | | -| `open []` | Open app App Store page | | -| `home …` | Open app web pages | | -| `seller …` | Open seller app web pages | | -| `vendor …` | `seller` alias | | -| `reset` | Reset App Store processes | | -| `config` | Output config | | -| `version` | Output version | | +| Command | Functionality | Notes | Aliases | +|:------------------------------|:----------------------------------------------|:------------------------------------------------------------------------------------------------------------|:-----------| +| `search …` | Search for App Store apps | [json](#json-app-output) | | +| `lookup …` | Output App Store app details | [json](#json-app-output) | `info` | +| `list […]` | Output installed apps | [spotlight](#spotlight), [json](#json-app-output) | | +| `outdated […]` | Output outdated apps | [spotlight](#spotlight), [json](#json-app-output) | | +| `outdated --accurate […]` | Output outdated apps | [spotlight](#spotlight), [account](#app-store-apple-account-requirements), [json](#json-app-output) | | +| `get …` | [Get free apps](#paid-apps), install any apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements-for-get) | `purchase` | +| `install …` | Install gotten or purchased apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | | +| `lucky …` | Install first matching app | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | | +| `update […]` | Update outdated apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | `upgrade` | +| `update --accurate […]` | Update outdated apps | [spotlight](#spotlight), [root](#root-privileges), [account](#app-store-apple-account-requirements) | `upgrade` | +| `uninstall (…\|--all)` | Uninstall apps | [spotlight](#spotlight), [root](#root-privileges) | | +| `signout` | Sign out from App Store | | | +| `open []` | Open app App Store page | | | +| `home …` | Open app web pages | | | +| `seller …` | Open seller app web pages | | `vendor` | +| `reset` | Reset App Store processes | | | +| `config` | Output config | [json](#json-config-output) | | +| `version` | Output version | | | @@ -98,6 +96,8 @@ Detailed documentation is available via `man mas` & `mas --help`. | Action | Command | |:------------------------------------------------------------------------|:-----------------------------| | Build | `Scripts/build` or Xcode 26+ | +| Set up zsh wrapper | `Scripts/setup_libexec` | +| Run zsh wrapper | `Scripts/mas` | | Test ([Swift Testing](https://developer.apple.com/xcode/swift-testing)) | `Scripts/test` | @@ -130,6 +130,41 @@ ADAM IDs can be found via: - e.g., `497799835` from +## JSON App Output + +`list`, `outdated` & `search` normally output tabular data, with a few fields +for each app on its own row. + +`lookup` normally outputs fields as key-value pairs—one per line—in a contiguous +block for each app, with a blank line between apps. + +If `--json` is supplied, these commands output a stream of JSON objects—one per +app—each containing all fields provided by Apple for that app. + +Many of the JSON keys provided by Apple are poorly named, so they are mapped to +better names by an algorithm. + + +Mapped JSON keys are [sorted]( + https://developer.apple.com/documentation/foundation/nsstring/compareoptions/numeric +). + + +Each JSON key should be unique within an object; if duplicate keys exist in an +object, their relative ordering in the input is preserved in the output. + +If Apple renames or adds JSON keys, suboptimal JSON keys might be output until +the mapping is updated. + +## JSON Config Output + +`config` normally outputs settings as key-value pairs, one per line. + +If `--json` is supplied, `config` outputs all settings in a single JSON object. + +Since the JSON keys are defined by mas, they are guaranteed to be unique & +correct. + ## Spotlight `list`, `outdated`, `get`, `install`, `lucky`, `update` & `uninstall` obtain diff --git a/Scripts/format b/Scripts/format index 67025537d..f9efb6bf8 100755 --- a/Scripts/format +++ b/Scripts/format @@ -20,7 +20,7 @@ export -r MAS_DISTRIBUTION=format printf -- $'--> 🕊​ SwiftFormat\n' script -q /dev/null swiftformat --strict --markdown-files format-strict . | # editorconfig-checker-disable-next-line - grep -vxE '(?:\^D\x08{2})?Running SwiftFormat\.{3}\r|Reading (?:config|swift-version) file at .*|\x1b\[32mSwiftFormat completed in \d+(?:\.\d+)?s\.\x1b\[0m\r|0/\d+ files formatted\.\r' || true + (grep -vxE '(?:\^D\x08{2})?Running SwiftFormat\.{3}\r|Reading (?:config|swift-version) file at .*|\x1b\[32mSwiftFormat completed in \d+(?:\.\d+)?s\.\x1b\[0m\r|0/\d+ files formatted\.\r' || true) printf -- $'--> 🦅 SwiftLint\n' swiftlint --fix --quiet --reporter relative-path diff --git a/Scripts/lint b/Scripts/lint index df13ad903..7ee6c28b0 100755 --- a/Scripts/lint +++ b/Scripts/lint @@ -40,7 +40,7 @@ integer exit_status=0 printf -- $'--> 🕊​ SwiftFormat\n' script -q /dev/null swiftformat --lint --markdown-files format-strict . | # editorconfig-checker-disable-next-line - grep -vxE '(?:\^D\x08{2})?Running SwiftFormat\.{3}\r|\(lint mode - no files will be changed\.\)\r|Reading (?:config|swift-version) file at .*|\x1b\[32mSwiftFormat completed in \d+(?:\.\d+)?s\.\x1b\[0m\r|0/\d+ files require formatting\.\r|Source input did not pass lint check\.\r' || true + (grep -vxE '(?:\^D\x08{2})?Running SwiftFormat\.{3}\r|\(lint mode - no files will be changed\.\)\r|Reading (?:config|swift-version) file at .*|\x1b\[32mSwiftFormat completed in \d+(?:\.\d+)?s\.\x1b\[0m\r|0/\d+ files require formatting\.\r|Source input did not pass lint check\.\r' || true) ((exit_status |= ${?})) printf -- $'--> 🦅 SwiftLint\n' @@ -58,15 +58,17 @@ fi if ((can_use_periphery)) && ! [[ -v 'received_flag[-P]' ]]; then printf -- $'--> 🌀 Periphery\n' - periphery scan --exclude-tests | + periphery scan --exclude-tests | ( grep -vxE '(?:\x1b\[0;1;32m|\^D\x08{2})\* (?:\x1b\[0;0m\x1b\[0;1m)?No unused code detected\.(?:\x1b\[0;0m)?\r?' || true + ) ((exit_status |= ${?})) printf -- $'--> 🌀 Periphery Tests\n' - periphery scan --no-superfluous-ignore-comments | + periphery scan --no-superfluous-ignore-comments | ( grep -vxE '(?:\x1b\[0;1;32m|\^D\x08{2})\* (?:\x1b\[0;0m\x1b\[0;1m)?No unused code detected\.(?:\x1b\[0;0m)?\r?' || true + ) ((exit_status |= ${?})) fi diff --git a/Scripts/mas b/Scripts/mas new file mode 100755 index 000000000..d5d871394 --- /dev/null +++ b/Scripts/mas @@ -0,0 +1,124 @@ +#!/bin/zsh -Ndefgku + +# Copyright © 2026 mas-cli. All rights reserved. + +builtin unalias -as +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 + +[[ -t 2 ]] && readonly error_prefix=$'\u001B[4;31mError:\u001B[0m' || readonly error_prefix=Error: + +case "${1:-}" in +outdated) + # shellcheck disable=SC1056,SC1072,SC1073 + { + # shellcheck disable=SC1083 + { exec "${mas}" "${@}" 3>&1 1>&4 2>&5 } | "${jq}" -sr ' +try ( + (map(.adamID | tostring | length) | max) as $max_adam_id_length | + (map(.version // "" | length) | max) as $max_version_length | + .[] | + (.adamID | tostring) as $adam_id | + (.version // "") as $version | + [ + " " * ($max_adam_id_length - ($adam_id | length)) + $adam_id, + .name, + "(" + $version + " " * ($max_version_length - ($version | length)) + " -> " + .newVersion + ")" + ] | + join("\u001f") +) catch ("'"${error_prefix}"' Invalid data from mas: \(.)\n" | halt_error(1)) +' | "${column}" -ts $'\u001f' + } 4>&1 5>&2 + ;; +search) + [[ -n ${argv[(r)--price]-} ]] && readonly output_price=' + [.formattedPrice // .price // "?"]' + ;& +list) + { + { 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 // "") + ")" + ] '"${output_price-}"'| + join("\u001f") +) catch ("'"${error_prefix}"' 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 ("'"${error_prefix}"' 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 ("'"${error_prefix}"' Invalid data from mas: \(.)\n" | halt_error(1)) +' + } 4>&1 5>&2 + ;; +*) + exec "${mas}" "${@}" + ;; +esac diff --git a/Scripts/package b/Scripts/package index 73fa471ec..20a3b192c 100755 --- a/Scripts/package +++ b/Scripts/package @@ -28,11 +28,13 @@ 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 -c "$(swift build -c release --show-bin-path "${@:2}")/mas" "${installation_staging_folder}/bin/mas" +cp -c "$(swift build -c release --show-bin-path "${@:2}")/mas" "${installation_staging_folder}/libexec/bin/mas" +cp -c Scripts/mas "${installation_staging_folder}/bin/mas" cp -c contrib/completion/mas.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" cp -c LICENSE README.md "${installation_staging_folder}" @@ -40,7 +42,7 @@ cp -c .build/plugins/GenerateManual/outputs/mas/mas.1 "${installation_staging_fo 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/prebuild b/Scripts/prebuild index 268ef7d4d..bda1c023e 100755 --- a/Scripts/prebuild +++ b/Scripts/prebuild @@ -12,10 +12,10 @@ print_notice '🎬 Prebuilding' "${@}" -# Generate Swift file containing build information. +# Generate Swift file containing build info. # shellcheck disable=SC1102 printf '// -// MAS+BuildInformation.swift +// MAS+BuildInfo.swift // mas // // Copyright © %s mas-cli. All rights reserved. @@ -37,4 +37,4 @@ extension MAS { "$(git rev-parse HEAD)"\ "${${${$(swift --version 2>/dev/null)##( |[[:alpha:]])##}%%$'\n'*}:-unknown}"\ "${${(SM)$(swift --version 2>&1 >/dev/null)##[[:digit:]]([[:digit:]]|.)##}:-unknown}"\ - >"${1%%/#}/MAS+BuildInformation.swift" + >"${1%%/#}/MAS+BuildInfo.swift" diff --git a/Scripts/setup_libexec b/Scripts/setup_libexec new file mode 100755 index 000000000..f31415e57 --- /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/Scripts/setup_workflow_repo b/Scripts/setup_workflow_repo index 403f961fe..1b4c36a73 100755 --- a/Scripts/setup_workflow_repo +++ b/Scripts/setup_workflow_repo @@ -12,7 +12,7 @@ # shellcheck disable=SC2066 for branch in\ - "${(f)"$(git for-each-ref refs/remotes/origin --format='%(if)%(symref)%(then)%(else)%(refname:strip=-1)%(end)')":#}";\ + "${(f)"$(git for-each-ref refs/remotes/origin --format='%(if)%(symref)%(then)%(else)%(refname:strip=-1)%(end)')":#}" do git branch --track "${branch}" "origin/${branch}" >/dev/null 2>&1 || true done diff --git a/Sources/mas/AppStore/AppStoreAction+download.swift b/Sources/mas/AppStore/AppStoreAction+download.swift index 193d5ed69..cb6b92830 100644 --- a/Sources/mas/AppStore/AppStoreAction+download.swift +++ b/Sources/mas/AppStore/AppStoreAction+download.swift @@ -79,7 +79,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { self.action = action self.adamID = adamID self.shouldCancel = shouldCancel - downloadFolderURL = .init(filePath: "\(CKDownloadDirectory(nil))/\(adamID)", directoryHint: .isDirectory) + downloadFolderURL = .init(folderPath: "\(CKDownloadDirectory(nil))/\(adamID)") let (eventStream, eventStreamContinuation) = AsyncStream.makeStream(of: Event.self) self.eventStreamContinuation = eventStreamContinuation // swiftlint:disable:this redundant_self @@ -108,8 +108,8 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { pkgHardLinkURL: pkgHardLinkURL, receiptHardLinkURL: receiptHardLinkURL, ) { continuation in - continuation // swiftformat:disable:next indent - .resume(throwing: MASError.error("Observer deallocated before download completed for ADAM ID \(adamID)")) + continuation + .resume(throwing: MASError.error("Observer deallocated before download completed for ADAM ID \(adamID)")) } } @@ -118,7 +118,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { } nonisolated func downloadQueue(_: CKDownloadQueue, changedWithAddition _: SSDownload) { - // Do nothing + // Empty } nonisolated func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) { @@ -267,11 +267,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { MAS.printer.notice(PhaseType.downloaded, snapshot.appNameAndVersion) MAS.printer.notice(action.performing.capitalizingFirstCharacter, snapshot.appNameAndVersion) - MAS.printer.info( - String(describing: action).capitalizingFirstCharacter, - "progress cannot be displayed", - terminator: "", - ) + MAS.printer.info(action.rawValue.capitalizingFirstCharacter, "progress cannot be displayed", terminator: "") appFolderURL = try await install(appNameAndVersion: snapshot.appNameAndVersion) MAS.printer.clearCurrentLine(of: .standardOutput) } else { @@ -286,12 +282,12 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { throw MASError.error("Download cancelled for \(snapshot.appNameAndVersion)") } - appFolderURL = snapshot.appFolderPath.map { .init(filePath: $0, directoryHint: .isDirectory) } + appFolderURL = snapshot.appFolderPath.map { .init(folderPath: $0) } } MAS.printer.notice( [action.performed.capitalizingFirstCharacter, snapshot.appNameAndVersion] - + (appFolderURL.map { ["in", $0.filePath] } ?? .init()), // swiftformat:disable:this indent + + (appFolderURL.map { ["in", $0.filePath] } ?? .init()), ) if let appFolderURL { @@ -377,19 +373,19 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { guard let appFolderURLSubstring = standardErrorString - .matches(of: appFolderURLRegex) // swiftformat:disable indent - .compactMap(\.1) - .min(by: { $0.count < $1.count }) - else { // swiftformat:enable indent + .matches(of: appFolderURLRegex) + .compactMap(\.1) + .min(by: { $0.count < $1.count }) + else { throw MASError.error( "Failed to find app folder URL in installer output for \(appNameAndVersion)", - error: standardErrorString, + cause: standardErrorString, ) } guard let appFolderURL = URL(string: .init(appFolderURLSubstring)) else { throw MASError.error( "Failed to parse app folder URL for \(appNameAndVersion) from \(appFolderURLSubstring)", - error: standardErrorString, + cause: standardErrorString, ) } @@ -418,7 +414,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { Failed to copy receipt for \(appNameAndVersion) from \(receiptHardLinkURL.filePath.quoted) to\ \(receiptURL.filePath.quoted) """, // editorconfig-checker-enable - error: error, + cause: error, ) } @@ -522,12 +518,6 @@ extension PhaseType: CustomStringConvertible { } } -private extension String { - var capitalizingFirstCharacter: Self { - prefix(1).capitalized + dropFirst() - } -} - private extension URL { func linksToSameInode(as url: URL?) throws -> Bool { guard let url, url.isFileURL, isFileURL else { @@ -545,7 +535,7 @@ private extension URL { } private func deleteTempFolder(containing url: URL?, fileType: String) { - url.map { url in + if let url { do { try FileManager.default.removeItem(at: url.deletingLastPathComponent()) } catch { diff --git a/Sources/mas/AppStore/AppStoreAction.swift b/Sources/mas/AppStore/AppStoreAction.swift index 0fefdd37f..e4292117c 100644 --- a/Sources/mas/AppStore/AppStoreAction.swift +++ b/Sources/mas/AppStore/AppStoreAction.swift @@ -9,7 +9,7 @@ private import ArgumentParser private import Darwin private import OrderedCollections -enum AppStoreAction { +enum AppStoreAction: String { case get case install case update @@ -60,10 +60,7 @@ enum AppStoreAction { let adamIDOrderedSet = OrderedSet(adamIDs) guard getuid() == 0 else { - try sudo( - MAS._commandName, - args: [.init(describing: self), "--force"] + adamIDOrderedSet.map(String.init(describing:)), - ) + try sudo(MAS._commandName, args: [rawValue, "--force"] + adamIDOrderedSet.map(String.init(_:))) return } diff --git a/Sources/mas/Commands/Config.swift b/Sources/mas/Commands/Config.swift index 13425f335..9c632745b 100644 --- a/Sources/mas/Commands/Config.swift +++ b/Sources/mas/Commands/Config.swift @@ -8,6 +8,7 @@ internal import ArgumentParser private import Darwin private import Foundation +private import JSONAST extension MAS { /// Outputs mas config & related system info. @@ -16,24 +17,28 @@ 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.Object( // swiftformat:disable:this wrap wrapArguments + dictionaryLiteral: // swiftformat:disable indent + ("mas", .string(version)), // swiftlint:disable vertical_parameter_alignment_on_call + ("slice", .string(runningSliceArchitecture)), + ("slices", .string(supportedSliceArchitectures.joined(separator: " "))), + ("dist", .string(distribution)), + ("origin", .string(gitOrigin)), + ("rev", .string(gitRevision)), + ("swift", .string(swiftVersion)), + ("driver", .string(swiftDriverVersion)), + ("store", .string(appStoreRegion)), + ("region", .string(macRegion)), + ("macos", .string(macOSVersion)), + ("mac", .string(configStringValue("hw.product"))), + ("cpu", .string(configStringValue("machdep.cpu.brand_string"))), + ("arch", .string(configStringValue("hw.machine"))), // swiftlint:enable vertical_parameter_alignment_on_call + ), // swiftformat:enable indent ) } } @@ -78,8 +83,10 @@ private var supportedSliceArchitectures: [String] { ?? .init() // swiftformat:disable:this indent } -private var macOSVersion: Substring { - ProcessInfo.processInfo.operatingSystemVersionString.dropFirst(8).replacing("Build ", with: "", maxReplacements: 1) +private var macOSVersion: String { + .init( + ProcessInfo.processInfo.operatingSystemVersionString.dropFirst(8).replacing("Build ", with: "", maxReplacements: 1), + ) } private func configStringValue(_ name: String) -> String { diff --git a/Sources/mas/Commands/Get.swift b/Sources/mas/Commands/Get.swift index 6f2ac5b56..58d34f203 100644 --- a/Sources/mas/Commands/Get.swift +++ b/Sources/mas/Commands/Get.swift @@ -25,7 +25,7 @@ extension MAS { try await AppStore.get.apps( withAppIDs: catalogAppIDsOptionGroup.appIDs, force: forceOptionGroup.force, - installedApps: try await installedApps, + installedApps: try await installedApps(), ) } } diff --git a/Sources/mas/Commands/Install.swift b/Sources/mas/Commands/Install.swift index 17c36137a..2e43fe216 100644 --- a/Sources/mas/Commands/Install.swift +++ b/Sources/mas/Commands/Install.swift @@ -24,7 +24,7 @@ extension MAS { try await AppStore.install.apps( withAppIDs: catalogAppIDsOptionGroup.appIDs, force: forceOptionGroup.force, - installedApps: try await installedApps, + installedApps: try await installedApps(), ) } } diff --git a/Sources/mas/Commands/List.swift b/Sources/mas/Commands/List.swift index 5f290d110..ba5b9a16e 100644 --- a/Sources/mas/Commands/List.swift +++ b/Sources/mas/Commands/List.swift @@ -15,19 +15,18 @@ extension MAS { abstract: "List apps installed from the App Store", ) + @OptionGroup + private var outputFormatOptionGroup: OutputFormatOptionGroup @OptionGroup private var installedAppIDsOptionGroup: InstalledAppIDsOptionGroup func run() async throws { - run(installedApps: try await installedApps) + run(installedApps: try await installedApps(withFullJSON: outputFormatOptionGroup.shouldOutputJSON)) } 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( // editorconfig-checker-disable """ 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 b946cfaca..68b238e15 100644 --- a/Sources/mas/Commands/Lookup.swift +++ b/Sources/mas/Commands/Lookup.swift @@ -6,20 +6,21 @@ // internal import ArgumentParser -private import Foundation extension MAS { - /// Outputs app information from the App Store. + /// Outputs app info from the App Store. /// /// Uses the iTunes Lookup API: /// /// https://performance-partners.apple.com/search-api struct Lookup: AsyncParsableCommand { static let configuration = CommandConfiguration( - abstract: "Output app information from the App Store", + abstract: "Output app info from the App Store", 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(String.init(describing:)).joined(separator: "\n")) } } } - -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/Lucky.swift b/Sources/mas/Commands/Lucky.swift index e6469643a..2fe80f32e 100644 --- a/Sources/mas/Commands/Lucky.swift +++ b/Sources/mas/Commands/Lucky.swift @@ -27,7 +27,7 @@ extension MAS { private var searchTermOptionGroup: SearchTermOptionGroup func run() async throws { - try await run(installedApps: try await installedApps) + try await run(installedApps: try await installedApps()) } private func run(installedApps: [InstalledApp]) async throws { diff --git a/Sources/mas/Commands/MAS.swift b/Sources/mas/Commands/MAS.swift index 29d80a05d..ab4a4080b 100644 --- a/Sources/mas/Commands/MAS.swift +++ b/Sources/mas/Commands/MAS.swift @@ -122,15 +122,9 @@ private func cast(_ instance: Any, as _: T.Type) -> T? { } private let applicationsFolderPath = "/Applications" -private let applicationsFolderURL = URL(filePath: applicationsFolderPath, directoryHint: .isDirectory) +private let applicationsFolderURL = URL(folderPath: applicationsFolderPath) let applicationsFolderURLs = UserDefaults(suiteName: "com.apple.appstored")? -.dictionary(forKey: "PreferredVolume")?["name"] // swiftformat:disable indent -.map { largeAppVolumeName in - [ - applicationsFolderURL, - .init(filePath: "/Volumes/\(largeAppVolumeName)\(applicationsFolderPath)", directoryHint: .isDirectory), - ] -} // swiftformat:disable:this blankLinesBetweenScopes -?? [applicationsFolderURL] -// swiftformat:enable indent + .dictionary(forKey: "PreferredVolume")?["name"] + .map { [applicationsFolderURL, .init(folderPath: "/Volumes/\($0)\(applicationsFolderPath)")] } + ?? [applicationsFolderURL] diff --git a/Sources/mas/Commands/Open.swift b/Sources/mas/Commands/Open.swift index ba431521f..c5fa0a314 100644 --- a/Sources/mas/Commands/Open.swift +++ b/Sources/mas/Commands/Open.swift @@ -29,9 +29,9 @@ extension MAS { func run() async throws { try await run( appStorePageURLString: appIDString.map { appIDString in - try await Dependencies.current // swiftformat:disable:next indent - .lookupAppFromAppID(.init(from: appIDString, forceBundleID: forceBundleIDOptionGroup.forceBundleID)) - .appStorePageURLString // swiftformat:disable:this indent + try await Dependencies.current + .lookupAppFromAppID(.init(from: appIDString, forceBundleID: forceBundleIDOptionGroup.forceBundleID)) + .appStorePageURLString }, ) } diff --git a/Sources/mas/Commands/OptionGroups/OutdatedAppOptionGroup.swift b/Sources/mas/Commands/OptionGroups/OutdatedAppOptionGroup.swift index 8aaf0216b..b14ee2a19 100644 --- a/Sources/mas/Commands/OptionGroups/OutdatedAppOptionGroup.swift +++ b/Sources/mas/Commands/OptionGroups/OutdatedAppOptionGroup.swift @@ -29,7 +29,7 @@ struct OutdatedAppOptionGroup: ParsableArguments { do { let catalogApp = try await Dependencies.current.lookupAppFromAppID(.bundleID(installedApp.bundleID)) return shouldCheckMinimumOSVersion // swiftformat:disable indent - && UniversalSemVerInt(from: catalogApp.minimumOSVersion).map { minimumOSVersion in + && UniversalSemVerInt(rawValue: catalogApp.minimumOSVersion).map { minimumOSVersion in ProcessInfo.processInfo.isOperatingSystemAtLeast( .init( majorVersion: minimumOSVersion.majorInteger, @@ -68,7 +68,7 @@ struct OutdatedAppOptionGroup: ParsableArguments { installedApp.version != appStoreVersion, !alreadyResumed.exchange(true, ordering: .acquiringAndReleasing) { - continuation.resume(returning: OutdatedApp(installedApp, appStoreVersion)) + continuation.resume(returning: .init(installedApp: installedApp, newVersion: appStoreVersion)) } return true } @@ -84,15 +84,10 @@ struct OutdatedAppOptionGroup: ParsableArguments { } : { @Sendable installedApp in await installableCatalogApp(from: installedApp).flatMap { catalogApp in - UniversalSemVer(from: installedApp.version).compareSemVerAndBuild(to: .init(from: catalogApp.version)) - == .orderedAscending ? OutdatedApp(installedApp, catalogApp.version) : nil + UniversalSemVer(rawValue: installedApp.version).compareSemVerAndBuild(to: .init(rawValue: catalogApp.version)) + == .orderedAscending ? .init(installedApp: installedApp, newVersion: catalogApp.version) : nil } }, ) // swiftformat:enable indent } } - -typealias OutdatedApp = ( - installedApp: InstalledApp, - newVersion: String, -) diff --git a/Sources/mas/Commands/OptionGroups/OutputFormatOptionGroup.swift b/Sources/mas/Commands/OptionGroups/OutputFormatOptionGroup.swift new file mode 100644 index 000000000..96e874e88 --- /dev/null +++ b/Sources/mas/Commands/OptionGroups/OutputFormatOptionGroup.swift @@ -0,0 +1,27 @@ +// +// OutputFormatOptionGroup.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +private import ArgumentParser +private import Darwin +private import Foundation + +struct OutputFormatOptionGroup: ParsableArguments { + @Flag(name: .customLong("json"), help: "Output JSON") + 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 + : .init(fileDescriptor: 3), + ) + } +} diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index 2158d2476..a1fc64607 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -6,7 +6,8 @@ // internal import ArgumentParser -private import Foundation +private import JSON +private import JSONAST extension MAS { /// Outputs a list of installed apps which have updates available to be @@ -16,11 +17,16 @@ extension MAS { abstract: "List pending app updates from the App Store", ) + @OptionGroup + private var outputFormatOptionGroup: OutputFormatOptionGroup @OptionGroup private var outdatedAppOptionGroup: OutdatedAppOptionGroup func run() async throws { - await run(installedApps: try await installedApps.filter(!\.isTestFlight)) + await run( + installedApps: // swiftformat:disable:next indent + try await installedApps(withFullJSON: outputFormatOptionGroup.shouldOutputJSON).filter(!\.isTestFlight), + ) } private func run(installedApps: [InstalledApp]) async { @@ -28,27 +34,9 @@ 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 { - return + if !outdatedApps.isEmpty { + outputFormatOptionGroup.info(outdatedApps.map { .init(describing: $0) }.joined(separator: "\n")) } - - 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, - ) - } - .joined(separator: "\n"), - ) } } } diff --git a/Sources/mas/Commands/Reset.swift b/Sources/mas/Commands/Reset.swift index 9124e3965..76e671087 100644 --- a/Sources/mas/Commands/Reset.swift +++ b/Sources/mas/Commands/Reset.swift @@ -43,7 +43,7 @@ extension MAS { "/System/Library/PrivateFrameworks/AppStoreComponents.framework/Support/appstorecomponentsd", "/System/Library/PrivateFrameworks/AppStoreDaemon.framework/Support/appstoreagent", "/System/Library/PrivateFrameworks/CascadeSets.framework/Versions/A/XPCServices" - + "/SetStoreUpdateService.xpc/Contents/MacOS/SetStoreUpdateService", // swiftformat:disable:this indent + + "/SetStoreUpdateService.xpc/Contents/MacOS/SetStoreUpdateService", "/System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/Resources/storeaccountd", "/System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/Resources/storeassetd", "/System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/Resources/storedownloadd", diff --git a/Sources/mas/Commands/Search.swift b/Sources/mas/Commands/Search.swift index fe42886d0..313745cd6 100644 --- a/Sources/mas/Commands/Search.swift +++ b/Sources/mas/Commands/Search.swift @@ -19,9 +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 + @Flag(help: "Output the price of each app") // swiftformat:disable:next unusedPrivateDeclarations + private var price = false // periphery:ignore + @OptionGroup // swiftlint:disable:previous unused_declaration private var searchTermOptionGroup: SearchTermOptionGroup func run() async throws { @@ -31,26 +33,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/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index 1177a8da3..20cb75f7e 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -35,7 +35,7 @@ extension MAS { } func run() async throws { - try run(installedApps: try await installedApps) + try run(installedApps: try await installedApps()) } private func run(installedApps: [InstalledApp]) throws { @@ -113,10 +113,7 @@ extension MAS { } var uninstalledAppNSURL = NSURL?.none // swiftlint:disable:this legacy_objc_type - try unsafe fileManager.trashItem( - at: .init(filePath: appPath, directoryHint: .isDirectory), - resultingItemURL: &uninstalledAppNSURL, - ) + try unsafe fileManager.trashItem(at: .init(folderPath: appPath), resultingItemURL: &uninstalledAppNSURL) guard let uninstalledAppPath = uninstalledAppNSURL?.path else { printer.error( // editorconfig-checker-disable """ diff --git a/Sources/mas/Commands/Update.swift b/Sources/mas/Commands/Update.swift index 52608fd23..215c6fdb3 100644 --- a/Sources/mas/Commands/Update.swift +++ b/Sources/mas/Commands/Update.swift @@ -22,13 +22,14 @@ extension MAS { private var outdatedAppOptionGroup: OutdatedAppOptionGroup func run() async throws { - try await run(installedApps: try await installedApps.filter(!\.isTestFlight)) + try await run(installedApps: try await installedApps().filter(!\.isTestFlight)) } private func run(installedApps: [InstalledApp]) async throws { try await run( outdatedApps: forceOptionGroup.force // swiftformat:disable:next indent - ? installedApps.filter(for: outdatedAppOptionGroup.installedAppIDsOptionGroup.appIDs).map { ($0, "") } + ? installedApps.filter(for: outdatedAppOptionGroup.installedAppIDsOptionGroup.appIDs) + .map { OutdatedApp(installedApp: $0, newVersion: "") } // swiftformat:disable:this indent : await outdatedAppOptionGroup.outdatedApps(from: installedApps), ) } diff --git a/Sources/mas/Controllers/CatalogApp+ITunesSearch.swift b/Sources/mas/Controllers/CatalogApp+ITunesSearch.swift deleted file mode 100644 index 0849dfe3a..000000000 --- a/Sources/mas/Controllers/CatalogApp+ITunesSearch.swift +++ /dev/null @@ -1,127 +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: .init())) - .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 SwiftSoup.parse(try await Dependencies.current.dataFrom(url).0, appStorePageURLString) - .getElementById("serialized-server-data")? // swiftformat:disable:this acronyms - .data() - .query( - string: - "$.data[0].data.shelfMapping.information.items[?(@.title == 'Compatibility')].items[?(@.heading == 'Mac')].text", - )? - .firstMatch(of: minimumOSVersionRegex)? - .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: .init())) - .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] = [.init(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: [.init(name: "media", value: "software")] - + additionalQueryItems // swiftformat:disable:this indent - + [.init(name: "country", value: region), queryItem], // swiftformat:disable:this 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 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 fbf704a85..000000000 --- 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 URL?.none - } - - enumerator.skipDescendants() - return try? url.appending(path: "Contents/_MASReceipt/receipt", directoryHint: .notDirectory) - .resourceValues(forKeys: [.fileSizeKey]) - .fileSize - .flatMap { $0 > 0 ? url : nil } - } - } - ?? .init() - } // 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 = .init(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: .init(describing: item.value(forAttribute: NSMetadataItemCFBundleIdentifierKey) ?? ""), - name: .init(describing: item.value(forAttribute: "_kMDItemDisplayNameWithExtensions") ?? "") - .removingSuffix(".app"), - path: .init(describing: item.value(forAttribute: NSMetadataItemPathKey) ?? ""), - version: .init(describing: item.value(forAttribute: NSMetadataItemVersionKey) ?? ""), - ) - } - } - .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/Models/CatalogApp.swift b/Sources/mas/Models/CatalogApp.swift index 12b029785..02b66aa35 100644 --- a/Sources/mas/Models/CatalogApp.swift +++ b/Sources/mas/Models/CatalogApp.swift @@ -5,83 +5,353 @@ // 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 jsonObjectRaw: JSON.Object + private let json: Lazy + + fileprivate var asMacDesktopApp: Self? { + get async { + guard + supportsMacDesktop, + let minimumOSVersion = try? await URL(string: appStorePageURLString) + .flatMap({ url in + try SwiftSoup.parse(try await Dependencies.current.dataFrom(url).0, appStorePageURLString) + .getElementById("serialized-server-data")? // swiftformat:disable:this acronyms + .data() + .query( + string: """ + $.data[0].data.shelfMapping.information.items[?(@.title == 'Compatibility')].items[?(@.heading == 'Mac')].text + """, + )? + .firstMatch(of: minimumOSVersionRegex) + .map { String($0.version) } + }) + else { + return nil + } + + return minimumOSVersion == self.minimumOSVersion + ? self // swiftformat:disable:this indent + : { + var jsonObjectRaw = jsonObjectRaw + if let index = jsonObjectRaw.fields.firstIndex(where: { $0.key == minimumOSVersionKey }) { + jsonObjectRaw.fields[index] = (minimumOSVersionKey, .string(minimumOSVersion)) + } else { + jsonObjectRaw.fields.append((minimumOSVersionKey, .string(minimumOSVersion))) + } + return .init( + adamID: adamID, + appStorePageURLString: appStorePageURLString, + minimumOSVersion: minimumOSVersion, + name: name, + sellerURLString: sellerURLString, + supportsMacDesktop: supportsMacDesktop, + version: version, + jsonObjectRaw: jsonObjectRaw, + ) + }() + } } - 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 = "", + private init( + adamID: ADAMID, + appStorePageURLString: String, + minimumOSVersion: String, + name: String, + sellerURLString: String?, + supportsMacDesktop: Bool, + version: String, + jsonObjectRaw: JSON.Object, ) { 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.supportsMacDesktop = supportsMacDesktop self.version = version + self.jsonObjectRaw = jsonObjectRaw + json = .init(.init(jsonObjectRaw.mappingKeys)) + } +} + +extension CatalogApp: CustomStringConvertible { + var description: String { + json.value + } +} + +extension CatalogApp: JSONDecodable { + fileprivate init(json: JSON.Node) throws { + guard case let .object(object) = json else { + throw MASError.unparsableJSON(.init(json)) + } + + self.init( + 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: // swiftformat:disable:next indent + try object["supportedDevices"]?.decode(to: [String]?.self)?.contains("MacDesktop-MacDesktop") ?? false, + version: try object["version"]?.decode() ?? "", + jsonObjectRaw: object, + ) } +} - func with(minimumOSVersion: String) -> Self { +private extension JSON.Node { + var mappingKeys: Self { + switch self { + case let .object(object): + .object(object.mappingKeys) + case let .array(array): + .array(.init(array.elements.map(\.mappingKeys))) + default: + self + } + } +} + +private extension JSON.Object { + var mappingKeys: Self { .init( - adamID: adamID, - appStorePageURLString: appStorePageURLString, - bundleID: bundleID, - fileSizeBytes: fileSizeBytes, - formattedPrice: formattedPrice, - minimumOSVersion: minimumOSVersion, - name: name, - releaseDate: releaseDate, - sellerName: sellerName, - sellerURLString: sellerURLString, - supportedDevices: supportedDevices, - version: version, + fields + .map { (.init(rawValue: $0.rawValue.mappingKey), $1.mappingKeys) } + .sorted(using: KeyPathComparator(\.0.rawValue, comparator: NumericStringComparator.forward)), ) } } -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 +private extension String { + var mappingKey: 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": + minimumOSVersionKey.rawValue + 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(artworkURLRegex) { match in + let output = match.output + guard let first = output.0.first else { + return "" + } + + return first.isLowercase ? "icon\(output.1)URL" : "Icon\(output.1)URL" + } + .replacing(trackRegex) { match in + func track(_ prefix: Self) -> Self { + output.3.first.map { $0.isUppercase ? $0.lowercased() : "\(prefix)\(output.2)\($0)" } + ?? "\(prefix)\(output.2)" // swiftformat:disable:this indent + } + + let output = match.output + return switch output.1 { + case "track": + track("app") + case "Track": + track("App") + case "trackId": + "adamID\(output.2)\(output.3)" + case "TrackId": + "ADAMID\(output.2)\(output.3)" + default: + Self(output.0) + } + } + .replacing(manyRegex) { match in + let output = match.output + return switch output.1 { + case "appletv": + "appleTV\(output.2)" + case "Appletv": + "AppleTV\(output.2)" + case "artist": + "developer\(output.2)" + case "Artist": + "Developer\(output.2)" + case "artwork": + "icon\(output.2)" + case "Artwork": + "Icon\(output.2)" + case "genre": + output.2.isEmpty ? "category" : "categories" + case "Genre": + output.2.isEmpty ? "Category" : "Categories" + case "Id": + "ID\(output.2)" + case "ipad": + "iPad\(output.2)" + case "Ipad": + "IPad\(output.2)" + case "Os": + output.2.isEmpty ? "OS" : .init(output.0) + case "releaseDate": + "originalVersionReleaseDate\(output.2)" + case "Url": + "URL\(output.2)" + case "view": + "appStorePage\(output.2)" + case "View": + "AppStorePage\(output.2)" + case "Vpp": + "VPP\(output.2)" + default: + Self(output.0) + } + } + } } } + +func lookup(appID: AppID) async throws -> CatalogApp { + try await lookup(appID: appID, inRegion: appStoreRegion) +} + +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 catalogApps(from: try url("lookup", queryItem, inRegion: region)).first + { + catalogApp + } else { + try await catalogApps(from: try url("lookup", queryItem, inRegion: region, additionalQueryItems: .init())) + .first + .flatMap { await $0.asMacDesktopApp } + ?? { throw MASError.unknownAppID(appID) }() + } +} + +func search(for searchTerm: String) async throws -> [CatalogApp] { + try await search(for: searchTerm, inRegion: appStoreRegion) +} + +private func search(for searchTerm: String, inRegion region: Region = appStoreRegion) async throws -> [CatalogApp] { + let queryItem = URLQueryItem(name: "term", value: searchTerm) + let catalogApps = try await catalogApps(from: try url("search", queryItem, inRegion: region)) + let adamIDSet = Set(catalogApps.map(\.adamID)) + return catalogApps.priorityMerge( + try await mas.catalogApps(from: try url("search", queryItem, inRegion: region, additionalQueryItems: .init())) + .filter { !adamIDSet.contains($0.adamID) } + .concurrentCompactMap { await $0.asMacDesktopApp }, + ) { $0.name.similarity(to: searchTerm) } +} + +private func url( + _ action: String, + _ queryItem: URLQueryItem, + inRegion region: Region, + additionalQueryItems: [URLQueryItem] = [.init(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: [.init(name: "media", value: "software")] + + additionalQueryItems + + [.init(name: "country", value: region), queryItem], + ) +} + +private func catalogApps(from url: URL) async throws -> [CatalogApp] { + try await unsafe Dependencies.current.dataFrom(url).0.withUnsafeBytes { bufferPointer in + try CatalogAppResults(json: .init(parsing: unsafe RawSpan(_unsafeBytes: unsafe bufferPointer))).results + } +} + +private let minimumOSVersionKey = JSON.Key("minimumOSVersion") +private let artworkURLRegex = /(?:^artworkUrl|ArtworkUrl)(\d+)/ +private let trackRegex = /((?:^track|Track)(?:Id)?)(s?)($|[\d\p{Upper}])/ +// editorconfig-checker-disable-next-line +private let manyRegex = /(^appletv|Appletv|^artist|Artist|^artwork|Artwork|^genre|Genre|Id|^ipad|Ipad|Os|^releaseDate|Url|^view|View|Vpp)(s?)(?=$|[\d\p{Upper}])/ +private let minimumOSVersionRegex = /macOS\s*(?\S+)/ diff --git a/Sources/mas/Models/CatalogAppResults.swift b/Sources/mas/Models/CatalogAppResults.swift index 47936e0fc..d09ab6b18 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(.init(json)) + } + + resultCount = try object["resultCount"]?.decode() ?? 0 + results = try object["results"]?.decode() ?? .init() + } } diff --git a/Sources/mas/Models/InstalledApp.swift b/Sources/mas/Models/InstalledApp.swift index d7a4407c6..4943b0a70 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 CoreFoundation +private import Foundation +internal import JSONAST +private import JSONParsing +private import ObjectiveC + struct InstalledApp { let adamID: ADAMID let bundleID: String @@ -12,10 +19,51 @@ struct InstalledApp { let path: String let version: String + let jsonObject: Lazy + + private let jsonObjectRaw: JSON.Object + private let json: Lazy + var isTestFlight: Bool { adamID == 0 } + fileprivate init(for item: NSMetadataItem, withFullJSON: Bool) { + let valueByAttribute = item.values( + forAttributes: withFullJSON + ? item.attributes + [NSMetadataItemPathKey] // swiftformat:disable:this indent + : [ + "kMDItemAppStoreAdamID", + NSMetadataItemCFBundleIdentifierKey, + "_kMDItemDisplayNameWithExtensions", + NSMetadataItemPathKey, + NSMetadataItemVersionKey, + ], + ) + ?? .init() // swiftformat:disable:this indent + adamID = valueByAttribute["kMDItemAppStoreAdamID"] as? ADAMID ?? 0 + bundleID = .init(describing: valueByAttribute[NSMetadataItemCFBundleIdentifierKey] ?? "") + name = .init(describing: valueByAttribute["_kMDItemDisplayNameWithExtensions"] ?? "").removingSuffix(".app") + path = valueByAttribute[NSMetadataItemPathKey].map { pathAny in + let path = String(describing: pathAny) + return (try? URL(folderPath: path).resourceValues(forKeys: [.canonicalPathKey]))?.canonicalPath ?? path + } + ?? "" // swiftformat:disable:this indent + version = .init(describing: valueByAttribute[NSMetadataItemVersionKey] ?? "") + + jsonObjectRaw = .init(valueByAttribute.map { (.init(rawValue: $0.key), jsonNode(for: $0.value)) }) + let jsonObjectRaw = jsonObjectRaw + let name = name + jsonObject = .init( + .init( + (jsonObjectRaw.fields.map { (.init(rawValue: $0.rawValue.mappingKey), $1) } + [("name", .string(name))]) + .sorted(using: KeyPathComparator(\.0.rawValue, comparator: NumericStringComparator.forward)), + ), + ) + let jsonObject = jsonObject + json = .init(.init(jsonObject.value)) + } + func matches(_ appID: AppID) -> Bool { switch appID { case let .adamID(adamID): @@ -26,6 +74,12 @@ struct InstalledApp { } } +extension InstalledApp: CustomStringConvertible { + var description: String { + json.value + } +} + extension [InstalledApp] { func filter(for appIDs: [AppID]) -> [Element] { appIDs.isEmpty @@ -39,3 +93,270 @@ extension [InstalledApp] { } } } + +private func jsonNode(for value: Any?) -> JSON.Node { + switch value { + case let jsonNode as JSON.Node: + jsonNode + case let number as NSNumber: // swiftlint:disable:this legacy_objc_type + number === kCFBooleanTrue || number === kCFBooleanFalse + ? .bool(number.boolValue) // swiftformat:disable:this indent + : .init(.init(describing: number)) ?? .null + case let date as Date: + .string(date.formatted(.iso8601)) + case let data as Data: + data.isEmpty + ? .string("") // swiftformat:disable:this indent // swiftlint:disable:this void_function_in_ternary + : { + var hex = "0x" + hex.reserveCapacity(2 + data.count * 2) + return .string( + data.reduce(into: hex) { hex, byte in + let byteHex = String(byte, radix: 16) + if byteHex.count < 2 { + hex += "0" + } + hex += byteHex + }, + ) + }() + case let array as [Any?]: + .array(.init(array.map { jsonNode(for: $0) })) + default: + value.map { .string(.init(describing: $0)) } ?? .null // swiftformat:disable:this indent + } +} + +private extension String { + var mappingKey: 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(keyRegex) { match in + let output = match.output + return output.1?.isEmpty == false ? "fileSystem" : output.2?.lowercased() ?? "" + } + } + } +} + +private extension URL { + var installedAppURLs: [URL] { + FileManager.default + .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 URL?.none + } + + enumerator.skipDescendants() + return try? url.appending(path: "Contents/_MASReceipt/receipt", directoryHint: .notDirectory) + .resourceValues(forKeys: [.fileSizeKey]) + .fileSize + .flatMap { $0 > 0 ? url : nil } + } + } + ?? .init() + } +} + +func installedApps(withFullJSON: Bool = false) async throws -> [InstalledApp] { + try await installedApps(matching: "kMDItemAppStoreAdamID LIKE '*'", withFullJSON: withFullJSON) +} + +func installedApps(withADAMID adamID: ADAMID, withFullJSON: Bool = false) async throws -> [InstalledApp] { + try await installedApps(matching: "kMDItemAppStoreAdamID = \(adamID)", withFullJSON: withFullJSON) +} + +@MainActor +func installedApps(matching metadataQuery: String, withFullJSON: Bool = false) async throws -> [InstalledApp] { + var observer = (any NSObjectProtocol)?.none + defer { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + + let query = NSMetadataQuery() + query.predicate = .init(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(for: $0, withFullJSON: withFullJSON) } } + .sorted(using: KeyPathComparator(\.name, comparator: .localizedStandard)) + + 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; will likely complete 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() + } +} + +// swiftformat:disable:next docComments +// editorconfig-checker-disable-next-line +private let keyRegex = /^_?kMDItem(?:(FS)|(?:AppStore)?(\p{Upper}(?=\p{Lower})|\p{Upper}+(?=$|\p{Upper}\p{Lower}))?)?/ diff --git a/Sources/mas/Models/MASError.swift b/Sources/mas/Models/MASError.swift index ef93791e4..f4aec272b 100644 --- a/Sources/mas/Models/MASError.swift +++ b/Sources/mas/Models/MASError.swift @@ -6,22 +6,23 @@ // enum MASError: Error { - case error(String, error: (any Error)? = nil, separator: String = ":\n", separatorAndErrorReplacement: String = "") + case error(String, cause: (any Error)? = nil, separatorWhenCause: String = ":\n", separatorWhenNoCause: String = "") case noCatalogAppsFound(for: String) case unknownAppID(AppID) + case unparsableJSON(String? = nil) case unparsableURL(String) static func error( _ message: String, - error: String?, - separator: String = ":\n", - separatorAndErrorReplacement: String = "", + cause: String?, + separatorWhenCause: String = ":\n", + separatorWhenNoCause: String = "", ) -> Self { .error( message, - error: error.map { Self.error($0) }, - separator: separator, - separatorAndErrorReplacement: separatorAndErrorReplacement, + cause: cause.map { Self.error($0) }, // swiftformat:disable:this redundantStaticSelf + separatorWhenCause: separatorWhenCause, + separatorWhenNoCause: separatorWhenNoCause, ) } } @@ -29,12 +30,14 @@ enum MASError: Error { extension MASError: CustomStringConvertible { var description: String { switch self { - case let .error(message, error, separator, separatorAndErrorReplacement): - "\(message)\(error.map { "\(separator)\($0)" } ?? separatorAndErrorReplacement)" + case let .error(message, cause, separatorWhenCause, separatorWhenNoCause): + "\(message)\(cause.map { "\(separatorWhenCause)\($0)" } ?? separatorWhenNoCause)" case let .noCatalogAppsFound(searchTerm): "No apps found in the App Store for search term: \(searchTerm)" case let .unknownAppID(appID): "No apps found in the App Store for \(appID)" + case let .unparsableJSON(string): + string.map { "Failed to parse JSON from:\n\($0)" } ?? "Failed to parse JSON" case let .unparsableURL(string): "Failed to parse URL from \(string)" } diff --git a/Sources/mas/Models/OutdatedApp.swift b/Sources/mas/Models/OutdatedApp.swift new file mode 100644 index 000000000..e5cbef67d --- /dev/null +++ b/Sources/mas/Models/OutdatedApp.swift @@ -0,0 +1,37 @@ +// +// OutdatedApp.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +private import JSONAST + +struct OutdatedApp { + let installedApp: InstalledApp + let newVersion: String // periphery:ignore + + private let json: Lazy + + init(installedApp: InstalledApp, newVersion: String) { + self.installedApp = installedApp + self.newVersion = newVersion + var jsonObjectInstalled = installedApp.jsonObject.value + jsonObjectInstalled.fields.insert( + (newVersionKey, .string(newVersion)), + at: jsonObjectInstalled.fields + .map(\.key.rawValue) + .lowerBound(of: newVersionKey.rawValue, using: NumericStringComparator.forward), + ) + let jsonObject = jsonObjectInstalled + json = .init(.init(describing: jsonObject)) + } +} + +extension OutdatedApp: CustomStringConvertible { + var description: String { + json.value + } +} + +private let newVersionKey = JSON.Key("newVersion") diff --git a/Sources/mas/Utilities/Comparison/NumericStringComparator.swift b/Sources/mas/Utilities/Comparison/NumericStringComparator.swift new file mode 100644 index 000000000..09e7f4d84 --- /dev/null +++ b/Sources/mas/Utilities/Comparison/NumericStringComparator.swift @@ -0,0 +1,40 @@ +// +// NumericStringComparator.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +internal import Foundation + +enum NumericStringComparator: SortComparator { + case forward + case reverse + + var order: SortOrder { + get { + self == .forward ? .forward : .reverse + } + set { + self = newValue == .forward ? .forward : .reverse + } + } + + func compare(_ lhs: String, _ rhs: String) -> ComparisonResult { + let result = lhs.compare(rhs, options: .numeric) + return self == .forward ? result : result.reversed + } +} + +extension ComparisonResult { + var reversed: Self { + switch self { + case .orderedAscending: + .orderedDescending + case .orderedDescending: + .orderedAscending + case .orderedSame: + .orderedSame + } + } +} diff --git a/Sources/mas/Utilities/JSON/JSON.Object.swift b/Sources/mas/Utilities/JSON/JSON.Object.swift new file mode 100644 index 000000000..337297c65 --- /dev/null +++ b/Sources/mas/Utilities/JSON/JSON.Object.swift @@ -0,0 +1,19 @@ +// +// JSON.Object.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +internal import JSONAST +internal import JSONDecoding + +extension JSON.Object { + subscript(key: JSON.Key) -> JSON.OptionalDecoder { + .init(key: key, value: fields.first { $0.key == key }?.value) + } // periphery:ignore + + subscript(key: JSON.Key) -> JSON.FieldDecoder? { + (fields.first { $0.key == key }?.value).map { .init(key: key, value: $0) } + } +} diff --git a/Sources/mas/Utilities/Output/Printer.swift b/Sources/mas/Utilities/Output/Printer.swift index 4cf05d014..f161faf68 100644 --- a/Sources/mas/Utilities/Output/Printer.swift +++ b/Sources/mas/Utilities/Output/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 @@ -75,11 +85,7 @@ struct Printer { func clearCurrentLine(of fileHandle: FileHandle) { if fileHandle.isTerminal { - do { - try fileHandle.write(contentsOf: Data("\(csi)2K\(csi)0G".utf8)) - } catch { - // Do nothing - } + try? fileHandle.write(contentsOf: clearLine) } } @@ -110,11 +116,10 @@ struct Printer { } private func print(_ items: [String], separator: String, terminator: String, to fileHandle: FileHandle) { - do { - try fileHandle.write(contentsOf: Data(items.joined(separator: separator).appending(terminator).utf8)) - } catch { - // Do nothing - } + unsafe items.joined(separator: separator) + .appending(terminator) + .utf8 + .withContiguousStorageIfAvailable { try? unsafe fileHandle.write(contentsOf: unsafe $0) } } private func print( @@ -140,7 +145,7 @@ struct Printer { print( items.first.map { item in ["\(formattedPrefix) \(mas.indent(item, with: indent))"] - + items.dropFirst().map { mas.indent($0, with: indent) } // swiftformat:disable:this indent + + items.dropFirst().map { mas.indent($0, with: indent) } } ?? [formattedPrefix], // swiftformat:disable:this indent separator: mas.indent(separator, with: indent), @@ -163,7 +168,6 @@ private func indent(_ item: Any, with indent: String) -> String { let errorPrefix = "Error:" let errorFormat = "4;31" -/// Terminal Control Sequence Indicator. private let csi = "\u{001B}[" - +private let clearLine = Data("\(csi)2K\(csi)0G".utf8) private let nonEmptyLineStartRegex = /\n(?!\n)/ diff --git a/Sources/mas/Utilities/Processes/Process.swift b/Sources/mas/Utilities/Processes/Process.swift index 506f6806a..991fed148 100644 --- a/Sources/mas/Utilities/Processes/Process.swift +++ b/Sources/mas/Utilities/Processes/Process.swift @@ -15,7 +15,7 @@ func run( runProcess run: (Process) throws -> Void = { try $0.run() }, ) async throws -> (standardOutputString: String, standardErrorString: String) { let process = Process() - process.executableURL = .init(filePath: executablePath, directoryHint: .notDirectory) + process.executableURL = .init(nonFolderPath: executablePath) process.arguments = args let standardOutputPipe = Pipe() @@ -34,7 +34,7 @@ func run( do { try run(process) } catch { - throw MASError.error(errorMessage(), error: error) + throw MASError.error(errorMessage(), cause: error) } process.waitUntilExit() diff --git a/Sources/mas/Utilities/Processes/ProcessInfo.swift b/Sources/mas/Utilities/Processes/ProcessInfo.swift index 308760e4f..db4e348f4 100644 --- a/Sources/mas/Utilities/Processes/ProcessInfo.swift +++ b/Sources/mas/Utilities/Processes/ProcessInfo.swift @@ -22,11 +22,11 @@ extension ProcessInfo { } func runAsSudoEffectiveUserAndSudoEffectiveGroup(_ body: () throws -> T) throws -> T { - try run(asEffectiveUID: sudoUID, andEffectiveGID: sudoGID, body) + try run(asEffectiveUID: try sudoUID, andEffectiveGID: try sudoGID, body) } func runAsSudoEffectiveUserAndSudoEffectiveGroup(_ body: () async throws -> T) async throws -> T { - try await run(asEffectiveUID: sudoUID, andEffectiveGID: sudoGID, body) + try await run(asEffectiveUID: try sudoUID, andEffectiveGID: try sudoGID, body) } func runAsSudoEffectiveUserAndSudoEffectiveGroupIfRootEffectiveUser(_ body: () throws -> T) throws -> T { diff --git a/Sources/mas/Utilities/Processes/Sudo.swift b/Sources/mas/Utilities/Processes/Sudo.swift index f30dabd43..2d4e87a0d 100644 --- a/Sources/mas/Utilities/Processes/Sudo.swift +++ b/Sources/mas/Utilities/Processes/Sudo.swift @@ -30,8 +30,8 @@ private func sudo(_ args: some Sequence) throws { guard spawnStatus == 0 else { throw MASError.error( "Failed to spawn installer process", - error: unsafe .init(cString: strerror(spawnStatus)), // swiftformat:disable:this spaceAroundOperators - separator: ": ", + cause: unsafe .init(cString: strerror(spawnStatus)), // swiftformat:disable:this spaceAroundOperators + separatorWhenCause: ": ", ) } diff --git a/Sources/mas/Utilities/Resources/URL.swift b/Sources/mas/Utilities/Resources/URL.swift index 9a6e8493d..fdc00d191 100644 --- a/Sources/mas/Utilities/Resources/URL.swift +++ b/Sources/mas/Utilities/Resources/URL.swift @@ -16,6 +16,14 @@ extension URL { ?? .init(path(percentEncoded: false).dropLast { $0 == "/" }) // swiftformat:disable:this indent } + init(folderPath path: String, relativeTo base: URL? = nil) { + self.init(filePath: path, directoryHint: .isDirectory, relativeTo: base) + } + + init(nonFolderPath path: String, relativeTo base: URL? = nil) { + self.init(filePath: path, directoryHint: .notDirectory, relativeTo: base) + } + func open(configuration: NSWorkspace.OpenConfiguration = .init()) async throws -> NSRunningApplication { try await NSWorkspace.shared.open(self, configuration: configuration) } diff --git a/Sources/mas/Utilities/Swift/Collection.swift b/Sources/mas/Utilities/Swift/Collection.swift index 71a222b36..8a14637c4 100644 --- a/Sources/mas/Utilities/Swift/Collection.swift +++ b/Sources/mas/Utilities/Swift/Collection.swift @@ -12,10 +12,10 @@ extension Collection { } extension Collection where Element: Sendable { - func concurrentMap( + func concurrentMap( // swiftlint:disable:this unused_declaration maxConcurrentTaskCount: Int = defaultMaxConcurrentTaskCount, _ transform: @escaping @Sendable (Element) async -> T, - ) async -> [T] { + ) async -> [T] { // periphery:ignore await concurrentTransform(maxConcurrentTaskCount: maxConcurrentTaskCount, transform) } diff --git a/Sources/mas/Utilities/Swift/Lazy.swift b/Sources/mas/Utilities/Swift/Lazy.swift new file mode 100644 index 000000000..398d6c89f --- /dev/null +++ b/Sources/mas/Utilities/Swift/Lazy.swift @@ -0,0 +1,39 @@ +// +// Lazy.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +private import os + +final class Lazy: Sendable { + private struct Storage { + var value = Value?.none + var initializer = (@Sendable () -> Value)?.none + } + + private let state: OSAllocatedUnfairLock + + var value: Value { + state.withLock { storage in + storage.value ?? { + storage.value = storage.initializer!() // swiftlint:disable:this force_unwrapping + storage.initializer = nil + return storage.value! // swiftlint:disable:this force_unwrapping + }() + } + } + + init(_ initializer: @escaping @Sendable () -> Value) { + state = .init(initialState: Storage(value: nil, initializer: initializer)) + } + + convenience init(_ initializer: @autoclosure @escaping @Sendable () -> Value) { + self.init(initializer) + } + + deinit { + // Empty + } +} diff --git a/Sources/mas/Utilities/Swift/RandomAccessCollection.swift b/Sources/mas/Utilities/Swift/RandomAccessCollection.swift new file mode 100644 index 000000000..d8b3d7d84 --- /dev/null +++ b/Sources/mas/Utilities/Swift/RandomAccessCollection.swift @@ -0,0 +1,24 @@ +// +// RandomAccessCollection.swift +// mas +// +// Copyright © 2026 mas-cli. All rights reserved. +// + +internal import Foundation + +extension RandomAccessCollection { + func lowerBound(of element: Element, using comparator: some SortComparator) -> Index { + var low = startIndex + var high = endIndex + while low < high { + let mid = index(low, offsetBy: distance(from: low, to: high) / 2) + if comparator.compare(self[mid], element) == .orderedAscending { + low = index(after: mid) + } else { + high = mid + } + } + return low + } +} diff --git a/Sources/mas/Utilities/Swift/String.swift b/Sources/mas/Utilities/Swift/String.swift index 70499aa45..0c4acefad 100644 --- a/Sources/mas/Utilities/Swift/String.swift +++ b/Sources/mas/Utilities/Swift/String.swift @@ -8,6 +8,10 @@ private import Foundation extension String { + var capitalizingFirstCharacter: Self { + prefix(1).capitalized + dropFirst() + } + var quoted: Self { "'\(replacing("'", with: "\\'"))'" } diff --git a/Sources/mas/Utilities/Versions/Version+SemVer.swift b/Sources/mas/Utilities/Versions/Version+SemVer.swift index 0a9c90574..eb63acd26 100644 --- a/Sources/mas/Utilities/Versions/Version+SemVer.swift +++ b/Sources/mas/Utilities/Versions/Version+SemVer.swift @@ -11,17 +11,16 @@ internal import Foundation // swiftlint:disable:next blanket_disable_command // swiftlint:disable file_types_order one_declaration_per_file -protocol Version: CustomStringConvertible { +protocol Version: RawRepresentable { var coreElements: [String] { get } var prereleaseElements: [String] { get } var buildElements: [String] { get } - var core: String { get } - var prerelease: String? { get } - var build: String? { get } - - init?(from versionString: String) // periphery:ignore -} + // swiftlint:disable unused_declaration + var core: String { get } // periphery:ignore + var prerelease: String? { get } // periphery:ignore + var build: String? { get } // periphery:ignore +} // swiftlint:enable unused_declaration protocol CoreIntegerVersion: Version { associatedtype Integer: BinaryInteger @@ -30,9 +29,13 @@ protocol CoreIntegerVersion: Version { } extension CoreIntegerVersion where Integer: FixedWidthInteger { - var coreElements: [String] { + fileprivate static func coreElements(from coreIntegers: [Integer]) -> [String] { coreIntegers.map { .init($0) } } + + var coreElements: [String] { + Self.coreElements(from: coreIntegers) + } } protocol MajorMinorPatch { // swiftlint:disable unused_declaration @@ -66,80 +69,109 @@ extension MajorMinorPatchInteger { protocol SemVerSyntax: Version {} extension SemVerSyntax { - var core: String { + fileprivate static func core(from coreElements: [String]) -> String { coreElements.joined(separator: ".") } - var prerelease: String? { + fileprivate static func prerelease(from prereleaseElements: [String]) -> String? { prereleaseElements.isEmpty ? nil : prereleaseElements.joined(separator: ".") } - var build: String? { + fileprivate static func build(from buildElements: [String]) -> String? { buildElements.isEmpty ? nil : buildElements.joined(separator: ".") } - var description: String { - "\(core)\(prerelease.map { "-\($0)" } ?? "")\(build.map { "+\($0)" } ?? "")" + var core: String { + Self.core(from: coreElements) + } + + var prerelease: String? { + Self.prerelease(from: prereleaseElements) + } + + var build: String? { + Self.build(from: buildElements) } } protocol SemVerSyntaxInteger: CoreIntegerVersion, SemVerSyntax, MajorMinorPatchInteger {} struct UniversalSemVerInt: SemVerSyntaxInteger { - typealias Integer = Int - - let coreIntegers: [Integer] + let coreIntegers: [Int] let prereleaseElements: [String] let buildElements: [String] + let rawValue: String - var majorInteger: Integer { + var majorInteger: Int { coreIntegers[0] } - var minorInteger: Integer { + var minorInteger: Int { coreIntegers[1] } - var patchInteger: Integer { + var patchInteger: Int { coreIntegers[2] } init( - coreIntegers: [Integer], + coreIntegers: [Int], prereleaseElements: [String] = .init(), buildElements: [String] = .init(), - ) { - self.coreIntegers = coreIntegers.padding(toCount: 3, with: 0) - self.prereleaseElements = prereleaseElements - self.buildElements = buildElements - } - - init?(from versionString: String) { + ) { // periphery:ignore + self.init( + coreIntegers: coreIntegers, + prereleaseElements: prereleaseElements, + buildElements: buildElements, + rawValue: """ + \(Self.core(from: Self.coreElements(from: coreIntegers)))\ + \(Self.prerelease(from: prereleaseElements).map { "-\($0)" } ?? "")\ + \(Self.build(from: buildElements).map { "+\($0)" } ?? "") + """, + ) + } + + init?(rawValue: String) { do { - let match = versionString.wholeMatch(of: universalSemVerRegex)! // swiftlint:disable:this force_unwrapping + let match = rawValue.wholeMatch(of: universalSemVerRegex)! // swiftlint:disable:this force_unwrapping self = .init( coreIntegers: try match.1.elements.map { coreElement in try .init(coreElement) ?? { throw MASError.error(coreElement) }() }, prereleaseElements: match.2.elements, buildElements: match.3.elements, + rawValue: rawValue, ) } catch { return nil } } + + private init( + coreIntegers: [Int], + prereleaseElements: [String], + buildElements: [String], + rawValue: String, + ) { + self.coreIntegers = coreIntegers.padding(toCount: 3, with: 0) + self.prereleaseElements = prereleaseElements + self.buildElements = buildElements + self.rawValue = rawValue + } } struct UniversalSemVer: SemVerSyntax { let coreElements: [String] let prereleaseElements: [String] let buildElements: [String] + let rawValue: String - init(from versionString: String) { - let match = versionString.wholeMatch(of: universalSemVerRegex)! // swiftlint:disable:this force_unwrapping + init(rawValue: String) { + let match = rawValue.wholeMatch(of: universalSemVerRegex)! // swiftlint:disable:this force_unwrapping coreElements = match.1.elements prereleaseElements = match.2.elements buildElements = match.3.elements + self.rawValue = rawValue } } diff --git a/Tests/MASTests/Commands/MASTests+Lookup.swift b/Tests/MASTests/Commands/MASTests+Lookup.swift index ab3a82fc5..fc6f806ae 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, // editorconfig-checker-disable """ - 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"\ + } """, // editorconfig-checker-enable ) diff --git a/Tests/MASTests/Commands/MASTests+Search.swift b/Tests/MASTests/Commands/MASTests+Search.swift index 951a0c82e..04cfca448 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: [.init(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, // editorconfig-checker-disable + """ + {\ + "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"\ + } + + """, // editorconfig-checker-enable + ) #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 b4699afd3..000000000 --- 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 .init(fromResource: "slack"), .init()) }) { - try await search(for: "slack").count - }, - ) - let expected = Consequences(39) - #expect(actual == expected) - } - - @Test - func `looks up slack`() async { - let adamID = ADAMID(803_453_959) - let actual = await consequencesOf( - try await Dependencies.$current.withValue( - .init { _ in (try .init(fromResource: "slack-lookup"), .init()) }, - ) { - 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 25461fb45..45cffa8c5 100644 --- a/Tests/MASTests/Models/MASTests+CatalogApp.swift +++ b/Tests/MASTests/Models/MASTests+CatalogApp.swift @@ -12,10 +12,44 @@ 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(ADAMID(1_472_954_003)) #expect(actual == expected) } + + @Test + func `iTunes searches for slack`() async { + let actual = await consequencesOf( + try await Dependencies.$current.withValue(.init { _ in (try .init(fromResource: "slack"), .init()) }) { + try await search(for: "slack").count + }, + ) + let expected = Consequences(39) + #expect(actual == expected) + } + + @Test + func `looks up slack`() async { + let adamID = ADAMID(803_453_959) + let actual = await consequencesOf( + try await Dependencies.$current.withValue(.init { _ in (try .init(fromResource: "slack-lookup"), .init()) }) { + 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 892a49d04..b6e7ec22f 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 000000000..627d963b4 --- /dev/null +++ b/Tests/MASTests/Utilities/Resources.swift @@ -0,0 +1,19 @@ +// +// 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 + +// swiftlint:disable:next function_default_parameter_at_end +func decode(_: T.Type = T.self, fromResource resource: String) throws -> T { + try unsafe Data(fromResource: resource).withUnsafeBytes { bufferPointer in + try .init(json: .init(parsing: unsafe RawSpan(_unsafeBytes: unsafe bufferPointer))) + } +} diff --git a/contrib/completion/mas.fish b/contrib/completion/mas.fish index 4c7f20381..071196ea0 100644 --- a/contrib/completion/mas.fish +++ b/contrib/completion/mas.fish @@ -23,7 +23,7 @@ complete -c mas -n __fish_use_subcommand -fa help -d 'Output general or command- complete -c mas -n __fish_use_subcommand -fa home -d 'Open App Store app pages in the default web browser' complete -c mas -n __fish_use_subcommand -fa install -d 'Install previously gotten apps from the App Store' complete -c mas -n __fish_use_subcommand -fa list -d 'List apps installed from the App Store' -complete -c mas -n __fish_use_subcommand -fa lookup -d 'Output app information from the App Store' +complete -c mas -n __fish_use_subcommand -fa lookup -d 'Output app info from the App Store' complete -c mas -n __fish_use_subcommand -fa lucky -d 'Install the first app returned from searching the App Store' complete -c mas -n __fish_use_subcommand -fa open -d 'Open app page in \'App Store.app\'' complete -c mas -n __fish_use_subcommand -fa outdated -d 'List pending app updates from the App Store' @@ -35,17 +35,18 @@ complete -c mas -n __fish_use_subcommand -fa uninstall -d 'Uninstall apps instal complete -c mas -n __fish_use_subcommand -fa update -d 'Update outdated apps installed from the App Store' complete -c mas -n __fish_use_subcommand -fa version -d 'Output version number' -complete -c mas -n '__fish_seen_subcommand_from get home install lookup open seller' -xa '(__fish_mas_list_available)' -complete -c mas -n '__fish_seen_subcommand_from get home install list lookup open outdated seller uninstall update; and not __fish_contains_opt bundle' -l bundle -d 'Process all app IDs as bundle IDs' # editorconfig-checker-disable-line -complete -c mas -n '__fish_seen_subcommand_from get install lucky update; and not __fish_contains_opt force' -l force -d 'Force reinstall' # editorconfig-checker-disable-line +complete -c mas -n '__fish_seen_subcommand_from config info list lookup outdated search; and not __fish_contains_opt json' -l json -d 'Output JSON' # editorconfig-checker-disable-line +complete -c mas -n '__fish_seen_subcommand_from get home info install lookup open purchase seller vendor' -xa '(__fish_mas_list_available)' # editorconfig-checker-disable-line +complete -c mas -n '__fish_seen_subcommand_from get home info install list lookup open outdated purchase seller uninstall update upgrade vendor; and not __fish_contains_opt bundle' -l bundle -d 'Process all app IDs as bundle IDs' # editorconfig-checker-disable-line +complete -c mas -n '__fish_seen_subcommand_from get install lucky purchase update upgrade; and not __fish_contains_opt force' -l force -d 'Force reinstall' # editorconfig-checker-disable-line complete -c mas -n '__fish_seen_subcommand_from help' -xa 'config get help home install list lookup lucky open outdated reset search seller signout uninstall update version' # editorconfig-checker-disable-line complete -c mas -n '__fish_seen_subcommand_from list outdated uninstall' -xa '(__fish_mas_list_installed)' -complete -c mas -n '__fish_seen_subcommand_from outdated update; and not __fish_contains_opt accurate; and not __fish_contains_opt inaccurate' -l accurate -d 'Accurate, slower outdated app detection' # editorconfig-checker-disable-line -complete -c mas -n '__fish_seen_subcommand_from outdated update; and not __fish_contains_opt accurate; and not __fish_contains_opt inaccurate' -l inaccurate -d 'Inaccurate, faster outdated app detection' # editorconfig-checker-disable-line -complete -c mas -n '__fish_seen_subcommand_from outdated update; and not __fish_contains_opt check-min-os; and not __fish_contains_opt no-check-min-os' -l check-min-os -d 'Check if macOS can install latest app version' # editorconfig-checker-disable-line -complete -c mas -n '__fish_seen_subcommand_from outdated update; and not __fish_contains_opt check-min-os; and not __fish_contains_opt no-check-min-os' -l no-check-min-os -d 'Do not check if macOS can install latest app version' # editorconfig-checker-disable-line -complete -c mas -n '__fish_seen_subcommand_from outdated update; and not __fish_contains_opt verbose' -l verbose -d 'Warn about app IDs unknown to the App Store' # editorconfig-checker-disable-line +complete -c mas -n '__fish_seen_subcommand_from outdated update upgrade; and not __fish_contains_opt accurate; and not __fish_contains_opt inaccurate' -l accurate -d 'Accurate, slower outdated app detection' # editorconfig-checker-disable-line +complete -c mas -n '__fish_seen_subcommand_from outdated update upgrade; and not __fish_contains_opt accurate; and not __fish_contains_opt inaccurate' -l inaccurate -d 'Inaccurate, faster outdated app detection' # editorconfig-checker-disable-line +complete -c mas -n '__fish_seen_subcommand_from outdated update upgrade; and not __fish_contains_opt check-min-os; and not __fish_contains_opt no-check-min-os' -l check-min-os -d 'Check if macOS can install latest app version' # editorconfig-checker-disable-line +complete -c mas -n '__fish_seen_subcommand_from outdated update upgrade; and not __fish_contains_opt check-min-os; and not __fish_contains_opt no-check-min-os' -l no-check-min-os -d 'Do not check if macOS can install latest app version' # editorconfig-checker-disable-line +complete -c mas -n '__fish_seen_subcommand_from outdated update upgrade; and not __fish_contains_opt verbose' -l verbose -d 'Warn about app IDs unknown to the App Store' # editorconfig-checker-disable-line complete -c mas -n '__fish_seen_subcommand_from search; and not __fish_contains_opt price' -l price -d 'Output the price of each app' # editorconfig-checker-disable-line complete -c mas -n '__fish_seen_subcommand_from uninstall; and not __fish_contains_opt all' -l all -d 'Uninstall all App Store apps' # editorconfig-checker-disable-line complete -c mas -n '__fish_seen_subcommand_from uninstall; and not __fish_contains_opt dry-run' -l dry-run -d 'Perform dry run' # editorconfig-checker-disable-line -complete -c mas -n '__fish_seen_subcommand_from update' -xa '(__fish_mas_outdated_installed)' +complete -c mas -n '__fish_seen_subcommand_from update upgrade' -xa '(__fish_mas_outdated_installed)'