// // FilePickerFix.m // FTStore // // Adapted from RyukSign (github.com/faroukbmiled/RyukSign), GPL-3.0, which is // a fork of the same upstream this app is. // // Why this exists: a sideloaded app is signed with a certificate that does not // carry the entitlements the original was built against, and one of the things // that quietly stops working is the document picker. The picker still opens, // the person still picks a file, and the app is handed a security-scoped URL // outside its own container that it has no right to read. Every app that // imports a file this way then fails at the point of reading it, which reads to // the person as "this app is broken", not as "this build is missing an // entitlement". // // So: force the picker into copy mode, and move anything that still arrives // from outside the sandbox into the app's own temporary directory before the // app's delegate ever sees the URL. // // Injected only when "Fix File Picker" is on; it is off by default because an // app whose picker already works does not need its callbacks rewritten. // #import #import static const void *kOriginalPickURLs = &kOriginalPickURLs; static const void *kOriginalPickURL = &kOriginalPickURL; /// Copies a URL that lives outside the container into the app's own tmp, and /// hands back a URL the app can actually open. A URL already inside the /// container is returned untouched. static NSURL *FTSRelocate(NSURL *url) { if (!url.isFileURL || [url.path hasPrefix:NSHomeDirectory()]) return url; BOOL scoped = [url startAccessingSecurityScopedResource]; NSString *directory = [NSTemporaryDirectory() stringByAppendingPathComponent: [@"FilePickerFix" stringByAppendingPathComponent:NSUUID.UUID.UUIDString]]; NSURL *destination = [NSURL fileURLWithPath:[directory stringByAppendingPathComponent:url.lastPathComponent]]; NSFileManager *manager = NSFileManager.defaultManager; NSError *error = nil; BOOL copied = [manager createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:&error] && [manager copyItemAtURL:url toURL:destination error:&error]; if (scoped) [url stopAccessingSecurityScopedResource]; if (!copied) { // Handing back the original is no worse than doing nothing, which is what // this app did before the dylib was injected. NSLog(@"[FilePickerFix] could not copy %@: %@", url.lastPathComponent, error.localizedDescription); return url; } return destination; } /// Walks up the hierarchy for the implementation we replaced, so a subclass that /// inherits the delegate method still finds the original to call through to. static IMP FTSOriginal(id delegate, const void *key) { for (Class cls = object_getClass(delegate); cls; cls = class_getSuperclass(cls)) { NSValue *stored = objc_getAssociatedObject(cls, key); if (stored) return stored.pointerValue; } return NULL; } static void FTSPickedURLs(id self, SEL _cmd, id picker, NSArray *urls) { IMP original = FTSOriginal(self, kOriginalPickURLs); if (!original) return; NSMutableArray *relocated = [NSMutableArray arrayWithCapacity:urls.count]; for (NSURL *url in urls) [relocated addObject:FTSRelocate(url)]; ((void (*)(id, SEL, id, id))original)(self, _cmd, picker, relocated); } static void FTSPickedURL(id self, SEL _cmd, id picker, NSURL *url) { IMP original = FTSOriginal(self, kOriginalPickURL); if (!original) return; ((void (*)(id, SEL, id, id))original)(self, _cmd, picker, FTSRelocate(url)); } /// Replaces the callback on the delegate's *own* class rather than wherever it /// was declared, so two sibling classes inheriting one implementation do not /// lose track of which original each should call. static void FTSPatchDelegate(Class cls, SEL selector, IMP replacement, const void *key) { Method existing = class_getInstanceMethod(cls, selector); if (!existing || method_getImplementation(existing) == replacement) return; IMP previous = class_replaceMethod(cls, selector, replacement, method_getTypeEncoding(existing)); if (!previous) previous = method_getImplementation(existing); objc_setAssociatedObject(cls, key, [NSValue valueWithPointer:previous], OBJC_ASSOCIATION_RETAIN_NONATOMIC); } static void (*FTSOriginalSetDelegate)(id, SEL, id); static void FTSSetDelegate(id self, SEL _cmd, id delegate) { if (delegate) { Class cls = object_getClass(delegate); FTSPatchDelegate(cls, @selector(documentPicker:didPickDocumentsAtURLs:), (IMP)FTSPickedURLs, kOriginalPickURLs); FTSPatchDelegate(cls, @selector(documentPicker:didPickDocumentAtURL:), (IMP)FTSPickedURL, kOriginalPickURL); } FTSOriginalSetDelegate(self, _cmd, delegate); } static id (*FTSOriginalInitWithTypes)(id, SEL, NSArray *, NSInteger); /// Open mode is the one that hands back a URL the app has no right to read. static id FTSInitWithTypes(id self, SEL _cmd, NSArray *types, NSInteger mode) { if (mode == UIDocumentPickerModeOpen) mode = UIDocumentPickerModeImport; return FTSOriginalInitWithTypes(self, _cmd, types, mode); } static id (*FTSOriginalInitForOpening)(id, SEL, NSArray *, BOOL); static id FTSInitForOpening(id self, SEL _cmd, NSArray *types, BOOL asCopy) { return FTSOriginalInitForOpening(self, _cmd, types, YES); } static void FTSSwizzle(Class cls, SEL selector, IMP replacement, IMP *original) { Method method = class_getInstanceMethod(cls, selector); if (!method) return; *original = method_setImplementation(method, replacement); } __attribute__((constructor)) static void FTSInstall(void) { Class picker = UIDocumentPickerViewController.class; FTSSwizzle(picker, @selector(initWithDocumentTypes:inMode:), (IMP)FTSInitWithTypes, (IMP *)&FTSOriginalInitWithTypes); FTSSwizzle(picker, @selector(initForOpeningContentTypes:asCopy:), (IMP)FTSInitForOpening, (IMP *)&FTSOriginalInitForOpening); FTSSwizzle(picker, @selector(setDelegate:), (IMP)FTSSetDelegate, (IMP *)&FTSOriginalSetDelegate); }