From a6aa111cae3edf3e92af9a60b82a14f3c5405f31 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 6 Oct 2026 13:28:23 +0900 Subject: [PATCH 1/4] Give the iCloud Drive folder the app's icon on iOS and visionOS The "Tortoise Blocks" folder in iCloud Drive was a plain folder the document browser made, and a plain folder cannot carry an icon; only the app's own ubiquity container, published through NSUbiquitousContainers, can. Claim iCloud.space.hiraku.tortoiseblocks with CloudDocuments and publish its document scope. macOS keeps its old entitlements: iCloud needs a provisioning profile, and the Mac's Debug loop is ad-hoc signed without a team. The bare CODE_SIGN_ENTITLEMENTS carries iCloud and [sdk=macosx*] opts out, so a platform added later inherits it rather than silently losing it. Refs #140 --- CLAUDE.md | 30 +++++++++++++++++++++- Support/Info.plist | 12 +++++++++ Support/TortoiseBlocks-iCloud.entitlements | 22 ++++++++++++++++ TortoiseBlocks.xcodeproj/project.pbxproj | 8 ++++-- 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 Support/TortoiseBlocks-iCloud.entitlements diff --git a/CLAUDE.md b/CLAUDE.md index ca0513b..73a76b0 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,33 @@ 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. `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; From ef44e17c7fb9c2dec97809129e69adc6726af1e0 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 6 Oct 2026 21:00:37 +0900 Subject: [PATCH 2/4] Create the iCloud container at launch The document browser makes a new document wherever it is looking, not in the app's container, so nothing ever asked for the container and iCloud Drive had no folder to show. Ask for it at launch, off the main actor, and create its Documents folder. From an app delegate rather than a view's task: no single view is there on every launch. Refs #140 --- App/Document/UbiquityContainer.swift | 31 ++++++++++++++++++++++++++++ App/TortoiseBlocksApp.swift | 22 ++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 App/Document/UbiquityContainer.swift diff --git a/App/Document/UbiquityContainer.swift b/App/Document/UbiquityContainer.swift new file mode 100644 index 0000000..673a94b --- /dev/null +++ b/App/Document/UbiquityContainer.swift @@ -0,0 +1,31 @@ +import Foundation + +/// 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 { + /// Creates the container and its `Documents` folder 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 { return } + + let documents = container.appending(path: "Documents", directoryHint: .isDirectory) + try? files.createDirectory(at: documents, withIntermediateDirectories: true) + } +} 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. From 1f721a28f97f3f5e786c7e5793f8e8c1fe09fd22 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 6 Oct 2026 21:05:52 +0900 Subject: [PATCH 3/4] Log whether the iCloud container came up Refs #140 --- App/Document/UbiquityContainer.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/App/Document/UbiquityContainer.swift b/App/Document/UbiquityContainer.swift index 673a94b..9850330 100644 --- a/App/Document/UbiquityContainer.swift +++ b/App/Document/UbiquityContainer.swift @@ -1,4 +1,5 @@ import Foundation +import os /// The app's own iCloud container — the folder iCloud Drive shows with the /// app's icon (#140). @@ -12,6 +13,8 @@ import Foundation /// 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 and its `Documents` folder 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 @@ -23,9 +26,20 @@ enum UbiquityContainer { @concurrent static func prepare() async { let files = FileManager.default - guard let container = files.url(forUbiquityContainerIdentifier: nil) else { return } + 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) - try? files.createDirectory(at: documents, withIntermediateDirectories: true) + do { + try files.createDirectory(at: documents, withIntermediateDirectories: true) + log.info( + "iCloud container ready: \(documents.path(percentEncoded: false), privacy: .public)" + ) + } + catch { + log.error("Could not create the iCloud Documents folder: \(error, privacy: .public)") + } } } From 7ed233ae6f234872762d45a61f61133a6177a4cd Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 6 Oct 2026 21:13:07 +0900 Subject: [PATCH 4/4] Leave a hidden marker so the iCloud folder shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty Documents folder stayed out of iCloud Drive on an iPhone — in the Files app and in the app's own browser, minutes later — and appeared, with its icon, once an empty .keep was inside it. Hidden, so a child has nothing to find or delete, and a marker already there, or its .icloud placeholder from another device, is left alone. Written through a file coordinator, as writes into a ubiquity container have to be. Refs #140 --- App/Document/UbiquityContainer.swift | 43 ++++++++++++++++++++++++---- CLAUDE.md | 12 +++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/App/Document/UbiquityContainer.swift b/App/Document/UbiquityContainer.swift index 9850330..31b0641 100644 --- a/App/Document/UbiquityContainer.swift +++ b/App/Document/UbiquityContainer.swift @@ -15,10 +15,10 @@ import os enum UbiquityContainer { private static let log = Logger(subsystem: "space.hiraku.tortoiseblocks", category: "icloud") - /// Creates the container and its `Documents` folder 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. + /// 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 @@ -34,12 +34,45 @@ enum UbiquityContainer { 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 create the iCloud Documents folder: \(error, privacy: .public)") + 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/CLAUDE.md b/CLAUDE.md index 73a76b0..058bcbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -432,7 +432,17 @@ 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. `NSUbiquitousContainers` still ships in the Mac's +*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