The heart of AutoShelf is the rule engine. It is the thing that looks at a file and decides what to do with it. The condition evaluation logic lives in about 140 lines of Swift inside RuleEngine.swift.
Building a rule engine sounds complicated. In practice, it is a series of straightforward checks against file metadata. The complexity comes from the variety of metadata sources you have to pull from.
The 13 Conditions
AutoShelf supports 13 conditions in its MVP. Each condition checks one thing about a file:
anyItemmatches everything (useful for catch-all rules)fileTypechecks UTI conformance (not just extensions)fileExtensionchecks the exact extension stringfilenameContainsdoes a case-insensitive substring matchwhereFromContainschecks the download URL in extended attributesdownloadedBychecks which app downloaded the filedateAddedOlderThanchecks creation datelastModifiedOlderThanchecks modification datelastOpenedOlderThanchecks last opened datefileSizeLargerThanandfileSizeSmallerThancheck file sizefileIsHiddenchecks if the filename starts with a dottextContentContainschecks file contents via Spotlight (Pro only)
Every condition needs a different metadata source. Some come from FileManager, some from CoreServices, some from extended attributes.
UTI-Based File Type Detection
The most common condition is file type. Users want to organize images, documents, videos, and audio files into separate folders.
The naive approach is to check the file extension. .jpg is a JPEG, .png is a PNG, done. But extensions are unreliable. A file named photo.jpg could actually be a text file that someone renamed. And what about .jpeg versus .jpg? Or .tiff versus .tif?
macOS has a better system: Uniform Type Identifiers (UTIs). Every file type has a canonical UTI string. public.jpeg for JPEG images, public.png for PNG, com.adobe.pdf for PDFs. UTIs form a hierarchy, so you can check if a file conforms to public.image and catch every image format at once.
Here is how I check file types:
private func matchesFileType(_ type: String, fileURL: URL) -> Bool {
if let uti = UTType(filenameExtension: fileURL.pathExtension) {
return uti.conforms(to: UTType(type) ?? UTType(filenameExtension: type) ?? .data)
}
if let resourceValues = try? fileURL.resourceValues(forKeys: [.contentTypeKey]),
let contentType = resourceValues.contentType {
return contentType.conforms(to: UTType(type) ?? .data)
}
return false
}
First attempt: derive the UTI from the file extension using UTType(filenameExtension:). This is fast and covers most cases.
Fallback: read the contentType resource value from the file system. This gives the actual type as determined by the system, which may differ from the extension-based guess.
The conforms(to:) method is what makes UTIs powerful. Checking if com.compuserve.gif conforms to public.image returns true automatically, without having to list every image format.
Reading WhereFroms and Quarantine Data
Two conditions check extended attributes that macOS attaches to downloaded files. whereFromContains looks at the URL the file was downloaded from. downloadedBy looks at which application downloaded it.
These attributes are not accessible through the standard FileManager API. You have to use CoreServices or lower-level calls.
For WhereFroms, I use MDItem:
static func sourceURLs(for url: URL) -> [String] {
guard let item = MDItemCreateWithURL(kCFAllocatorDefault, url as CFURL) else { return [] }
guard let whereFroms = MDItemCopyAttribute(item, kMDItemWhereFroms) as? [String] else { return [] }
return whereFroms
}
kMDItemWhereFroms is a Spotlight metadata attribute. It stores an array of URLs representing the download source. Usually the first entry is the actual download URL and the second is the referring page.
For the downloading app, I have to read the quarantine extended attribute directly:
static func downloadingApp(for url: URL) -> String? {
let path = url.path
let length = getxattr(path, "com.apple.quarantine", nil, 0, 0, 0)
guard length > 0 else { return nil }
var buffer = [CChar](repeating: 0, count: length)
getxattr(path, "com.apple.quarantine", &buffer, length, 0, 0)
let value = String(cString: buffer)
// Skip the 32-byte header, parse key=value pairs
let body = String(value.dropFirst(32))
for pair in body.split(separator: ";") {
if pair.hasPrefix("app=") {
let appPath = String(pair.dropFirst(4))
return (appPath as NSString).lastPathComponent
}
}
return nil
}
The quarantine format is not well documented. I had to reverse-engineer it by downloading files with different browsers and inspecting the raw xattr bytes. It starts with a 32-byte header (the Gateway ID), followed by semicolon-separated key-value pairs. The app= field contains the full path of the application that downloaded the file.
Date Conditions and the MDItem Fallback
Checking file dates sounds simple. FileManager has attributes for creation date and modification date. But there is a catch: the last opened date is not stored in FileManager attributes. It is a Spotlight metadata attribute.
private func matchesLastOpened(_ days: Int, fileURL: URL) -> Bool {
if let item = MDItemCreateWithURL(kCFAllocatorDefault, fileURL as CFURL),
let lastUsed = MDItemCopyAttribute(item, kMDItemLastUsedDate) as? Date {
return lastUsed < Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date()
}
return false
}
File creation date also has a similar split. Most of the time, FileManager.creationDate is the date the file was added to the current filesystem. But for downloaded files, the creation date might be when the file was created on the server. The Spotlight attribute kMDItemDateAdded is when the file appeared on this specific machine.
I use both and prefer the more accurate one based on context.
The AND Logic
Each rule has a primary condition, plus additional conditions that are all AND-ed together. There is no OR support in the MVP. This was a deliberate simplification.
Here is the evaluation logic:
static func evaluate(rule: Rule, against fileURL: URL) -> Bool {
guard evaluate(condition: rule.condition, against: fileURL) != rule.isFirstConditionNegated else {
return false
}
for clause in rule.additionalConditions {
guard evaluate(condition: clause.condition, against: fileURL) != clause.isNegated else {
return false
}
}
return true
}
Each condition can be negated individually. So you can check "not an image" by negating the file type condition. This covers the common case where users want to organize "everything except images" into a catch-all folder.
I intentionally kept OR out of the MVP. OR conditions add combinatorial complexity to the UI and the engine. Users who need OR can usually express it with two separate rules.
Performance
Rule evaluation is fast. The slowest condition is whereFromContains, which reads from extended attributes and has disk I/O. Even that takes under a millisecond per file.
In practice, the bottleneck is never the rule engine. It is the file system enumeration and the file operation execution. Moving a 2GB video file takes seconds. Evaluating the condition takes microseconds.
The rule engine runs synchronously on the main actor, but it is fast enough that it does not block the UI. If I ever need to evaluate hundreds of rules against thousands of files at once, I would move it to a background queue and use async enumeration. But that day has not come yet.