├── img ├── icon.png ├── icon@2x.png ├── icon@3x.png └── icon-1024.png ├── screenshot.png ├── appInfo.json ├── LICENSE ├── XDSDocumentSourceAttribute.h ├── README.md └── XDSDocumentSourceAttribute.m /img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palmin/x-document-source/HEAD/img/icon.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palmin/x-document-source/HEAD/screenshot.png -------------------------------------------------------------------------------- /img/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palmin/x-document-source/HEAD/img/icon@2x.png -------------------------------------------------------------------------------- /img/icon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palmin/x-document-source/HEAD/img/icon@3x.png -------------------------------------------------------------------------------- /img/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palmin/x-document-source/HEAD/img/icon-1024.png -------------------------------------------------------------------------------- /appInfo.json: -------------------------------------------------------------------------------- 1 | {"icons": 2 | [{"width": 29, "height": 29, "src": "img/icon.png"}, 3 | {"width": 58, "height": 58, "src": "img/icon@2x.png"}, 4 | {"width": 87, "height": 87, "src": "img/icon@3x.png"}, 5 | {"width": 1024, "height": 1024, "src": "img/icon-1024.png"} 6 | ]} 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Alexander Blach & Anders Borum 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /XDSDocumentSourceAttribute.h: -------------------------------------------------------------------------------- 1 | // 2 | // XDSDocumentSourceAttribute.h 3 | // Textastic & Working Copy 4 | // 5 | // Created by Alexander Blach & Anders Borum in June & July 2016 6 | // 7 | 8 | #import 9 | 10 | typedef NS_ENUM(NSInteger, XDSDocumentSourceAttributeIconType) { 11 | // 29x29 icon used for spotlight and settings 12 | XDSDocumentSourceAttributeIconTypeSpotlight 13 | }; 14 | 15 | 16 | NS_ASSUME_NONNULL_BEGIN 17 | 18 | @interface XDSDocumentSourceAttribute : NSObject 19 | // Bundle identifier of the app: e.g. "com.appliedphasor.working-copy" 20 | @property (nonatomic, copy, nullable) NSString *bundleIdentifier; 21 | // Application name: e.g. "Working Copy" 22 | @property (nonatomic, copy, nullable) NSString *applicationName; 23 | // Path of the document in the source app: e.g. "libgit2/doc/README.md" 24 | @property (nonatomic, copy, nullable) NSString *documentPath; 25 | // URL pointing to a JSON file that countains icon urls and sizes: 26 | // "https://workingcopyapp.com/appInfo.json" 27 | @property (nonatomic, copy, nullable) NSURL *appInfoURL; 28 | 29 | // Create an instance for writing. 30 | - (instancetype)initWithBundleIdentifier:(NSString *)bundleIdentifier 31 | applicationName:(NSString *)applicationName 32 | documentPath:(NSString *)documentPath 33 | appInfoURL:(NSURL *)appInfoURL; 34 | 35 | + (instancetype)documentSourceAttributeWithBundleIdentifier:(NSString *)bundleIdentifier 36 | applicationName:(NSString *)applicationName 37 | documentPath:(NSString *)documentPath 38 | appInfoURL:(NSURL *)appInfoURL; 39 | 40 | // Tries to read the "x-document-source" extended attribute and returns 41 | // an XDSDocumentSourceAttribute instance on success. 42 | + (nullable instancetype)readDocumentSourceAttributeAtURL:(NSURL *)fileURL; 43 | 44 | // Write x-document-source extended attribute with contents of this 45 | // XDSDocumentSourceAttribute returning whether this succeeded. 46 | - (BOOL)writeToURL:(NSURL *)fileURL; 47 | 48 | // Load a matching icon using the JSON file pointed to by appInfoURL. 49 | // The completion handler is most likely not going to be invoked on the calling queue, so you probably 50 | // want to dispatch it to the main queue if you want to update your UI in response to the icon load. 51 | // In order to limit the number of server requests, the icon should be cached between app launches! 52 | - (void)loadIcon:(XDSDocumentSourceAttributeIconType)iconType 53 | withCompletionHandler:(void (^)(UIImage *icon, NSError *error))completionHandler; 54 | 55 | @end 56 | 57 | NS_ASSUME_NONNULL_END 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # x-document-source 2 | 3 | ## Motivation 4 | 5 | iOS apps usually only have access to files in their own sandbox container. The [document picker](https://developer.apple.com/library/ios/documentation/FileManagement/Conceptual/DocumentPickerProgrammingGuide/Introduction/Introduction.html) which was introduced in iOS 8 lets users import or open files from iCloud Drive and third-party apps. Apps can add [document provider extensions](https://developer.apple.com/library/prerelease/content/documentation/General/Conceptual/ExtensibilityPG/FileProvider.html) to make them appear in the list of locations of the document picker. 6 | 7 | When the user picks a file or folder, the app that invoked the document picker only [gets a security-scoped url](https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIDocumentPickerDelegate/index.html#//apple_ref/occ/intfm/UIDocumentPickerDelegate/documentPicker:didPickDocumentAtURL:), but no additional information about the app that provided it. 8 | 9 | For iCloud Drive documents, apps can use the [NSURLUbiquitousItemContainerDisplayNameKey](https://developer.apple.com/reference/foundation/nsurlubiquitousitemcontainerdisplaynamekey?language=objc) to get the name of the item's iCloud Drive container. 10 | 11 | Unfortunately, no such information is available for third-party document providers. The app accessing external files has no immediate way to know which app provides these files. 12 | 13 | We describe a way to share such information using an [extended file attribute](https://en.m.wikipedia.org/wiki/Extended_file_attributes) in a way that has minimal consequences for apps unaware of this mechanism. 14 | 15 | 16 | 17 | ## Writing the Extended File Attribute 18 | 19 | When files or directories are picked, the app extension should use the [setxattr](https://developer.apple.com/legacy/library/documentation/Darwin/Reference/ManPages/man2/setxattr.2.html) function to write an extended file attribute named `x-document-source` containing a **property list in binary format**. As an example, here is how such a property list might look: 20 | 21 | ```objc 22 | NSDictionary *plist = @{ 23 | @"identifier": @"com.appliedphasor.working-copy", 24 | @"name": @"Working Copy", 25 | @"path": @"directory/filename.ext", 26 | @"appInfoURL": @"https://raw.githubusercontent.com/palmin/x-document-source/master/appInfo.json" 27 | }; 28 | ``` 29 | 30 | 31 | ## App Icons 32 | 33 | Icon files are not included in the extended file attribute to keep it small. 34 | 35 | Instead, app icons can be retrieved by accessing the JSON file pointed to by `appInfoURL`. This file will itself reference icons at different resolutions and the `src` value can be either a fully qualified URL or relative to the JSON file itself. 36 | 37 | You should serve the files from **a secure website** (https) to comply with App Transport Security. 38 | 39 | ```json 40 | {"icons": 41 | [{"width": 29, "height": 29, "src": "img/icon.png"}, 42 | {"width": 58, "height": 58, "src": "img/icon@2x.png"}, 43 | {"width": 87, "height": 87, "src": "img/icon@3x.png"}, 44 | {"width": 1024, "height": 1024, "src": "img/icon-1024.png"} 45 | ]} 46 | ``` 47 | 48 | It is the responsibility of the app reading these images to mask the image to the rounded rectangle expected for app icons. This repository contains code for this. 49 | 50 | ## Adoption 51 | 52 | So far, x-document-source has been implemented by [Working Copy](https://workingcopyapp.com), [Textastic](https://www.textasticapp.com) v6.2 and [GoCoEdit](http://gocoedit.com/) v9.1. 53 | 54 | If you have an app that is either a document provider or uses the iOS document picker: please have a look at the `XDSDocumentSourceAttribute` class in this repository and add it to your app! 55 | 56 | A document provider might write this extended attribute as part of `dismissGrantingAccessToURL` in `UIDocumentPickerExtensionViewController`: 57 | ```objc 58 | - (void)dismissGrantingAccessToURL:(NSURL *)url { 59 | // determine documentPath relative to rootStorageURL 60 | NSString* rootPath = self.documentStorageURL.path; 61 | NSRange range = [url.path rangeOfString: rootPath.lastPathComponent]; 62 | NSString* documentPath = [url.path substringFromIndex:range.location + range.length]; 63 | 64 | NSString* appId = @"com.appliedphasor.working-copy"; 65 | NSURL* appInfoURL = [NSURL URLWithString:@"https://workingcopyapp.com/appInfo.json"]; 66 | XDSDocumentSourceAttribute* attribute; 67 | attribute = [XDSDocumentSourceAttribute documentSourceAttributeWithBundleIdentifier:appId 68 | applicationName:@"Working Copy" 69 | documentPath:documentPath 70 | appInfoURL:appInfoURL]; 71 | [attribute writeToURL:url]; 72 | 73 | [super dismissGrantingAccessToURL:url]; 74 | } 75 | ``` 76 | -------------------------------------------------------------------------------- /XDSDocumentSourceAttribute.m: -------------------------------------------------------------------------------- 1 | // 2 | // XDSDocumentSourceAttribute.m 3 | // Textastic & Working Copy 4 | // 5 | // Created by Alexander Blach & Anders Borum in June & July 2016 6 | // 7 | 8 | #import "XDSDocumentSourceAttribute.h" 9 | 10 | #include 11 | 12 | #if !__has_feature(objc_arc) 13 | #error This file must be compiled with ARC enabled. 14 | #endif 15 | 16 | 17 | static NSString *const XDSDocumentSourceAttributeName = @"x-document-source"; 18 | 19 | @implementation XDSDocumentSourceAttribute 20 | 21 | - (instancetype)initWithBundleIdentifier:(NSString *)bundleIdentifier 22 | applicationName:(NSString *)applicationName 23 | documentPath:(NSString *)documentPath 24 | appInfoURL:(NSURL *)appInfoURL { 25 | if (self = [super init]) { 26 | self.bundleIdentifier = bundleIdentifier; 27 | self.applicationName = applicationName; 28 | self.documentPath = documentPath; 29 | self.appInfoURL = appInfoURL; 30 | } 31 | return self; 32 | } 33 | 34 | + (instancetype)documentSourceAttributeWithBundleIdentifier:(NSString *)bundleIdentifier 35 | applicationName:(NSString *)applicationName 36 | documentPath:(NSString *)documentPath 37 | appInfoURL:(NSURL *)appInfoURL { 38 | return [[self alloc] initWithBundleIdentifier:bundleIdentifier 39 | applicationName:applicationName 40 | documentPath:documentPath 41 | appInfoURL:appInfoURL]; 42 | } 43 | 44 | + (instancetype)readDocumentSourceAttributeAtURL:(NSURL *)fileURL { 45 | if (![fileURL isFileURL]) { 46 | // we need a file path for getxattr 47 | return nil; 48 | } 49 | 50 | const char *attributeName = [XDSDocumentSourceAttributeName UTF8String]; 51 | const char *path = [[fileURL path] fileSystemRepresentation]; 52 | 53 | // try to read the "x-document-source" extended attribute 54 | // get length of attribute data 55 | ssize_t size = getxattr(path, attributeName, NULL, 0, 0, 0); 56 | if (size == -1) { 57 | return nil; 58 | } 59 | 60 | // get attribute data 61 | NSMutableData *data = [NSMutableData dataWithLength:size]; 62 | ssize_t size2 = getxattr(path, attributeName, [data mutableBytes], size, 0, 0); 63 | 64 | XDSDocumentSourceAttribute *attribute = nil; 65 | if (size2 == size) { 66 | NSDictionary *dict = [NSPropertyListSerialization propertyListWithData:data 67 | options:NSPropertyListImmutable 68 | format:nil 69 | error:nil]; 70 | 71 | if (dict != nil && [dict isKindOfClass:[NSDictionary class]]) { 72 | attribute = [[XDSDocumentSourceAttribute alloc] init]; 73 | 74 | Class stringClass = [NSString class]; 75 | 76 | id identifier = [dict objectForKey:@"identifier"]; 77 | if ([identifier isKindOfClass:stringClass]) { 78 | attribute.bundleIdentifier = identifier; 79 | } 80 | id name = [dict objectForKey:@"name"]; 81 | if ([name isKindOfClass:stringClass]) { 82 | attribute.applicationName = name; 83 | } 84 | id path = [dict objectForKey:@"path"]; 85 | if ([path isKindOfClass:stringClass]) { 86 | attribute.documentPath = path; 87 | } 88 | id appInfoURL = [dict objectForKey:@"appInfoURL"]; 89 | if ([appInfoURL isKindOfClass:stringClass]) { 90 | attribute.appInfoURL = [NSURL URLWithString:appInfoURL]; 91 | } 92 | } 93 | } 94 | 95 | return attribute; 96 | } 97 | 98 | // Takes a square application icon and applies the superellipse rounded rect. 99 | // Also strokes the path to match the look of the icon in the document picker document provider list. 100 | - (UIImage *)roundedImage:(UIImage *)image withSize:(CGSize)size scale:(CGFloat)scale { 101 | CGFloat cornerRadius = round(0.225 * size.width); 102 | CGRect rect = CGRectMake(0, 0, size.width, size.height); 103 | UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:cornerRadius]; 104 | 105 | UIImage *resultImage; 106 | 107 | UIGraphicsBeginImageContextWithOptions(size, NO, scale); 108 | { 109 | [path addClip]; 110 | [image drawInRect:rect]; 111 | 112 | [[UIColor colorWithWhite:0.0f alpha:0.1f] setStroke]; 113 | [path stroke]; 114 | resultImage = UIGraphicsGetImageFromCurrentImageContext(); 115 | } 116 | UIGraphicsEndImageContext(); 117 | 118 | return resultImage; 119 | } 120 | 121 | - (void)loadIcon:(XDSDocumentSourceAttributeIconType)iconType 122 | withCompletionHandler:(void (^)(UIImage *icon, NSError *error))completionHandler { 123 | NSURL *url = self.appInfoURL; 124 | 125 | if (url) { 126 | // try to load json 127 | NSURLSessionDataTask *task = [[NSURLSession sharedSession] 128 | dataTaskWithURL:url 129 | completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response, NSError *_Nullable error) { 130 | if (!error) { 131 | NSError *jsonError = nil; 132 | 133 | NSDictionary *dictionary = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; 134 | if (jsonError) { 135 | completionHandler(nil, jsonError); 136 | } else { 137 | id object = dictionary[@"icons"]; 138 | if ([object isKindOfClass:[NSArray class]]) { 139 | NSUInteger preferredWidth; 140 | NSUInteger preferredHeight; 141 | CGFloat scale = [UIScreen mainScreen].scale; 142 | 143 | switch (iconType) { 144 | case XDSDocumentSourceAttributeIconTypeSpotlight: 145 | default: 146 | preferredWidth = preferredHeight = 29; 147 | break; 148 | } 149 | 150 | BOOL foundExactMatch = NO; 151 | NSString *foundSrc = nil; 152 | NSInteger foundHeight = NSIntegerMax; 153 | NSInteger foundWidth = NSIntegerMax; 154 | 155 | for (NSDictionary *dict in object) { 156 | if ([dict isKindOfClass:[NSDictionary class]]) { 157 | NSNumber *width = dict[@"width"]; 158 | NSNumber *height = dict[@"height"]; 159 | NSString *src = dict[@"src"]; 160 | 161 | if (width && height && src) { 162 | NSInteger w = [width integerValue]; 163 | NSInteger h = [height integerValue]; 164 | 165 | if (w == preferredWidth * scale && h == preferredHeight * scale) { 166 | // found exact match -> perfect! 167 | foundExactMatch = YES; 168 | foundSrc = src; 169 | foundWidth = w; 170 | foundHeight = h; 171 | break; 172 | } else if (w > preferredWidth * scale && h > preferredHeight * scale 173 | && w < foundWidth && h < foundWidth) { 174 | // found an icon that is larger than our preferred size but smaller than 175 | // our previously found icon 176 | foundSrc = src; 177 | foundWidth = w; 178 | foundHeight = h; 179 | } 180 | } 181 | } 182 | } 183 | if (foundSrc) { 184 | NSURL *imageURL = [[NSURL alloc] initWithString:foundSrc relativeToURL:url]; 185 | if (imageURL) { 186 | // load icon 187 | NSURLSessionDataTask *imageTask = [[NSURLSession sharedSession] 188 | dataTaskWithURL:imageURL 189 | completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response, 190 | NSError *_Nullable error) { 191 | if (error || [data length] == 0) { 192 | completionHandler(nil, error); 193 | } else { 194 | UIImage *image = [UIImage imageWithData:data]; 195 | if (!image) { 196 | // could not load image 197 | completionHandler(nil, nil); 198 | } else { 199 | image = 200 | [self roundedImage:image 201 | withSize:CGSizeMake(preferredWidth, preferredHeight) 202 | scale:scale]; 203 | // finally we have our image! 204 | completionHandler(image, nil); 205 | } 206 | } 207 | }]; 208 | [imageTask resume]; 209 | } else { 210 | // could not parse relative url string 211 | completionHandler(nil, nil); 212 | } 213 | } else { 214 | // no matching icon found 215 | completionHandler(nil, nil); 216 | } 217 | } else { 218 | // no "icon" key in appInfo.json file 219 | completionHandler(nil, nil); 220 | } 221 | } 222 | } else { 223 | // error parsing json file 224 | completionHandler(nil, error); 225 | } 226 | }]; 227 | [task resume]; 228 | } else { 229 | // no "appInfoURL" key found 230 | completionHandler(nil, nil); 231 | } 232 | } 233 | 234 | - (BOOL)writeToURL:(NSURL *)fileURL { 235 | if (![fileURL isFileURL]) { 236 | // we need a file path for setxattr 237 | return NO; 238 | } 239 | 240 | NSDictionary *plist = @{ 241 | @"identifier": self.bundleIdentifier, 242 | @"name": self.applicationName, 243 | @"path": self.documentPath, 244 | @"appInfoURL": self.appInfoURL.absoluteString 245 | }; 246 | 247 | NSData *data = [NSPropertyListSerialization dataWithPropertyList:plist 248 | format:NSPropertyListBinaryFormat_v1_0 249 | options:0 250 | error:NULL]; 251 | 252 | const char *filePath = [fileURL.path fileSystemRepresentation]; 253 | return setxattr(filePath, XDSDocumentSourceAttributeName.UTF8String, data.bytes, data.length, 0, 0) == 0; 254 | } 255 | 256 | @end 257 | --------------------------------------------------------------------------------