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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions Sources/mas/Commands/Ignore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//
// Ignore.swift
// mas
//
// Copyright © 2025 mas-cli. All rights reserved.
//

internal import ArgumentParser
private import Foundation

extension MAS {
struct Ignore: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Manage ignored apps and versions",
subcommands: [Add.self, Remove.self, List.self, Clear.self]
)
}
}

private func promptYesNo(_ message: String) -> Bool {
MAS.printer.info(message, terminator: " ")
guard let response = readLine() else {
return false
}

let normalized = response.trimmingCharacters(in: .whitespaces).lowercased()
// Default to "yes" if user just presses ENTER
return normalized.isEmpty || normalized == "y" || normalized == "yes"
}

extension MAS.Ignore {
struct Add: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Add an app or app version to the ignore list"
)

@Argument(help: "App ID to ignore")
var adamID: ADAMID

@Option(name: .shortAndLong, help: "Specific version to ignore (optional)")
var version: String?

@Flag(name: .shortAndLong, help: "Skip confirmation prompts")
var yes = false

func run() async throws {
let cleanedVersion = version?.trimmingCharacters(in: CharacterSet(charactersIn: "()"))
let ignoreList = IgnoreList.shared

// Case 1: User wants to add a specific version, but all versions are already ignored
if let cleanedVersion, await ignoreList.hasAllVersionsIgnore(adamID: adamID) {
MAS.printer.warning(
"App \(adamID) already has all versions ignored."
)
if yes || promptYesNo("Replace with version-specific ignore for \(cleanedVersion)? [Y/n]:") {
// Remove the all-versions entry
try await ignoreList.remove(IgnoreEntry(adamID: adamID, version: nil))
// Add the specific version entry
let entry = IgnoreEntry(adamID: adamID, version: cleanedVersion)
try await ignoreList.add(entry)
MAS.printer.info("Replaced all-versions ignore with version-specific ignore for \(cleanedVersion)")
} else {
MAS.printer.info("Keeping existing all-versions ignore")
}
return
}

// Case 2: User wants to ignore all versions, but specific versions are already ignored
if cleanedVersion == nil, await ignoreList.hasSpecificVersionIgnores(adamID: adamID) {
let versionsList = await ignoreList.entriesFor(adamID: adamID)
.compactMap(\.version)
.sorted()
.joined(separator: ", ")
MAS.printer.warning(
"App \(adamID) already has specific version(s) ignored: \(versionsList)"
)
if yes || promptYesNo("Replace with all-versions ignore? [Y/n]:") {
// Remove all existing entries for this adamID
try await ignoreList.removeAll(forADAMID: adamID)
// Add the all-versions entry
let entry = IgnoreEntry(adamID: adamID, version: nil)
try await ignoreList.add(entry)
MAS.printer.info("Replaced version-specific ignores with all-versions ignore")
} else {
MAS.printer.info("Keeping existing version-specific ignores")
}
return
}

// Normal case: no conflicts
let entry = IgnoreEntry(adamID: adamID, version: cleanedVersion)
try await ignoreList.add(entry)

if let cleanedVersion {
MAS.printer.info("Ignoring \(adamID) version \(cleanedVersion)")
} else {
MAS.printer.info("Ignoring all versions of \(adamID)")
}
}
}

struct Remove: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Remove an app or app version from the ignore list"
)

@Argument(help: "App ID to stop ignoring")
var adamID: ADAMID

@Option(name: .shortAndLong, help: "Specific version to stop ignoring (optional)")
var version: String?

@Flag(name: .shortAndLong, help: "Remove all ignore entries for this app ID")
var all = false

func run() async throws {
if all {
try await IgnoreList.shared.removeAll(forADAMID: adamID)
MAS.printer.info("Removed all ignore entries for \(adamID)")
} else {
let cleanedVersion = version?.trimmingCharacters(in: CharacterSet(charactersIn: "()"))
let entry = IgnoreEntry(adamID: adamID, version: cleanedVersion)
try await IgnoreList.shared.remove(entry)

if let cleanedVersion {
MAS.printer.info("No longer ignoring \(adamID) version \(cleanedVersion)")
} else {
MAS.printer.info("No longer ignoring all versions of \(adamID)")
}
}
}
}

struct List: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "List all ignored apps and versions"
)

func run() async {
let entries = await IgnoreList.shared.all()

guard !entries.isEmpty else {
MAS.printer.info("No ignored apps")
return
}

let maxADAMIDLength = entries.map { String(describing: $0.adamID).count }.max() ?? 0
let format = "%\(maxADAMIDLength)lu %@"

MAS.printer.info(
entries.map { entry in
String(
format: format,
entry.adamID,
entry.version ?? "(all versions)"
)
}
.joined(separator: "\n")
)
}
}

struct Clear: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Clear all ignored apps and versions"
)

func run() async throws {
let entries = await IgnoreList.shared.all()
for entry in entries {
try await IgnoreList.shared.remove(entry)
}
MAS.printer.info("Cleared all ignore entries")
}
}
}
1 change: 1 addition & 0 deletions Sources/mas/Commands/MAS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct MAS: AsyncParsableCommand, Sendable {
Config.self,
Get.self,
Home.self,
Ignore.self,
Install.self,
List.self,
Lookup.self,
Expand Down
49 changes: 37 additions & 12 deletions Sources/mas/Commands/OutdatedAppCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ extension OutdatedAppCommand { // swiftlint:disable:this file_types_order
await withTaskGroup(of: OutdatedApp?.self, returning: [OutdatedApp].self) { group in
let installedApps = await installedApps
.filter(by: optionalAppIDsOptionGroup) // swiftformat:disable indent
.filterOutIgnoredApps()
.filterOutApps(
unknownTo: appCatalog,
if: shouldIgnoreUnknownApps,
Expand Down Expand Up @@ -70,9 +71,10 @@ extension OutdatedAppCommand { // swiftlint:disable:this file_types_order
},
inaccurate: {
await installedApps
.filter(by: optionalAppIDsOptionGroup) // swiftformat:disable:this indent
.outdated(appCatalog: appCatalog, shouldWarnIfUnknownApp: verboseOptionGroup.verbose)
} // swiftformat:disable:previous indent
.filter(by: optionalAppIDsOptionGroup)
.filterOutIgnoredApps()
.outdated(appCatalog: appCatalog, shouldWarnIfUnknownApp: verboseOptionGroup.verbose)
}
)
}
}
Expand All @@ -85,18 +87,26 @@ typealias OutdatedApp = (
private extension InstalledApp {
var outdated: OutdatedApp? {
get async {
await withCheckedContinuation { continuation in
let ignoreList = IgnoreList.shared
if await ignoreList.isIgnored(adamID: adamID) {
return nil
}

return await withCheckedContinuation { continuation in
Task {
let alreadyResumed = ManagedAtomic(false)
do {
try await AppStore.install.app(withADAMID: adamID) { appStoreVersion, shouldOutput in
if
shouldOutput,
let appStoreVersion,
version != appStoreVersion,
!alreadyResumed.exchange(true, ordering: .acquiringAndReleasing)
{
continuation.resume(returning: OutdatedApp(self, appStoreVersion))
Task {
if
shouldOutput,
let appStoreVersion,
version != appStoreVersion,
!alreadyResumed.exchange(true, ordering: .acquiringAndReleasing),
!(await ignoreList.isIgnored(adamID: adamID, version: appStoreVersion))
{
continuation.resume(returning: OutdatedApp(self, appStoreVersion))
}
}
return true
}
Expand Down Expand Up @@ -163,10 +173,14 @@ private extension [InstalledApp] {
}

func outdated(appCatalog: some AppCatalog, shouldWarnIfUnknownApp: Bool) async -> [OutdatedApp] {
await compactMap { installedApp in
let ignoreList = IgnoreList.shared
return await compactMap { installedApp in
do {
let catalogApp = try await appCatalog.lookup(appID: .adamID(installedApp.adamID))
if installedApp.isOutdated(comparedTo: catalogApp) {
if await ignoreList.isIgnored(adamID: installedApp.adamID, version: catalogApp.version) {
return nil
}
return OutdatedApp(installedApp, catalogApp.version)
}
} catch {
Expand All @@ -177,6 +191,17 @@ private extension [InstalledApp] {
}
}

private extension [InstalledApp] {
func filterOutIgnoredApps() async -> Self {
let ignoreList = IgnoreList.shared
var filtered = [InstalledApp]()
for app in self where !(await ignoreList.isIgnored(adamID: app.adamID)) {
filtered.append(app)
}
return filtered
}
}

private extension Error {
func print(forExpectedAppName appName: String, shouldWarnIfUnknownApp: Bool) {
guard let error = self as? MASError, case MASError.unknownAppID = error else {
Expand Down
80 changes: 80 additions & 0 deletions Sources/mas/Controllers/IgnoreList.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// IgnoreList.swift
// mas
//
// Copyright © 2025 mas-cli. All rights reserved.
//

private import Foundation

actor IgnoreList {
static let shared = IgnoreList()

private var entries = Set<IgnoreEntry>()
private let fileURL: URL

private init() {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let masDirectory = appSupport.appendingPathComponent("mas", isDirectory: true)
fileURL = masDirectory.appendingPathComponent("ignore.json")

try? FileManager.default.createDirectory(at: masDirectory, withIntermediateDirectories: true)

if let data = try? Data(contentsOf: fileURL) {
entries = (try? JSONDecoder().decode(Set<IgnoreEntry>.self, from: data)) ?? []
}
}

func add(_ entry: IgnoreEntry) throws {
entries.insert(entry)
try save()
}

func remove(_ entry: IgnoreEntry) throws {
entries.remove(entry)
try save()
}

func removeAll(forADAMID adamID: ADAMID) throws {
entries = entries.filter { $0.adamID != adamID }
try save()
}

func isIgnored(adamID: ADAMID, version: String) -> Bool {
entries.contains { $0.matches(adamID: adamID, version: version) }
}

func isIgnored(adamID: ADAMID) -> Bool {
entries.contains { $0.adamID == adamID && $0.version == nil }
}

func all() -> [IgnoreEntry] {
Array(entries).sorted { lhs, rhs in
if lhs.adamID != rhs.adamID {
return lhs.adamID < rhs.adamID
}
guard let lhsVersion = lhs.version, let rhsVersion = rhs.version else {
return lhs.version == nil
}

return lhsVersion < rhsVersion
}
}

func entriesFor(adamID: ADAMID) -> [IgnoreEntry] {
entries.filter { $0.adamID == adamID }
}

func hasAllVersionsIgnore(adamID: ADAMID) -> Bool {
entries.contains { $0.adamID == adamID && $0.version == nil }
}

func hasSpecificVersionIgnores(adamID: ADAMID) -> Bool {
entries.contains { $0.adamID == adamID && $0.version != nil }
}

private func save() throws {
let data = try JSONEncoder().encode(entries)
try data.write(to: fileURL, options: .atomic)
}
}
24 changes: 24 additions & 0 deletions Sources/mas/Models/IgnoreEntry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// IgnoreEntry.swift
// mas
//
// Copyright © 2025 mas-cli. All rights reserved.
//

struct IgnoreEntry: Codable, Hashable, Sendable {
let adamID: ADAMID
let version: String?

init(adamID: ADAMID, version: String? = nil) {
self.adamID = adamID
self.version = version
}

func matches(adamID: ADAMID, version: String) -> Bool {
guard self.adamID == adamID else {
return false
}

return self.version == nil || self.version == version
}
}
Loading