Plan: iCloud Drive as the default folder

Status: planned 2026-09-06, blocked on the holder’s iCloud materials. Tracked in the backlog under Integrations.

Context

The default folder on iOS is the app container’s Documents/Minuta. Since 2026-09-06 it is visible in the Files app (On My iPhone > Minuta) through UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace, but it is local to the device: it never appears in iCloud Drive and nothing syncs it. People who install Minuta on a phone expect the default folder to be in iCloud and shared with their other devices.

Minuta’s sync model is “any folder sync service”. iCloud Drive is the folder sync every Apple device already has, so an iCloud ubiquity container as the default folder gives zero-config sync between iPhone, iPad and Mac without any server code and without breaking the “no network requests” rule: the system syncs the files, the app only reads and writes them.

Goal: when iCloud Drive is available, the default folder is iCloud Drive/Minuta (the app’s public ubiquity container), visible in Files and Finder and synced by the system. When iCloud is unavailable (not signed in, iCloud Drive off, or a build without the entitlement such as the Homebrew/personal-team build) the default stays the local Documents/Minuta. Folders the user picked explicitly are untouched.

Needed from the Account Holder (blocks testing on devices)

Add an “iCloud” section to TESTFLIGHT-HOLDER.md:

  1. Identifiers > App ID tools.minuta.app > enable the iCloud capability; create the container iCloud.tools.minuta.app and assign it. CloudKit is not needed (documents only). The widget App ID does not need iCloud.
  2. Profiles: a capability change invalidates tools.minuta.app AppStore (and later tools.minuta.app MacAppStore). Regenerate and resend the .mobileprovision / .provisionprofile. Check with security cms -D -i <profile> | plutil -extract Entitlements xml1 -o - - that com.apple.developer.ubiquity-container-identifiers is present.
  3. Strongly recommended: an Apple Development certificate (.p12, password through a separate channel) plus an iOS App Development profile tools.minuta.app Development with Ramil’s device UDIDs. iCloud cannot be used on the free personal team UKMJ95C96B, so without a development profile every iteration goes through TestFlight.

Project changes

  1. Entitlements: Minuta/TestFlight/Minuta.entitlements (TestFlight/App Store only; the committed Debug entitlements stay empty) and the planned Minuta.macOS.entitlements from 917-plan-mac-testflight, so the Mac App Store build defaults to the same container:

    • com.apple.developer.icloud-container-identifiers = [iCloud.tools.minuta.app]
    • com.apple.developer.icloud-services = [CloudDocuments]
    • com.apple.developer.ubiquity-container-identifiers = [iCloud.tools.minuta.app]

    For local development with the holder’s development profile: an untracked Minuta/Development.xcconfig mirroring TestFlight.xcconfig (manual signing, Apple Development identity, the Development profile, same entitlements file).

  2. Minuta/Sources/Info.plist, NSUbiquitousContainers:

    iCloud.tools.minuta.app
      NSUbiquitousContainerIsDocumentScopePublic = true
      NSUbiquitousContainerSupportedFolderLevels = Any
      NSUbiquitousContainerName = Minuta

    The public scope is what makes the container appear as an iCloud Drive/Minuta folder in Files and Finder. iCloud caches these values per CFBundleVersion; change them only together with a build-number bump.

  3. Default location resolution (Minuta/Sources/Services/StorageLocationManager.swift):

    • New UbiquityContainerResolver (app target): calls FileManager.default.url(forUbiquityContainerIdentifier: nil) off the main thread (the first call can take seconds and creates the container), caches the result, returns nil when iCloud is unavailable.
    • Data root is <container>/Documents directly, no Minuta subfolder: the container is already shown as Minuta in iCloud Drive, and Minuta/Minuta would be redundant.
    • storageURL is synchronous and read during init, so resolution runs once at launch before AppState builds the storage service (the app initialization is already async). Until resolved the app does not touch the default folder. StorageSetupView shows “Default: iCloud Drive/Minuta” or “Default: Documents/Minuta” depending on the result.
    • Observe NSUbiquityIdentityDidChange (sign in/out) and re-resolve; AppState.reinitializeStorage() reloads.
  4. Files that are not downloaded yet. In a ubiquity container a file that exists in iCloud but not on this device is a placeholder named .<name>.icloud. AutomergeStorageService enumerates with contentsOfDirectory and would skip them. Changes in Shared/Sources/MinutaShared/Services/AutomergeStorageService.swift:

    • Map .<name>.icloud to <name> when listing.
    • Call startDownloadingUbiquitousItem(at:) for placeholders and wait for URLResourceKey.ubiquitousItemDownloadingStatusKey == .current before reading.
    • New UbiquitousStorageMonitor (app target) with an NSMetadataQuery on NSMetadataQueryUbiquitousDocumentsScope: keeps everything downloaded (the data is tiny; never evict) and feeds ExternalChangeMonitor from the query’s update notification instead of 5-second mtime polling when the storage folder is ubiquitous.
  5. Coordinated I/O. Wrap reads and writes in NSFileCoordinator (coordinate(readingItemAt:) / coordinate(writingItemAt:)) when the base URL is inside a ubiquity container. This is also correct for user-picked iCloud Drive folders. The existing flock on .minuta.writelock.nosync stays for the same-machine app + CLI case; .nosync files are never uploaded, so the lock stays local as intended. .minuta-version syncs, which is fine.

  6. Conflicts. Inside a ubiquity container iCloud does not create name 2.automerge copies (that is iCloud Drive behavior for non-coordinating writers); it keeps NSFileVersion.unresolvedConflictVersionsOfItem(at:). Add a resolver that merges each conflict version into the current document with the same Automerge merge ConflictResolutionService already uses, then removeOtherVersionsOfItem(at:) and marks the versions resolved. Because every file is a CRDT document, the merge loses nothing regardless of which version iCloud picked as current. ConflictResolutionService keeps handling the name 2 / (conflicted copy) naming for plain synced folders.

  7. Migration of existing users (decision 2026-09-06: always ask, never move silently).

    • Detection, after the container resolves: the user is on the local default (hasSelectedLocation == true, no bookmark) and the local Documents/Minuta contains tags.automerge or records/. Users with an explicitly chosen folder are not asked.
    • Prompt: “Move to iCloud Drive” / “Keep on this device”. The choice is stored (storageMigrationDecision), so the prompt shows once; “Keep on this device” leaves the local default and the user can still switch in Settings later.
    • Order: wait for the NSMetadataQuery to report every placeholder in the container as .current first, then migrate under the write lock, off the main thread. Merging into a not-yet-downloaded version would only produce an iCloud conflict version to resolve afterwards.
    • Empty container (first device): FileManager.setUbiquitous(true, itemAt:destinationURL:) per file. It moves, not copies; the local copy is gone afterwards.
    • Container already has data (another device synced first): setUbiquitous fails on an existing destination, and tags.automerge / records/YYYY/MM.automerge collide by name. Load both documents, Automerge-merge, save into the container, delete the local file. Nothing is lost regardless of which side is older.
    • After a successful move: useDefaultLocation() semantics stay (the default now resolves to the container), AppState.reinitializeStorage(), and the empty local folder is removed.
  8. CLI discovery (Shared/Sources/MinutaCLI/Runtime/StorageResolver.swift). On the Mac the container is ~/Library/Mobile Documents/iCloud~tools~minuta~app/Documents. Add it as a discovery step after storage-path.txt and before ~/Documents/Minuta. This also covers the sandboxed Mac App Store build that cannot write the pointer (see 917). doctor warns when the folder contains .icloud placeholders, since the CLI cannot trigger downloads on its own.

  9. Builds without the entitlement (Debug on the personal team, Homebrew release): url(forUbiquityContainerIdentifier:) returns nil and behavior is exactly today’s. UI tests keep using --test-storage=.

Verification

  1. cd Shared && swift test; MinutaTests for the resolver fallback, placeholder name mapping, and the version-merge function (in-memory Automerge documents, no iCloud needed).
  2. Personal-team Debug build on the simulator and ./scripts/run-mac.sh: no entitlement, storage stays local, all UI tests green on both platforms.
  3. With the holder’s development profile on a device signed in to iCloud: fresh install shows “Default: iCloud Drive/Minuta”; Files shows iCloud Drive/Minuta with tags.automerge and records/; a timer started on the iPhone appears on a Mac App Store build (or a Mac with the same iCloud account and the container synced) within a minute; edits made offline on both devices merge with no lost records; signing out of iCloud falls back to local storage without a crash.
  4. Existing local-default user upgrades: the move prompt appears once; “Keep on this device” leaves everything where it was.
  5. TestFlight build uploads with the regenerated profile (no ITMS entitlement mismatch).

Docs to update when implemented

  • 305-storage-location: default resolution order and the ubiquity resolver.
  • 502-multi-cloud-sync: iCloud Documents section becomes “implemented”.
  • 506-cross-process-safety: coordinated I/O and NSFileVersion conflicts.
  • 311-cli: the container path in discovery order, doctor placeholder warning.
  • 702-testflight and TESTFLIGHT-ORG.md: iCloud entitlements, regenerated profiles; TESTFLIGHT-HOLDER.md: the “iCloud” section above.
  • Support page: “Where is my data?” answer for iPhone (Files > iCloud Drive > Minuta, or On My iPhone > Minuta when iCloud is off).

Nice to have

  • “Show in Files” button in Settings > Data: UIApplication.open with a shareddocuments://<path> URL opens the Files app at the storage folder. Works for the local folder already.