Files
Felitendo 994f38353c feat: two more signing options, and installing several apps in one go
The library already had multi-select for removing apps. The same selection
now also signs and installs, one app after another: every install ends at
the system's own sheet and only one of those can be up at a time, so the
queue is drained in order rather than in parallel, and each app is signed
only once the previous sheet has been dismissed. Signing a whole selection
up front would fill storage with builds nobody has agreed to install yet. A
failure moves the queue on instead of abandoning it, and the companion
updater is never swept into a bulk action.

Two signing options join it. Liquid Glass can now be turned off as well as
on, which is the more useful direction for an app that already looks right
and stops doing so; the two are mutually exclusive because they write one
key. And a bundled fix for the document picker, off by default, copies
whatever is picked into the app's own container first, so importing works
in an app whose resigned build lost the permission to read it. It is built
from source at build time, and a toolchain that cannot produce it leaves the
option unavailable rather than failing the build.

Saved signing options are now decoded field by field. Every one of them
falls back to its own default, so adding an option no longer resets all of
them for anyone who had changed one.
2026-08-24 15:34:11 +02:00

138 lines
5.8 KiB
Objective-C

//
// 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 <UIKit/UIKit.h>
#import <objc/runtime.h>
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<NSURL *> *urls) {
IMP original = FTSOriginal(self, kOriginalPickURLs);
if (!original) return;
NSMutableArray<NSURL *> *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);
}