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;