diff --git a/App/Document/UbiquityContainer.swift b/App/Document/UbiquityContainer.swift
new file mode 100644
index 0000000..31b0641
--- /dev/null
+++ b/App/Document/UbiquityContainer.swift
@@ -0,0 +1,78 @@
+import Foundation
+import os
+
+/// The app's own iCloud container — the folder iCloud Drive shows with the
+/// app's icon (#140).
+///
+/// Nothing creates it on the app's behalf. The document browser makes a new
+/// document wherever it is currently looking rather than in the container, so
+/// without this the container never comes into being and iCloud Drive has no
+/// folder to show. Asking for its URL is what creates it, and `Documents` is
+/// the part `NSUbiquitousContainerIsDocumentScopePublic` publishes.
+///
+/// Not on macOS, which has no iCloud entitlement (see CLAUDE.md): the URL
+/// would only ever come back nil there.
+enum UbiquityContainer {
+ private static let log = Logger(subsystem: "space.hiraku.tortoiseblocks", category: "icloud")
+
+ /// Creates the container, its `Documents` folder and the marker that makes
+ /// the folder show, if they are not there yet. Does nothing without iCloud
+ /// — signed out, iCloud Drive off, or the app switched off under it — which
+ /// leaves documents where they always went, On My iPhone / iPad.
+ ///
+ /// Off the main actor, because the first call can block while iCloud sets
+ /// the container up, and Apple's documentation says not to make it on the
+ /// main thread.
+ @concurrent
+ static func prepare() async {
+ let files = FileManager.default
+ guard let container = files.url(forUbiquityContainerIdentifier: nil) else {
+ log.info("No iCloud container: iCloud is unavailable to this app")
+ return
+ }
+
+ let documents = container.appending(path: "Documents", directoryHint: .isDirectory)
+ do {
+ try files.createDirectory(at: documents, withIntermediateDirectories: true)
+ try leaveMarker(in: documents)
+ log.info(
+ "iCloud container ready: \(documents.path(percentEncoded: false), privacy: .public)"
+ )
+ }
+ catch {
+ log.error("Could not prepare the iCloud Documents folder: \(error, privacy: .public)")
+ }
+ }
+
+ /// An empty hidden file in `Documents`, because an empty `Documents` is not
+ /// enough: measured on an iPhone, the folder stayed out of iCloud Drive —
+ /// in the Files app and in the app's own browser alike, minutes later —
+ /// until something was inside it, and appeared with its icon once this
+ /// was. Hidden, so there is nothing for a child to find, open or delete,
+ /// unlike a sample document; and nothing to keep track of either, because
+ /// a marker that is already there is simply left alone.
+ ///
+ /// One another device left behind may not have downloaded yet, in which
+ /// case only its `.icloud` placeholder is here; that counts as there, so
+ /// two devices don't write the same file over each other.
+ private static func leaveMarker(in documents: URL) throws {
+ let files = FileManager.default
+ let marker = documents.appending(path: ".keep")
+ let placeholder = documents.appending(path: ".keep.icloud")
+ guard
+ !files.fileExists(atPath: marker.path(percentEncoded: false)),
+ !files.fileExists(atPath: placeholder.path(percentEncoded: false))
+ else { return }
+
+ // Coordinated, as every write into a ubiquity container has to be.
+ var coordinationError: NSError?
+ var writeError: (any Error)?
+ NSFileCoordinator().coordinate(
+ writingItemAt: marker, options: .forReplacing, error: &coordinationError
+ ) { url in
+ do { try Data().write(to: url) }
+ catch { writeError = error }
+ }
+ if let error = coordinationError ?? writeError { throw error }
+ }
+}
diff --git a/App/TortoiseBlocksApp.swift b/App/TortoiseBlocksApp.swift
index e881463..ca907ba 100644
--- a/App/TortoiseBlocksApp.swift
+++ b/App/TortoiseBlocksApp.swift
@@ -29,8 +29,30 @@ import SwiftUI
}
#endif
+#if !os(macOS)
+ /// Launch work that belongs to no window.
+ ///
+ /// Only the iCloud container (#140) for now. It is here rather than in a
+ /// view's `task` because no single view is there on every launch: a
+ /// document opened from the Files app skips the launch scene, and the
+ /// visionOS viewer has none.
+ final class AppDelegate: NSObject, UIApplicationDelegate {
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ Task { await UbiquityContainer.prepare() }
+ return true
+ }
+ }
+#endif
+
@main
struct TortoiseBlocksApp: App {
+ #if !os(macOS)
+ @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
+ #endif
+
// The viewer's whole state, shared by its two scenes (#53). One `Scene`
// cannot see another's, and the window and the table are exactly that
// split: controls here, drawing there.
diff --git a/CLAUDE.md b/CLAUDE.md
index ca0513b..058bcbc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -315,7 +315,8 @@ SDK-conditional: `INFOPLIST_FILE` takes one path, splitting it would
duplicate the UTType declarations, and macOS simply ignores an iOS key. It
is what puts a "Tortoise Blocks" folder under On My iPad in the Files app —
the app's own directory *is* the gallery, which is why no in-app gallery is
-planned (#15).
+planned (#15). With iCloud signed in, the iCloud Drive folder below plays the
+same part.
The QuickLook extension is a second target in the same hand-written project
(#15): its own buildable folder `ThumbnailExtension/`, `NSExtension` keys in
`Support/ThumbnailExtension-Info.plist`, and a `dstSubfolderSpec = 13` copy
@@ -415,6 +416,43 @@ rules apply. The app takes `files.user-selected.read-write` for the
`read-only` and is sandboxed for a different reason (a macOS QuickLook
extension is not loaded otherwise).
+**iCloud Drive gets a folder with the app's icon — on iOS and visionOS, not
+macOS** (#140). An icon only appears on a folder that is the app's own
+ubiquity container, published by `NSUbiquitousContainers`
+(`IsDocumentScopePublic`); the "Tortoise Blocks" folder that existed before was
+a plain one the document browser made, and a plain folder cannot carry an
+icon. So the app claims `iCloud.space.hiraku.tortoiseblocks` with
+`CloudDocuments`, from `Support/TortoiseBlocks-iCloud.entitlements` — the bare
+`CODE_SIGN_ENTITLEMENTS` — while `[sdk=macosx*]` keeps
+`Support/TortoiseBlocks.entitlements` without it. Name the platform that is
+left out, as with `CODE_SIGN_IDENTITY`: a platform added later inherits iCloud
+instead of silently losing it. macOS is left out because iCloud entitlements
+need a provisioning profile and the Mac's Debug loop is ad-hoc signed with no
+team; splitting by configuration instead would only have moved the hole into
+Debug. The Mac loses little: iCloud Drive syncs the container's folder to the
+Finder like any iOS-only app's, and the open/save panels already reach it
+through `user-selected.read-write` — what it lacks is only the folder as the
+*default* save location. **Nothing creates the container for the app**:
+`DocumentGroup` makes a new document in whatever folder its browser has
+open, never in the container, so `UbiquityContainer.prepare()` asks for it
+at launch (from an app delegate — no single view is there on every launch).
+**And an empty `Documents` does not show**: measured on an iPhone, the
+folder stayed out of iCloud Drive, in the Files app and the app's own
+browser, until something was inside it; a hidden empty `.keep` was enough,
+and brought the icon with it. Hidden, so a child has nothing to find or
+delete, and nothing to track — a marker already there (or its `.icloud`
+placeholder from another device) is left alone.
+`NSUbiquitousContainers` still ships in the Mac's
+Info.plist (one `INFOPLIST_FILE`, as above) and does nothing there without the
+entitlement. iCloud Drive reads that metadata once per new build, which Xcode
+Cloud's `CI_BUILD_NUMBER` provides; locally, reinstall. The container lives
+in the Developer portal and Xcode Cloud cannot register one, same as a bundle
+identifier. Verifying the result has a trap: a simulator build carries its
+entitlements in a `__TEXT,__entitlements` section of the executable, not the
+signature, so `codesign -d --entitlements -` prints an empty dict for a build
+that is perfectly right — read the section (`otool -l` for its offset) or
+`strings` the binary.
+
**visionOS runs the iPad app, not a port** (#11). `SUPPORTED_PLATFORMS` gains
`xros xrsimulator`, `XROS_DEPLOYMENT_TARGET` is 26.0, and the same three-pane
`NavigationSplitView` fills the window — the scene is regular width, so nothing
diff --git a/Support/Info.plist b/Support/Info.plist
index 86e7ca1..3e4beb4 100644
--- a/Support/Info.plist
+++ b/Support/Info.plist
@@ -21,6 +21,18 @@
ITSAppUsesNonExemptEncryption
+ NSUbiquitousContainers
+
+ iCloud.space.hiraku.tortoiseblocks
+
+ NSUbiquitousContainerIsDocumentScopePublic
+
+ NSUbiquitousContainerName
+ Tortoise Blocks
+ NSUbiquitousContainerSupportedFolderLevels
+ Any
+
+
NSWorldSensingUsageDescription
Used to place your drawing on a real table.
UIFileSharingEnabled
diff --git a/Support/TortoiseBlocks-iCloud.entitlements b/Support/TortoiseBlocks-iCloud.entitlements
new file mode 100644
index 0000000..9217ad8
--- /dev/null
+++ b/Support/TortoiseBlocks-iCloud.entitlements
@@ -0,0 +1,22 @@
+
+
+
+
+ com.apple.developer.icloud-container-identifiers
+
+ iCloud.space.hiraku.tortoiseblocks
+
+ com.apple.developer.icloud-services
+
+ CloudDocuments
+
+ com.apple.developer.ubiquity-container-identifiers
+
+ iCloud.space.hiraku.tortoiseblocks
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.files.user-selected.read-write
+
+
+
diff --git a/TortoiseBlocks.xcodeproj/project.pbxproj b/TortoiseBlocks.xcodeproj/project.pbxproj
index 0424809..1782870 100644
--- a/TortoiseBlocks.xcodeproj/project.pbxproj
+++ b/TortoiseBlocks.xcodeproj/project.pbxproj
@@ -53,6 +53,7 @@
1A0000000000000000000014 /* TortoiseBlocks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TortoiseBlocks.entitlements; sourceTree = ""; };
1A0000000000000000000015 /* ThumbnailExtension-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "ThumbnailExtension-Info.plist"; sourceTree = ""; };
1A0000000000000000000016 /* ThumbnailExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ThumbnailExtension.entitlements; sourceTree = ""; };
+ 1A0000000000000000000017 /* TortoiseBlocks-iCloud.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "TortoiseBlocks-iCloud.entitlements"; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -143,6 +144,7 @@
1A0000000000000000000012 /* Signing.xcconfig */,
1A0000000000000000000015 /* ThumbnailExtension-Info.plist */,
1A0000000000000000000016 /* ThumbnailExtension.entitlements */,
+ 1A0000000000000000000017 /* TortoiseBlocks-iCloud.entitlements */,
1A0000000000000000000014 /* TortoiseBlocks.entitlements */,
);
path = Support;
@@ -510,7 +512,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
- CODE_SIGN_ENTITLEMENTS = Support/TortoiseBlocks.entitlements;
+ CODE_SIGN_ENTITLEMENTS = "Support/TortoiseBlocks-iCloud.entitlements";
+ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Support/TortoiseBlocks.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -550,7 +553,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
- CODE_SIGN_ENTITLEMENTS = Support/TortoiseBlocks.entitlements;
+ CODE_SIGN_ENTITLEMENTS = "Support/TortoiseBlocks-iCloud.entitlements";
+ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Support/TortoiseBlocks.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;