installedapp: derive installed apps from _MASReceipt receipt instead of Spotlight#1266
installedapp: derive installed apps from _MASReceipt receipt instead of Spotlight#1266stepbrobd wants to merge 1 commit into
Conversation
…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.
|
There was a problem hiding this comment.
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
InstalledAppinitialization 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.
| 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 } | ||
| } |
| 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 | ||
| } |
| 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 | ||
| } | ||
| } | ||
| } |
| static func unsignedInteger(_ bytes: [UInt8]) -> UInt64 { | ||
| var value: UInt64 = 0 | ||
| for byte in bytes { | ||
| value = (value << 8) | UInt64(byte) | ||
| } | ||
| return value | ||
| } |
| // 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. |
There was a problem hiding this comment.
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.
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
Correctness & Performance Optimization
- 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, orversionwith garbage or certificate values. We should locate theeContentpayload (OID1.2.840.113549.1.7.1) and only parse that payload. - Avoid Memory Allocations (Performance): Converting
Datato[UInt8]and slicing it viaArray(...)creates many unnecessary allocations and copies. UsingDatadirectly with its native slicing capabilities avoids all copies sinceDataslices 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)) |
|
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 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 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. |
|
Thanks for the fast reply!
Yep, even enabled on disks that don't even needed to be enabled
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
I don't quite remember this, but likely since the last few macOS 26 developer beta
to test this i have to drop my forked mac's overlay, i'll probably test this over the weekend
I'm on US app store Thanks for the few other tips, will take a look |
|
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. |



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)