Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions App/Document/UbiquityContainer.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
22 changes: 22 additions & 0 deletions App/TortoiseBlocksApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 39 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions Support/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.space.hiraku.tortoiseblocks</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerName</key>
<string>Tortoise Blocks</string>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>Any</string>
</dict>
</dict>
<key>NSWorldSensingUsageDescription</key>
<string>Used to place your drawing on a real table.</string>
<key>UIFileSharingEnabled</key>
Expand Down
22 changes: 22 additions & 0 deletions Support/TortoiseBlocks-iCloud.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.space.hiraku.tortoiseblocks</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudDocuments</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>iCloud.space.hiraku.tortoiseblocks</string>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
8 changes: 6 additions & 2 deletions TortoiseBlocks.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
1A0000000000000000000014 /* TortoiseBlocks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TortoiseBlocks.entitlements; sourceTree = "<group>"; };
1A0000000000000000000015 /* ThumbnailExtension-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "ThumbnailExtension-Info.plist"; sourceTree = "<group>"; };
1A0000000000000000000016 /* ThumbnailExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ThumbnailExtension.entitlements; sourceTree = "<group>"; };
1A0000000000000000000017 /* TortoiseBlocks-iCloud.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "TortoiseBlocks-iCloud.entitlements"; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
Expand Down Expand Up @@ -143,6 +144,7 @@
1A0000000000000000000012 /* Signing.xcconfig */,
1A0000000000000000000015 /* ThumbnailExtension-Info.plist */,
1A0000000000000000000016 /* ThumbnailExtension.entitlements */,
1A0000000000000000000017 /* TortoiseBlocks-iCloud.entitlements */,
1A0000000000000000000014 /* TortoiseBlocks.entitlements */,
);
path = Support;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading