Shipping an app to the Mac App Store is not just about the code. It is about the whole package: the onboarding experience, the templates that show users what is possible, the import/export system for power users, and the migration path when data formats change.
This post covers the things I built after the core engine was working. The polish layer that turns a functional app into a shippable product.
The Built-In Templates
AutoShelf ships with 9 built-in templates. Each template is a pre-configured set of rules that users can apply to their folders with a few clicks.
struct RuleTemplate: Identifiable {
let id: UUID
let nameKey: String
let descriptionKey: String
let iconName: String
let rules: [TemplateRule]
}
struct TemplateRule {
let nameKey: String
let condition: RuleCondition
let action: RuleAction
let destinationSubfolder: String?
}
The templates are:
- Organize Downloads - Sort images, PDFs, videos, archives, and code into separate subfolders
- Auto-delete DMGs - Trash .dmg files after 1 day
- Clean Desktop - Archive files older than 7 days
- Sort by Source - Route GitHub downloads to Developer/ and Slack downloads to Work/
- Auto-trash Installers - Trash .dmg, .pkg, .mpkg after 3 days
- Organize Screenshots - Move Screenshot/Screen Shot files to a subfolder
- Tag Large Files - Tag files over 100MB with a "Large" tag
- Sort Music - Move audio files to a subfolder
- Auto-trash ZIPs - Trash .zip, .rar, .7z files
Each template is defined as a struct, not a JSON file. This means they are compiled into the binary and cannot be accidentally deleted or corrupted. It also means they update when the app updates.
When a user picks a template, the TemplateManager converts it into real rules:
func createRules(from template: RuleTemplate, for folder: URL) -> [Rule] {
let bookmarkData = try? folder.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
return template.rules.map { templateRule in
let action: RuleAction
if let subfolder = templateRule.destinationSubfolder {
let subfolderURL = folder.appendingPathComponent(subfolder)
try? FileManager.default.createDirectory(at: subfolderURL, withIntermediateDirectories: true)
let subBookmark = try? subfolderURL.bookmarkData(...)
action = resolveAction(templateRule.action, with: subBookmark)
} else {
action = templateRule.action
}
return Rule(
name: templateRule.nameKey,
folderBookmarkData: bookmarkData,
condition: templateRule.condition,
actions: [action]
)
}
}
The destination subfolders (like "Sorted" or "Archives") are created inside the watched folder. The bookmark data is saved so the app can access them after restart.
Templates are gated behind the Pro purchase. Free users can see them in the Templates tab, but they are locked with a PRO badge. This gives users a clear reason to upgrade.
Import and Export
Power users want to share rule configurations. The import/export system serializes rules and groups into a JSON file with a .autoshelf extension.
struct ExportedData: Codable {
let version: Int
let exportedAt: Date
let rules: [Rule]
let groups: [RuleGroup]
}
The export encodes rules and groups, including their bookmark data. When the user imports, the bookmarks are validated. If a bookmark is stale (the folder was moved or deleted), the app asks the user to re-select the folder.
func importRules(from url: URL) throws -> ImportResult {
let data = try Data(contentsOf: url)
let exported = try JSONDecoder().decode(ExportedData.self, from: data)
var freshRules: [Rule] = []
var staleRules: [Rule] = []
for rule in exported.rules {
if validateBookmark(rule.folderBookmarkData) {
freshRules.append(rule)
} else {
staleRules.append(rule)
}
}
return ImportResult(rules: freshRules, staleRules: staleRules, groups: exported.groups)
}
The import result tells the UI which rules were imported cleanly and which need user attention. The UI shows a list of stale rules and offers a folder picker for each one.
I registered the .autoshelf file type with the system so users can double-click an exported rule file to open it in AutoShelf. This uses the standard macOS document opening flow.
The Migration Problem
As the app evolves, the rule data format changes. New condition types are added. Old ones are renamed. The action system grew from single-action to multi-action. Each change needs a migration path.
The Rule struct has a custom Codable implementation that handles these migrations:
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Migration: "anyFile" was renamed to "anyItem" in 2.0
if let conditionString = try? container.decode(String.self, forKey: .conditionType),
conditionString == "anyFile" {
condition = .anyItem
} else {
condition = try container.decode(RuleCondition.self, forKey: .condition)
}
// Migration: "action" became "actions" (array) in 2.0
if let singleAction = try? container.decode(RuleAction.self, forKey: .action) {
actions = [singleAction]
} else {
actions = try container.decode([RuleAction].self, forKey: .actions)
}
}
The RuleStore also has a migration pass that runs on app startup. It reads the raw JSON, checks for legacy fields, and rewrites the file in the new format. This ensures that users who skip several versions still get working data.
private func decodeWithMigration(_ data: Data, url: URL) throws -> [Rule] {
guard var json = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
throw DecodingError.dataCorrupted(...)
}
var didMigrate = false
for (index, var ruleDict) in json.enumerated() {
if ruleDict["conditionType"] as? String == "anyFile" {
ruleDict["conditionType"] = "anyItem"
json[index] = ruleDict
didMigrate = true
}
}
if didMigrate {
let migratedData = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
try migratedData.write(to: url)
}
return try JSONDecoder().decode([Rule].self, from: data)
}
This approach is not elegant, but it works. The migration runs silently on launch and users never notice it happened.
The 19-Language Localization
AutoShelf ships with 19 languages. English is the base, and the other 18 are machine-translated through Xcode's string catalog system.
The localization covers all user-facing strings: rule conditions, action names, settings labels, notification text, and the App Store description. It does not cover blog posts, documentation, or error logs.
SwiftUI's string catalog makes localization straightforward. Every Text("key") call is automatically extracted into the catalog. The translator fills in the localizations, and the app adapts at runtime based on the system language.
Users can also override the language in Settings, which sets AppleLanguages in UserDefaults. This is useful for bilingual users who want the app in a different language than their system.
Shipping to the Mac App Store
The App Store submission process has its own challenges. Sandbox validation rejects apps that try to access files without proper entitlements. The com.apple.security.files.user-selected.read-write entitlement is required for folder access, and the photos library entitlement must exactly match Apple's documented key.
I had a typo in the photos library entitlement in an early build. The key is com.apple.security.personal-information.photos-library, and I had it slightly wrong. The build compiled fine, passed local testing, but was rejected by App Store validation. It took me a day to find and fix the typo.
The build number (CURRENT_PROJECT_VERSION) increments with every App Store submission. The marketing version (MARKETING_VERSION) bumps with feature releases. Version 2.0.1 is build 18 as of this writing.
What I Learned About Shipping
The templates are the highest-engagement feature in the app. New users who open the app and see nothing configured often close it and never come back. New users who apply a template and watch their Downloads folder organize themselves in real time become paying customers.
The import/export system is used by a small fraction of users, but those users are the most engaged. They are setting up AutoShelf on multiple machines or sharing configurations with colleagues.
The migration system has saved me multiple times. Every time I refactored the data model, I was glad I built the migration pass before I needed it. Data migration is one of those things that is easy to add early and painful to add later.
Localization 18 languages was a weekend of work with Xcode's string catalog. The translations are not perfect, but they are good enough for users to understand the app. I have gotten positive feedback from non-English users specifically mentioning the localization.
Shipping is not the end. It is the beginning of a cycle: users file bugs, I fix them, users request features, I build them, the data model changes, migration runs, and the cycle continues. The templates, import/export, and migration systems are what make this cycle sustainable.