Flutter plugin for iCloud document storage (iOS/macOS) with coordinated file access and optional Files app (iCloud Drive) visibility.
Hosted reference docs are available on DeepWiki: https://deepwiki.com/kingdomseed/icloud_storage_plus
This package operates inside your app’s iCloud ubiquity container. You choose
which container to use via the containerId you configured in Apple Developer
Portal / Xcode.
| Platform | Minimum version |
|---|---|
| iOS | 13.0 |
| macOS | 10.15 |
flutter pub add icloud_storage_plusThis plugin supports Flutter’s Swift Package Manager integration for iOS/macOS
projects (requires Flutter >= 3.24). To enable SwiftPM in your app:
flutter config --enable-swift-package-manager- Create an iCloud Container ID (example:
iCloud.com.yourapp.container) - Enable iCloud for your App ID and assign that container
- In Xcode → your target → Signing & Capabilities:
- Add iCloud
- Enable iCloud Documents
- Select your container
To make items visible in the Files app under “iCloud Drive”, you typically need
to declare your container under NSUbiquitousContainers in your Info.plist.
Files are only visible in Files/iCloud Drive when they live under the
Documents/ prefix.
// Visible in Files app
cloudRelativePath: 'Documents/notes.txt'
// Not visible in Files app (still syncs)
cloudRelativePath: 'cache/notes.txt'Note: your app’s folder won’t appear in Files/iCloud Drive until at least one
file exists under Documents/.
There are four “tiers” of API in this plugin:
- Path-only transfers for large files (no bytes returned to Dart)
uploadFile(local → iCloud)downloadFile(iCloud → local)
- In-place content for small files (bytes/strings cross the platform
channel; loads full contents in memory)
readInPlace,readInPlaceByteswriteInPlace,writeInPlaceBytes- On iOS and macOS, existing-file writes use coordinated atomic replacement so the destination path stays stable during overwrite.
- On iOS and macOS, overwrite paths for
writeInPlace,writeInPlaceBytes, anduploadFileproactively start any needed iCloud download, wait with an interactive-write timeout budget, and resolve unresolvedNSFileVersionconflicts before the coordinated replace. - Those file-write overwrite paths still reject an existing directory destination instead of replacing it.
- File management and queries
delete,move,copy,renamedocumentExists,getItemMetadata,getDocumentMetadata- On iOS and macOS, copying onto an existing destination also uses coordinated atomic replacement rather than remove-then-copy behavior.
copy()keeps its file-or-directory behavior for existing destinations; the stricter file-only overwrite rules apply to file-write APIs such asuploadFile,writeInPlace, andwriteInPlaceBytes.
- Container listing (two complementary approaches)
gather— NSMetadataQuery-based; sees remote files and document promises; provides real-time change notifications and download progress; eventually consistent after local mutationslistContents— FileManager-based; immediately consistent after local mutations; returns download/upload status via URL resource values; only sees files with a local representation (including iCloud placeholders)
On iOS, when Flutter provides a background platform-channel task queue, native
filesystem work for the in-place APIs runs there so iCloud container lookup and
UIDocument preflight do not block the app's main thread. If that queue is not
available, Flutter falls back to its default platform-channel dispatch model.
macOS keeps the existing dispatch model.
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:icloud_storage_plus/icloud_storage.dart';
const containerId = 'iCloud.com.yourapp.container';
const notesPath = 'Documents/notes.txt';
Future<void> example() async {
final available = await ICloudStorage.icloudAvailable();
if (!available) {
// Not signed into iCloud, iCloud Drive disabled, etc.
return;
}
// 1) Write a text file *in place* (recommended for JSON/text).
await ICloudStorage.writeInPlace(
containerId: containerId,
relativePath: notesPath,
contents: 'Hello from iCloud',
);
final contents = await ICloudStorage.readInPlace(
containerId: containerId,
relativePath: notesPath,
);
// 2) Copy-out to local storage (useful for large files / sharing / etc).
final localCopy = '${Directory.systemTemp.path}/notes.txt';
await ICloudStorage.downloadFile(
containerId: containerId,
cloudRelativePath: notesPath,
localPath: localCopy,
);
// 3) Typed metadata / listing.
final metadata = await ICloudStorage.getItemMetadata(
containerId: containerId,
relativePath: notesPath,
);
if (metadata != null && !metadata.isDirectory) {
final size = metadata.sizeInBytes;
}
try {
// Example: path validation happens in Dart before calling native.
await ICloudStorage.readInPlace(
containerId: containerId,
relativePath: 'Documents/.hidden.txt',
);
} on InvalidArgumentException catch (e) {
// Invalid path segment (starts with '.', contains ':', etc.)
// This is a Dart-side exception (not a PlatformException).
throw Exception(e);
} on ICloudOperationException catch (e) {
// Structured request/response failures are typed.
throw Exception(e);
} on PlatformException catch (e) {
// Legacy unstructured native failures stay raw PlatformException values.
throw Exception(e);
}
}Progress is delivered via an EventChannel as data events of type
ICloudTransferProgress. Failures are not delivered via stream onError.
Transfer-progress failures still carry raw PlatformException objects in
event.exception even though structured request/response APIs map to typed
Dart exceptions.
Important: the progress stream is listener-driven; start listening immediately
in the onProgress callback or you may miss early events.
await ICloudStorage.uploadFile(
containerId: containerId,
localPath: '/absolute/path/to/local/file.pdf',
cloudRelativePath: 'Documents/file.pdf',
onProgress: (stream) {
stream.listen((event) {
if (event.isProgress) {
// 0.0 - 100.0
final percent = event.percent!;
} else if (event.isError) {
final exception = event.exception!;
}
});
},
);This plugin always works in terms of paths inside the iCloud container:
cloudRelativePath: used byuploadFile/downloadFilerelativePath: used by the rest of the API
These are the same kind of value; the naming difference exists for historical reasons.
Directory paths can show up with trailing slashes in metadata, so the directory-oriented methods accept them:
delete,move,copy,rename,documentExists,getItemMetadata,getDocumentMetadata
File-centric operations reject trailing slashes (they require a file path):
uploadFile,downloadFilereadInPlace,readInPlaceBytes,writeInPlace,writeInPlaceBytes
On iOS and macOS, file-centric overwrite operations also reject an existing directory
destination instead of replacing it. If you need to replace an existing
directory tree, use the directory-aware copy() APIs rather than a file-write
operation.
Many methods validate path segments in Dart and throw InvalidArgumentException
for invalid values (empty segments, segments starting with ., segments that
contain : or /, etc).
gather() returns a GatherResult:
files: parsed metadata entriesinvalidEntries: entries that could not be parsed intoICloudFile
When onUpdate is provided, the update stream stays active until the
subscription is canceled. (Dispose listeners when done.)
final initial = await ICloudStorage.gather(
containerId: containerId,
onUpdate: (stream) {
stream.listen((update) {
// Full file list on every update.
final files = update.files;
});
},
);listContents() returns List<ContainerItem> — an immediately-consistent
snapshot of the container (or a subdirectory) using FileManager rather than
NSMetadataQuery.
final items = await ICloudStorage.listContents(
containerId: containerId,
relativePath: 'Documents/', // optional; defaults to container root
);
for (final item in items) {
print('${item.relativePath} — ${item.downloadStatus}');
if (item.isDirectory) print(' (directory)');
if (item.hasUnresolvedConflicts) print(' ⚠ conflicts');
}| Capability | gather |
listContents |
|---|---|---|
| Consistency after mutations | Eventually consistent (Spotlight index lag) | Immediately consistent |
| Sees remote-only files | Yes (document promises) | No |
| Real-time change notifications | Yes (via onUpdate stream) |
No (one-shot) |
| Download/upload progress % | Yes | No |
| Download/upload status | Yes | Yes |
| Conflict detection | Yes | Yes |
Hidden files (.DS_Store, etc.) |
Excluded by Spotlight | Filtered by resolved-name prefix |
| Underlying mechanism | NSMetadataQuery (Spotlight) |
FileManager + URL resource values |
When to use which:
- After your own mutations (rename, delete, copy, write): use
listContentsfor an immediate, accurate listing. - Initial sync on a new device: use
gatherto discover document promises (remote files not yet represented locally). - Live monitoring: use
gatherwithonUpdatefor real-time change notifications from other devices.
iCloud uses placeholder files to represent items that exist in iCloud but have not been fully downloaded to the local device. There are two eras:
- iOS and pre-Sonoma macOS: stub files named
.originalName.icloud(~192 bytes) that stand in for the real file. - macOS Sonoma+: APFS dataless files that keep the real filename and logical size; the actual content is fetched on demand.
Both gather and listContents handle this transparently — they resolve
placeholder names and report download status so you don't need to parse
.icloud filenames yourself.
listContents also filters out system hidden files (.DS_Store, .Trash,
.DocumentRevisions-V100, etc.) by skipping any entry whose resolved name
starts with .. Files whose real name starts with . will not appear in
listContents results. gather excludes most of these naturally via
Spotlight's indexing scope.
To check if a file has local content available:
// With ContainerItem (from listContents)
if (item.isDownloaded) {
// File has local content (downloadStatus is .downloaded or .current)
}
// With ICloudFile (from gather)
if (file.downloadStatus == DownloadStatus.current) {
// Fully up-to-date local copy
}Populated from the known-path metadata request API. This is the typed metadata model for request/response use.
class ICloudItemMetadata {
final String relativePath;
final bool isDirectory;
final int? sizeInBytes;
final DateTime? creationDate;
final DateTime? contentChangeDate;
final bool isDownloading;
final DownloadStatus? downloadStatus;
final bool isUploading;
final bool isUploaded;
final bool hasUnresolvedConflicts;
/// Whether the item has local content available.
// bool get isLocal => <computed>;
}Populated from NSMetadataQuery. Eventually consistent — the Spotlight index
may lag behind local filesystem mutations.
class ICloudFile {
final String relativePath;
final bool isDirectory;
final int? sizeInBytes;
final DateTime? creationDate;
final DateTime? contentChangeDate;
final bool isDownloading;
final DownloadStatus? downloadStatus;
final bool isUploading;
final bool isUploaded;
final bool hasUnresolvedConflicts;
}Populated from FileManager.contentsOfDirectory with URL resource values.
Immediately consistent after local mutations.
class ContainerItem {
final String relativePath;
final bool isDirectory;
final DownloadStatus? downloadStatus;
final bool isDownloading;
final bool isUploaded;
final bool isUploading;
final bool hasUnresolvedConflicts;
/// Whether the item has local content available.
// bool get isDownloaded => <computed>;
}ContainerItem does not include sizeInBytes, creationDate, or
contentChangeDate — these require NSMetadataQuery or additional URL resource
key lookups that are not part of the current implementation.
Thrown when you pass an invalid path/name to the Dart API (before calling native code).
Structured native failures from request/response APIs such as readInPlace,
writeInPlace, copy, getContainerPath, and getItemMetadata map to typed
exceptions:
ICloudContainerAccessExceptionICloudItemNotFoundExceptionICloudConflictExceptionICloudCoordinationExceptionICloudItemNotDownloadedExceptionICloudDownloadInProgressExceptionICloudTimeoutExceptionICloudUnknownNativeException
For iOS and macOS file-write overwrite operations, trying to overwrite an existing directory target is treated as an invalid argument rather than as a successful replacement.
For iOS and macOS overwrite paths, conflict/download refusal is now a
last-resort outcome rather than the default contract. Existing-file overwrites
first try to auto-download non-current ubiquitous items and auto-resolve
unresolved conflict versions. If the download wait exhausts its interactive
budget, the request/response API surfaces ICloudTimeoutException.
These exceptions expose operation, retryable, relativePath, and native
error context when the platform provides it.
ICloudCoordinationException is reserved for structured coordination failures.
Current iOS and macOS native implementations still classify some lower-level
coordination problems as ICloudUnknownNativeException when they do not yet
emit an explicit coordination category.
PlatformException is still the contract for:
- legacy unstructured request/response failures
getDocumentMetadata()- transfer-progress stream error events (
event.exception)
PlatformExceptionCode contains constants:
E_CTR(iCloud container/permission issues)E_CONFLICT(structured conflict failures)E_FNF(file not found)E_NOT_DOWNLOADED(structured nonlocal placeholder failures)E_DOWNLOAD_IN_PROGRESS(structured active-download failures)E_FNF_READ(file not found during read)E_FNF_WRITE(file not found during write)E_NAT(native error)E_ARG(invalid arguments passed to native)E_READ(read failure)E_CANCEL(operation canceled)E_INIT(plugin not properly initialized)E_TIMEOUT(download idle timeout)E_PLUGIN_INTERNAL(internal plugin error)E_INVALID_EVENT(invalid event from native layer)
- Prefer testing on physical devices. iCloud sync is unreliable in iOS Simulator.
documentExists()checks the filesystem path in the container; it does not force a download.- If Files app visibility matters, ensure paths start with
Documents/and your container is configured underNSUbiquitousContainers. - After a rename/move/delete,
gather()may still return stale results for a few seconds while the Spotlight index catches up. UselistContents()for immediate consistency.
- Hosted DeepWiki reference: https://deepwiki.com/kingdomseed/icloud_storage_plus
- Local notes index:
doc/README.md
MIT License - see LICENSE.
Forked from icloud_storage by deansyd.
Upstream is referenced for attribution only. This repository is not intended to track upstream changes.