Skip to content

installedapp: derive installed apps from _MASReceipt receipt instead of Spotlight#1266

Open
stepbrobd wants to merge 1 commit into
mas-cli:mainfrom
stepbrobd:macos26
Open

installedapp: derive installed apps from _MASReceipt receipt instead of Spotlight#1266
stepbrobd wants to merge 1 commit into
mas-cli:mainfrom
stepbrobd:macos26

Conversation

@stepbrobd

@stepbrobd stepbrobd commented Jun 12, 2026

Copy link
Copy Markdown

macOS 26 stopped indexing kMDItemAppStoreAdamID, so the NSMetadataQuery returned nothing and mas list/outdated were empty and install redownloaded. Read the adamID (ASN.1 attr 1), bundle id (2) and version (3) from each app's App Store-written Contents/_MASReceipt/receipt, and synthesize the JSON object from those fields so --json and the Scripts/mas columnar wrapper keep working

This works even after upgrading to macOS 27 beta

i've been getting the same error as described in #1249 on every nix-darwin system activation, this seem to work quite well in my fork

Please do note that i dont do swift dev, this is LLM assisted (feel free to close and take what you need, i've tested this on my macbook on nix-darwin with mas)

…of Spotlight

macOS 26 stopped indexing kMDItemAppStoreAdamID, so the NSMetadataQuery
returned nothing and mas list/outdated were empty and install redownloaded.
Read the adamID (ASN.1 attr 1), bundle id (2) and version (3) from each app's
App Store-written Contents/_MASReceipt/receipt, and synthesize the JSON object
from those fields so --json and the Scripts/mas columnar wrapper keep working.
Copilot AI review requested due to automatic review settings June 12, 2026 14:16
@sonarqubecloud

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR switches Mac App Store installed-app discovery from Spotlight metadata queries to parsing each app’s _MASReceipt/receipt, addressing cases where Spotlight no longer provides kMDItemAppStore* attributes.

Changes:

  • Replace NSMetadataQuery-based discovery with receipt enumeration under Applications folders.
  • Introduce a minimal DER walker to extract MAS receipt attributes (adamID, bundleID, version).
  • Update InstalledApp initialization to accept explicit fields and rebuild JSON output from a synthetic attribute map.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 275 to 281
func installedApps(withFullJSON: Bool = false) async throws -> [InstalledApp] {
try await installedApps(matching: "kMDItemAppStoreAdamID LIKE '*'", withFullJSON: withFullJSON)
installedAppsFromReceipts()
}

func installedApps(withADAMID adamID: ADAMID, withFullJSON: Bool = false) async throws -> [InstalledApp] {
try await installedApps(matching: "kMDItemAppStoreAdamID = \(adamID)", withFullJSON: withFullJSON)
installedAppsFromReceipts().filter { $0.adamID == adamID }
}
Comment on lines +287 to 297
private func installedAppsFromReceipts() -> [InstalledApp] {
applicationsFolderURLs
.flatMap(\.installedAppURLs) // swiftformat:disable:this indent
.compactMap { appURL in
guard
let receipt = try? Data(
contentsOf: appURL.appending(path: "Contents/_MASReceipt/receipt", directoryHint: .notDirectory),
)
else {
return InstalledApp?.none
}
Comment on lines +345 to +364
func walk(_ bytes: [UInt8]) {
var index = 0
while index < bytes.count {
let start = index
guard let node = DER.element(bytes, &index) else {
break
}
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
if node.tag == DER.sequence, let attribute = attribute(node.content) {
attributes[attribute.type] = attribute.value
}
if node.tag & DER.constructed != 0 {
walk(node.content) // descend into SEQUENCE / SET / context-tagged nodes
} else if node.tag == DER.octetString {
walk(node.content) // the eContent payload is wrapped in an OCTET STRING
}
if index <= start {
break
}
}
}
Comment on lines +430 to 436
static func unsignedInteger(_ bytes: [UInt8]) -> UInt64 {
var value: UInt64 = 0
for byte in bytes {
value = (value << 8) | UInt64(byte)
}
return value
}
Comment on lines +283 to +284
// macOS 26 (Tahoe) no longer indexes the kMDItemAppStore* Spotlight attributes (kMDItemAppStoreAdamID
// is null even for installed apps), so the previous NSMetadataQuery-based discovery returned nothing.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request replaces the Spotlight-based application discovery mechanism with a direct parsing of App Store receipts from application bundles using a custom DER PKCS#7 reader, addressing changes in macOS 26 (Tahoe) where App Store Spotlight attributes are no longer indexed. The review feedback suggests critical improvements to the custom DER parser, specifically to parse the receipt directly as Data to avoid unnecessary memory allocations and to target the eContent payload specifically to prevent potential attribute collisions with certificates or signatures.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +324 to 437
private func receiptAttributes(_ bytes: [UInt8]) -> [Int: [UInt8]] {
var attributes: [Int: [UInt8]] = [:]

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
func attribute(_ sequence: [UInt8]) -> (type: Int, value: [UInt8])? {
var index = 0
guard let typeElement = DER.element(sequence, &index), typeElement.tag == DER.integer else {
return nil
}
guard let versionElement = DER.element(sequence, &index), versionElement.tag == DER.integer else {
return nil
}
guard let valueElement = DER.element(sequence, &index), valueElement.tag == DER.octetString else {
return nil
}
guard index == sequence.count else {
return nil // exactly three elements
}
_ = versionElement
return (Int(DER.unsignedInteger(typeElement.content)), valueElement.content)
}

func walk(_ bytes: [UInt8]) {
var index = 0
while index < bytes.count {
let start = index
guard let node = DER.element(bytes, &index) else {
break
}
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
if node.tag == DER.sequence, let attribute = attribute(node.content) {
attributes[attribute.type] = attribute.value
}
if node.tag & DER.constructed != 0 {
walk(node.content) // descend into SEQUENCE / SET / context-tagged nodes
} else if node.tag == DER.octetString {
walk(node.content) // the eContent payload is wrapped in an OCTET STRING
}
if index <= start {
break
}
}
}

query.stop()

let installedApps = query.results
.compactMap { ($0 as? NSMetadataItem).map { InstalledApp(for: $0, withFullJSON: withFullJSON) } }
.sorted(using: KeyPathComparator(\.name, comparator: .localizedStandard))
walk(bytes)
return attributes
}

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,
"""
private func adamID(fromReceiptValue value: [UInt8]) -> ADAMID? {
var index = 0
guard let inner = DER.element(value, &index), inner.tag == DER.integer else {
return nil
}
return DER.unsignedInteger(inner.content)
}

private func string(fromReceiptValue value: [UInt8]) -> String? {
var index = 0
guard
let inner = DER.element(value, &index),
inner.tag == DER.utf8String || inner.tag == DER.ia5String || inner.tag == DER.printableString
else {
return nil
}
return String(bytes: inner.content, encoding: .utf8)
}

Indexing now; will likely complete sometime after mas exits
// Minimal DER reader supporting definite-length encodings (which is all DER uses).
private enum DER {
static let integer: UInt8 = 0x02
static let octetString: UInt8 = 0x04
static let utf8String: UInt8 = 0x0C
static let printableString: UInt8 = 0x13
static let ia5String: UInt8 = 0x16
static let sequence: UInt8 = 0x30
static let constructed: UInt8 = 0x20

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)
}
}
}
// Parse one tag-length-value element starting at `index`, advancing `index` past it.
static func element(_ bytes: [UInt8], _ index: inout Int) -> (tag: UInt8, content: [UInt8])? {
guard index < bytes.count else {
return nil
}
let tag = bytes[index]
index += 1
guard index < bytes.count else {
return nil
}
var length = Int(bytes[index])
index += 1
if length & 0x80 != 0 {
let byteCount = length & 0x7F
guard byteCount >= 1, byteCount <= 4, index + byteCount <= bytes.count else {
return nil
}

continuation.resume(returning: installedApps)
length = 0
for _ in 0 ..< byteCount {
length = (length << 8) | Int(bytes[index])
index += 1
}
}
guard length >= 0, index + length <= bytes.count else {
return nil
}
let content = Array(bytes[index ..< (index + length)])
index += length
return (tag, content)
}

query.start()
static func unsignedInteger(_ bytes: [UInt8]) -> UInt64 {
var value: UInt64 = 0
for byte in bytes {
value = (value << 8) | UInt64(byte)
}
return value
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Correctness & Performance Optimization

  1. Avoid Certificate/Signature Collisions (Correctness): The current implementation recursively walks the entire PKCS#7 container (including certificates, CRLs, and signer infos). If any certificate contains a sequence matching the receipt attribute pattern (e.g., in custom extensions), it can overwrite the actual receipt's adamID, bundleID, or version with garbage or certificate values. We should locate the eContent payload (OID 1.2.840.113549.1.7.1) and only parse that payload.
  2. Avoid Memory Allocations (Performance): Converting Data to [UInt8] and slicing it via Array(...) creates many unnecessary allocations and copies. Using Data directly with its native slicing capabilities avoids all copies since Data slices share the same underlying storage.
private func receiptAttributes(_ receipt: Data) -> [Int: Data] {
	var attributes: [Int: Data] = [:]

	func attribute(_ sequence: Data) -> (type: Int, value: Data)? {
		var index = sequence.startIndex
		guard let typeElement = DER.element(sequence, &index), typeElement.tag == DER.integer else {
			return nil
		}
		guard let versionElement = DER.element(sequence, &index), versionElement.tag == DER.integer else {
			return nil
		}
		guard let valueElement = DER.element(sequence, &index), valueElement.tag == DER.octetString else {
			return nil
		}
		guard index == sequence.endIndex else {
			return nil // exactly three elements
		}
		_ = versionElement
		return (Int(DER.unsignedInteger(typeElement.content)), valueElement.content)
	}

	func walk(_ data: Data) {
		var index = data.startIndex
		while index < data.endIndex {
			let start = index
			guard let node = DER.element(data, &index) else {
				break
			}
			if node.tag == DER.sequence, let attribute = attribute(node.content) {
				attributes[attribute.type] = attribute.value
			} else if node.tag & DER.constructed != 0 {
				walk(node.content) // descend into SEQUENCE / SET / context-tagged nodes
			} else if node.tag == DER.octetString {
				walk(node.content) // the eContent payload is wrapped in an OCTET STRING
			}
			if index <= start {
				break
			}
		}
	}

	// Find the eContent payload (the actual receipt SET) inside the PKCS#7 container
	let oid = Data([0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x01])
	guard let range = receipt.firstRange(of: oid) else {
		return [:]
	}
	var index = range.upperBound
	guard let container = DER.element(receipt, &index), container.tag == 0xA0 else {
		return [:]
	}
	var innerIndex = container.content.startIndex
	guard let octetString = DER.element(container.content, &innerIndex), octetString.tag == DER.octetString else {
		return [:]
	}

	walk(octetString.content)
	return attributes
}

private func adamID(fromReceiptValue value: Data) -> ADAMID? {
	var index = value.startIndex
	guard let inner = DER.element(value, &index), inner.tag == DER.integer else {
		return nil
	}
	return DER.unsignedInteger(inner.content)
}

private func string(fromReceiptValue value: Data) -> String? {
	var index = value.startIndex
	guard
		let inner = DER.element(value, &index),
		inner.tag == DER.utf8String || inner.tag == DER.ia5String || inner.tag == DER.printableString
	else {
		return nil
	}
	return String(bytes: inner.content, encoding: .utf8)
}

// Minimal DER reader supporting definite-length encodings (which is all DER uses).
private enum DER {
	static let integer: UInt8 = 0x02
	static let octetString: UInt8 = 0x04
	static let utf8String: UInt8 = 0x0C
	static let printableString: UInt8 = 0x13
	static let ia5String: UInt8 = 0x16
	static let sequence: UInt8 = 0x30
	static let constructed: UInt8 = 0x20

	// Parse one tag-length-value element starting at `index`, advancing `index` past it.
	static func element(_ data: Data, _ index: inout Data.Index) -> (tag: UInt8, content: Data)? {
		guard index < data.endIndex else {
			return nil
		}
		let tag = data[index]
		index = data.index(after: index)
		guard index < data.endIndex else {
			return nil
		}
		var length = Int(data[index])
		index = data.index(after: index)
		if length & 0x80 != 0 {
			let byteCount = length & 0x7F
			guard byteCount >= 1, byteCount <= 4, data.index(index, offsetBy: byteCount, limitedBy: data.endIndex) != nil else {
				return nil
			}
			length = 0
			for _ in 0 ..< byteCount {
				length = (length << 8) | Int(data[index])
				index = data.index(after: index)
			}
		}
		guard length >= 0, let nextIndex = data.index(index, offsetBy: length, limitedBy: data.endIndex) else {
			return nil
		}
		let content = data[index ..< nextIndex]
		index = nextIndex
		return (tag, content)
	}

	static func unsignedInteger(_ data: Data) -> UInt64 {
		var value: UInt64 = 0
		for byte in data {
			value = (value << 8) | UInt64(byte)
		}
		return value
	}
}

return InstalledApp?.none
}

let attributes = receiptAttributes([UInt8](receipt))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since we can parse the receipt directly as Data without copying it to a [UInt8] array, we should pass receipt directly to receiptAttributes.

Suggested change
let attributes = receiptAttributes([UInt8](receipt))
let attributes = receiptAttributes(receipt)

@rgoldberg rgoldberg self-assigned this Jun 12, 2026
@rgoldberg

Copy link
Copy Markdown
Member

Spotlight still works for mas for me (baring a now-fixed inclusion of a single extra app; #1249 excluded a single app for its reporter). Hopefully, such seemingly transient problems will be fixed by Apple, especially given the macOS 27 Spotlight overhaul.

Have you tried reindexing all Spotlight data, restarting your Mac, or signing out & back into your Apple Account in the App Store? (I doubt signing out & in would fix Spotlight, but it did fix an App Store, not Spotlight, problem for someone else, and it's quick if the other suggestions don't solve everything)

Can you create a new issue where we can continue this discussion?

Can you provide mas config output for all Macs that you test for this, both for Macs that experience or don't experience this problem?

What versions of macOS & mas were the last that you know did not have this Spotlight problem?

What versions were the first that you know had this Spotlight problem?

Can you confirm that Spotlight does not work for mas 7.0.0 in macOS 27 beta for you?

Are you connected to the FR App Store? If so, maybe your problem is caused by Apple dealing with EU laws…

Do you know any other mas users with the same problem?

Since Spotlight still mainly works for me, and I think you're the only reporter of a persistent complete lack of kMDItemAppStoreAdamID in Spotlight, I'd prefer to not move from a public interface to yet another brittle undocumented private interface unless absolutely necessary.

A better workaround would probably be: mas could use the presence of Contents/_MASReceipt/receipt to consider an app from the App Store, then use the bundle ID from Spotlight in all possible places. Where necessary, mas would query the iTunes Search web API via the bundle ID to obtain the associated ADAM ID.

If you are good at getting AI to investigate undocumented things like the receipt format, could you try having the AI research / implement using AppleMediaServices (or some other Apple macOS private framework) instead of using StoreFoundation / CommerceKit (see #1253). I haven't had the chance to investigate that much, or use AI for deep investigation…

That would be helpful for many things, including possibly using bundle ID instead of ADAM ID everywhere, so my workaround for this issue would not need to query the iTunes Search web API, which would be faster at runtime & would be more resilient against Apple services being down, having inconsistent or outdated data, or being changed.

Thanks.

@stepbrobd

stepbrobd commented Jun 12, 2026

Copy link
Copy Markdown
Author

Thanks for the fast reply!

Have you tried reindexing all Spotlight data, restarting your Mac, or signing out & back into your Apple Account in the App Store? (I doubt signing out & in would fix Spotlight, but it did fix an App Store, not Spotlight, problem for someone else, and it's quick if the other suggestions don't solve everything)

Yep, even enabled on disks that don't even needed to be enabled

Can you provide mas config output

I'm currently on my fork:

$ mas config
mas ▁▁▁▁ 7.0.0-receipt
slice ▁▁ arm64
slices ▁ arm64
dist ▁▁▁ unknown
origin ▁ git@github.com:stepbrobd/mas
rev ▁▁▁▁ 432cd2eaa5b8104b7ec135d8c3b8990abcfcfe5e
swift ▁▁ 6.3.2 (swiftlang-6.3.2.1.108 clang-2100.1.1.101)
driver ▁ 1.148.6
store ▁▁ US
region ▁ US
macos ▁▁ 27.0 (26A5353q)
mac ▁▁▁▁ Mac14,5
cpu ▁▁▁▁ Apple M2 Max
arch ▁▁▁ arm64

What versions of macOS & mas were the last that you know did not have this Spotlight problem?
What versions were the first that you know had this Spotlight problem?

I don't quite remember this, but likely since the last few macOS 26 developer beta

Can you confirm that Spotlight does not work for mas 7.0.0 in macOS 27 beta for you?

to test this i have to drop my forked mac's overlay, i'll probably test this over the weekend

Are you connected to the FR App Store? If so, maybe your problem is caused by Apple dealing with EU laws…

I'm on US app store

Thanks for the few other tips, will take a look

@rgoldberg

Copy link
Copy Markdown
Member

Thanks for all the info.

Have you experienced this problem on any macOS stable versions, or only on prerelease versions?

I don't have a Mac on which I can run a prerelease macOS, even in a container.

Once you gather more info, we can investigate further.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants