Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,74 @@
x.y.z Release Notes (yyyy-MM-dd)
=============================================================

0.1.0 Release Notes (2026-05-04)
=============================================================

### Breaking

* Minimum platform requirements raised to iOS 11.0 and macOS 10.13. The library's
Files-app integration premise and APFS extended-attribute usage have always
required these floors in practice; they are now stated explicitly.
* `-[NSURL to_setFileSystemUUID:]` now returns `BOOL` (previously `void`) so
callers can detect xattr write failures. Existing call sites that ignore
the return value continue to compile.
* `-[NSURL to_generateFileSystemUUID]` is now annotated `nullable` and returns
`nil` when the underlying xattr write fails, instead of returning a UUID
that was never persisted to disk.
* `TOFileSystemPresenter`'s `-performCoordinatedRead:` and
`-performCoordinatedWrite:` methods have been removed. Their only callers
were inside the library and have been replaced with direct, simpler logic.
* `TOFileSystemItemMapTable` no longer conforms to `NSFastEnumeration`.
Iterate the new `-allItems` snapshot accessor instead.

### Added

* `-[NSURL to_setFileSystemUUIDIfAbsent:]` — atomic set-if-absent UUID write
using `XATTR_CREATE`. Replaces the previous read-then-write coordination
for the initial UUID assignment path.
* `TOFileSystemItemMapTable -allItems` — point-in-time snapshot accessor
that's safe to iterate while the table is being mutated.
* `TOFileSystemObserver.directoryItem` is now actually implemented (the
property was previously declared but auto-synthesized to always return
`nil`).
* Threading contract for `-addNotificationBlock:` is now documented in the
public header. Notification blocks fire on a background queue.

### Fixed

* `getxattr` / `setxattr` return values are now checked. UUID reads no
longer interpret uninitialised stack memory as a UUID on filesystems
that don't support extended attributes; UUID writes no longer silently
fail.
* `-[NSURL to_setFileSystemUUID:]` no longer crashes when passed `nil` or
an empty string (`strlen(NULL)`).
* `-[NSURL to_isCopying]` now returns `NO` when the file's modification
date is unreadable. Previously it returned `YES` and consumers would
treat the file as "still copying" indefinitely.
* `-[NSURL to_numberOfSubItems]` now falls back to `lstat` when the
filesystem reports `DT_UNKNOWN` (NFS, FAT and similar). Previously the
count was silently wrong on those filesystems.
* Notification-token dispatch now iterates a snapshot of the underlying
hash table, so a block that invalidates another token mid-dispatch can
no longer crash or skip in-flight notifications.
* `TOFileSystemItemList -synchronizeWithDisk` removes deleted items in
reverse index order. Previously, removing two non-contiguous deletions
could mis-target items because indices shifted between removals.
* The full-scan `WillBegin` / `DidComplete` notifications now fire on
empty directories. Previously the scan returned early without sending
either, leaving consumers hung waiting for "scan complete".
* Cross-instance scan stalls eliminated. UUID writes from one
`TOFileSystemObserver` instance no longer serialise behind another
observer's queue. Tests that previously took ~8 seconds now complete
in under a second.
* `TOFileSystemPresenter -stop` now drains any buffered file events and
resets internal timer state, so a subsequent `-start` does not replay
events from before the stop.
* Symlinked directories are no longer recursed into during scans. A
circular directory symlink would previously loop the scan indefinitely.
* Removed the dead/broken `-[TOFileSystemItem regenerateUUID]` private
method.

0.0.4 Release Notes (2022-01-23)
=============================================================

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
[![PayPal](https://img.shields.io/badge/paypal-donate-blue.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=M4RKULAVKV7K8)
[![Twitch](https://img.shields.io/badge/twitch-timXD-6441a5.svg)](http://twitch.tv/timXD)

`TOFileSystemObserver` is a bullet-proof mechanism (hopefully) for detecting any user-initiated changes made to the contents of an iOS / iPadOS app's sandbox while the app is open.
`TOFileSystemObserver` is a bullet-proof mechanism (hopefully) for detecting any user-initiated changes made to the contents of an iOS / iPadOS / macOS app's sandbox while the app is open.

Since iOS 11, the Files app has given the option to allow apps to expose the contents of their Documents directories to the users, letting them manipulate the files, either while the app is closed, suspended, or even running side-by-side with iPad multitasking.

Expand Down Expand Up @@ -64,7 +64,9 @@ Please check the sample app for more examples on the features of this library.

# Requirements

`TOFileSystemObserver` will work with iOS 8.0 and above. While it's been written in Objective-C, it will also work with Swift (But the Swift interface may need some more work.)
`TOFileSystemObserver` requires iOS 11+ / iPadOS 11+, and runs on macOS 10.13+ as well. The iOS 11 floor reflects the Files-app integration the library is built around, and the use of APFS extended file attributes for tracking item identity. While it's been written in Objective-C, it will also work with Swift (the Swift interface may need some more work).

Notification blocks fire on a background queue. If you're updating UI in response to events, dispatch to the main queue yourself.

## Manual Installation

Expand Down
6 changes: 3 additions & 3 deletions TOFileSystemObserver.podspec
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
Pod::Spec.new do |s|
s.name = 'TOFileSystemObserver'
s.version = '0.0.4'
s.version = '0.1.0'
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.summary = 'A bullet-proof mechanism for detecting any changes made to the contents of a folder in iOS & iPadOS.'
s.summary = 'A bullet-proof mechanism for detecting any changes made to the contents of a folder in iOS, iPadOS and macOS.'
s.homepage = 'https://github.com/TimOliver/TOFileSystemObserver'
s.author = 'Tim Oliver'
s.source = { :git => 'https://github.com/TimOliver/TOFileSystemObserver.git', :tag => s.version }
s.platforms = { :ios => "8.0", :osx => "10.12" }
s.platforms = { :ios => "11.0", :osx => "10.13" }
s.source_files = 'TOFileSystemObserver/**/*.{h,m}'
s.exclude_files = 'TOFileSystemObserver/include/**'
s.osx.exclude_files = 'TOFileSystemObserver/Utilities/TOFileSystemObserver+UIKit.h'
Expand Down
38 changes: 29 additions & 9 deletions TOFileSystemObserver/Categories/NSURL+TOFileSystemAttributes.m
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#import "NSURL+TOFileSystemAttributes.h"
#import "TOFileSystemObserverConstants.h"
#include <dirent.h>
#include <sys/stat.h>

@implementation NSURL (TOFileSystemAttributes)

Expand Down Expand Up @@ -65,24 +66,43 @@ - (NSDate *)to_modificationDate
return modificationDate;
}

- (NSInteger)to_numberOfSubItems
// Decides whether a directory entry should count toward `to_numberOfSubItems`.
// Hidden entries (leading dot) are always skipped. Regular files and directories
// are counted via the d_type fast path. DT_UNKNOWN — which happens on filesystems
// that don't fill d_type, like NFS or FAT — falls back to lstat. Exposed (not
// static) so the DT_UNKNOWN branch can be exercised by unit tests, since it's
// not reachable on APFS where readdir always reports a concrete type.
BOOL TOFileSystemDirEntryIsCountable(const char *parentPath, const struct dirent *entry)
{
NSInteger numberOfItems = 0;
DIR *directory;
struct dirent *entry;
if (entry->d_name[0] == '.') { return NO; }
if (entry->d_type == DT_REG || entry->d_type == DT_DIR) { return YES; }
if (entry->d_type != DT_UNKNOWN) { return NO; }

char fullPath[PATH_MAX];
int written = snprintf(fullPath, sizeof(fullPath), "%s/%s", parentPath, entry->d_name);
if (written <= 0 || written >= (int)sizeof(fullPath)) { return NO; }

struct stat st;
if (lstat(fullPath, &st) != 0) { return NO; }
return S_ISREG(st.st_mode) || S_ISDIR(st.st_mode);
}

- (NSInteger)to_numberOfSubItems
{
// Do it using POSIX APIs to avoid needing to load in all of the file names
const char *path = [self.path cStringUsingEncoding:NSUTF8StringEncoding];
directory = opendir(path);
DIR *directory = opendir(path);
if (directory == NULL) { return 0; }

NSInteger numberOfItems = 0;
struct dirent *entry;
while ((entry = readdir(directory)) != NULL) {
if (entry->d_name[0] == '.') { continue; }
if (entry->d_type == DT_REG || entry->d_type == DT_DIR) {
numberOfItems++;
if (TOFileSystemDirEntryIsCountable(path, entry)) {
numberOfItems++;
}
}
closedir(directory);

return numberOfItems;
}

Expand Down
9 changes: 9 additions & 0 deletions TOFileSystemObserver/Categories/NSURL+TOFileSystemUUID.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ NS_ASSUME_NONNULL_BEGIN
/** Sets a predetermined UUID to be the value of the file. Returns YES if the attribute was written. */
- (BOOL)to_setFileSystemUUID:(NSString *)uuid;

/**
Sets a UUID only if no UUID attribute is currently present on the file.
Returns YES if this call wrote the attribute. Returns NO if the attribute
already exists (another writer won the race) or the write failed for any
other reason. Callers should re-read with `to_fileSystemUUID` on NO to
see the canonical on-disk value.
*/
- (BOOL)to_setFileSystemUUIDIfAbsent:(NSString *)uuid;

/** Regardless if one exists, generate and save a new UUID. Returns nil if the write failed. */
- (nullable NSString *)to_generateFileSystemUUID;

Expand Down
20 changes: 20 additions & 0 deletions TOFileSystemObserver/Categories/NSURL+TOFileSystemUUID.m
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ - (BOOL)to_setFileSystemUUID:(NSString *)uuid
return setxattr(filePath, keyName, uuidString, 36, 0, 0) == 0;
}

- (BOOL)to_setFileSystemUUIDIfAbsent:(NSString *)uuid
{
if (uuid.length == 0) { return NO; }
if (uuid.length != 36) {
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:@"UUID must be 36 characters long!"
userInfo:nil];
}

const char *filePath = [self.path fileSystemRepresentation];
const char *keyName = kTOFileSystemAttributeKey.UTF8String;
const char *uuidString = [uuid cStringUsingEncoding:NSUTF8StringEncoding];
if (uuidString == NULL) { return NO; }

// XATTR_CREATE makes setxattr fail with EEXIST if the attribute is already
// present. That is the atomic "set if absent" we want — no read-then-write
// race between two writers for the initial UUID assignment.
return setxattr(filePath, keyName, uuidString, 36, 0, XATTR_CREATE) == 0;
}

- (NSString *)to_generateFileSystemUUID
{
NSString *uuid = [NSUUID UUID].UUIDString;
Expand Down
8 changes: 0 additions & 8 deletions TOFileSystemObserver/Entities/FilePaths/TOFileSystemPath.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,6 @@ NS_ASSUME_NONNULL_BEGIN
/** The path to the application documents directory. */
+ (NSURL *)documentsDirectoryURL;

/** Takes an absolute URL, and strips off the sandbox portion, making it relative. */
+ (NSString *)relativePathWithPath:(NSURL *)fileURL;

/** Takes a flat array of URLs, and organizes them into a
dictionary where each key is the parent directory URL, and the value
is an array of all items in that directory. */
+ (NSDictionary<NSURL *, NSArray *> *)directoryDictionaryWithItemURLs:(NSArray<NSURL *> *)itemURLs;

@end

NS_ASSUME_NONNULL_END
28 changes: 0 additions & 28 deletions TOFileSystemObserver/Entities/FilePaths/TOFileSystemPath.m
Original file line number Diff line number Diff line change
Expand Up @@ -35,32 +35,4 @@ + (NSURL *)documentsDirectoryURL
return [fileManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].lastObject;
}

+ (NSString *)relativePathWithPath:(NSURL *)fileURL
{
NSString *sandboxPath = [TOFileSystemPath applicationSandboxURL].path;
NSString *path = fileURL.path;

// Replace the sandbox portion with an empty string.
path = [path stringByReplacingOccurrencesOfString:sandboxPath withString:@""];

// Remove leading slashes
if ([[path substringToIndex:1] isEqualToString:@"/"]) {
path = [path substringFromIndex:1];
}

// Remove trailing slashes
if ([[path substringFromIndex:path.length - 1] isEqualToString:@"/"]) {
path = [path substringToIndex:path.length - 2];
}

return path;
}

+ (NSDictionary<NSURL *, NSArray *> *)directoryDictionaryWithItemURLs:(NSArray<NSURL *> *)itemURLs
{
NSMutableDictionary *dictionary = [NSMutableDictionary dictionary];

return dictionary;
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,6 @@ NS_ASSUME_NONNULL_BEGIN
/** Remove this item from a list. */
- (void)removeFromList;

/** Forces a refresh of the UUID (in cases where the file seems to have been duplicated) */
- (void)regenerateUUID;

/** Notify this object that it should re-fetch all its properties from disk.
Returns true if there were changes. */
- (BOOL)refreshWithURL:(nullable NSURL *)itemURL;
Expand Down
11 changes: 2 additions & 9 deletions TOFileSystemObserver/Entities/Items/TOFileSystemItem.m
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ - (instancetype)initWithItemAtFileURL:(NSURL *)fileURL
// If this item represents a deleted file, skip gathering the data
if (!self.isDeleted) {
[self performWithLock:^{
[self configureUUIDForceRefresh:NO];
[self configureUUID];
[self refreshFromItemAtURL:fileURL];
}];
}
Expand All @@ -94,7 +94,7 @@ - (instancetype)initWithItemAtFileURL:(NSURL *)fileURL

#pragma mark - Update Properties -

- (void)configureUUIDForceRefresh:(BOOL)forceRefresh
- (void)configureUUID
{
TOFileSystemPresenter *presenter = self.fileSystemObserver.fileSystemPresenter;
_uuid = [presenter uuidForItemAtURL:_fileURL];
Expand Down Expand Up @@ -171,13 +171,6 @@ - (BOOL)isDeleted
return ![[NSFileManager defaultManager] fileExistsAtPath:self.fileURL.path];
}

- (void)regenerateUUID
{
[self performWithLock:^{
[self configureUUIDForceRefresh:YES];
}];
}

#pragma mark - Lists -

- (BOOL)refreshWithURL:(nullable NSURL *)itemURL
Expand Down
18 changes: 5 additions & 13 deletions TOFileSystemObserver/Scanning/TOFileSystemPresenter.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,9 @@
NS_ASSUME_NONNULL_BEGIN

/**
This file presenter object handles coordinating state
and events with the file system. It uses `NSFileCoordinator`
to receive events from the system, when files change,
and also performs coordinated reads and writes for retrieving
UUIDs from the files it manages.
This file presenter object adopts `NSFilePresenter` to receive callbacks
when items in its target directory change, and exposes a UUID accessor
backed by extended file attributes.
*/
@interface TOFileSystemPresenter : NSObject <NSFilePresenter>

Expand All @@ -56,16 +54,10 @@ NS_ASSUME_NONNULL_BEGIN
/** Start listening for file events in the target directory. */
- (void)start;

/** Perform a synchronous coordinated read on a file. */
- (void)performCoordinatedRead:(void (^)(void))block;

/** Perform a synchronous write operation on a file */
- (void)performCoordinatedWrite:(void (^)(void))block;

/** Stop listening and cancel any pending timer events. */
/** Stop listening, cancel any pending timer events, and discard buffered state. */
- (void)stop;

/** Coordinates reading (and writing if need be) a UUID string for the supplied item */
/** Reads (creating if needed, atomically) a UUID string for the supplied item. */
- (nullable NSString *)uuidForItemAtURL:(NSURL *)itemURL;

@end
Expand Down
Loading
Loading