Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(scheme-handler): Improve memory usage & Range support #1481

Merged
merged 2 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ Licensed to the Apache Software Foundation (ASF) under one
#import <Foundation/Foundation.h>
#import <MobileCoreServices/MobileCoreServices.h>

#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#endif

static const NSUInteger FILE_BUFFER_SIZE = 1024 * 1024 * 4; // 4 MiB

@interface CDVURLSchemeHandler ()

@property (nonatomic, weak) CDVViewController *viewController;
Expand Down Expand Up @@ -57,86 +63,192 @@ - (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)ur
}
}


NSURLRequest *req = urlSchemeTask.request;
if (![req.URL.scheme isEqualToString:self.viewController.appScheme]) {
return;
}

// Indicate that we are handling this task, by adding an entry with a null plugin
// We do this so that we can (in future) detect if the task is cancelled before we finished feeding it response data
[self.handlerMap setObject:(id)[NSNull null] forKey:urlSchemeTask];

NSString * startPath = [[NSBundle mainBundle] pathForResource:self.viewController.webContentFolderName ofType: nil];
NSURL * url = urlSchemeTask.request.URL;
NSString * stringToLoad = url.path;
NSString * scheme = url.scheme;
[self.viewController.commandDelegate runInBackground:^{
NSURL *fileURL = [self fileURLForRequestURL:req.URL];
NSError *error;

if ([scheme isEqualToString:self.viewController.appScheme]) {
if ([stringToLoad hasPrefix:@"/_app_file_"]) {
startPath = [stringToLoad stringByReplacingOccurrencesOfString:@"/_app_file_" withString:@""];
} else {
if ([stringToLoad isEqualToString:@""] || [url.pathExtension isEqualToString:@""]) {
startPath = [startPath stringByAppendingPathComponent:self.viewController.startPage];
} else {
startPath = [startPath stringByAppendingPathComponent:stringToLoad];
NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingFromURL:fileURL error:&error];
if (!fileHandle || error) {
if ([self taskActive:urlSchemeTask]) {
[urlSchemeTask didFailWithError:error];
}

@synchronized(self.handlerMap) {
[self.handlerMap removeObjectForKey:urlSchemeTask];
}
return;
}
}

NSError * fileError = nil;
NSData * data = nil;
if ([self isMediaExtension:url.pathExtension]) {
data = [NSData dataWithContentsOfFile:startPath options:NSDataReadingMappedIfSafe error:&fileError];
}
if (!data || fileError) {
data = [[NSData alloc] initWithContentsOfFile:startPath];
}
NSInteger statusCode = 200;
if (!data) {
statusCode = 404;
}
NSURL * localUrl = [NSURL URLWithString:url.absoluteString];
NSString * mimeType = [self getMimeType:url.pathExtension];
id response = nil;
if (data && [self isMediaExtension:url.pathExtension]) {
response = [[NSURLResponse alloc] initWithURL:localUrl MIMEType:mimeType expectedContentLength:data.length textEncodingName:nil];
} else {
NSDictionary * headers = @{ @"Content-Type" : mimeType, @"Cache-Control": @"no-cache"};
response = [[NSHTTPURLResponse alloc] initWithURL:localUrl statusCode:statusCode HTTPVersion:nil headerFields:headers];
}
NSInteger statusCode = 200; // Default to 200 OK status
NSString *mimeType = [self getMimeType:fileURL] ?: @"application/octet-stream";
NSNumber *fileLength;
[fileURL getResourceValue:&fileLength forKey:NSURLFileSizeKey error:nil];

NSNumber *responseSize = fileLength;
NSUInteger responseSent = 0;

NSMutableDictionary *headers = [NSMutableDictionary dictionaryWithCapacity:5];
headers[@"Content-Type"] = mimeType;
headers[@"Cache-Control"] = @"no-cache";
headers[@"Content-Length"] = [responseSize stringValue];

// Check for Range header
NSString *rangeHeader = [urlSchemeTask.request valueForHTTPHeaderField:@"Range"];
if (rangeHeader) {
NSRange range = NSMakeRange(NSNotFound, 0);

if ([rangeHeader hasPrefix:@"bytes="]) {
NSString *byteRange = [rangeHeader substringFromIndex:6];
NSArray<NSString *> *rangeParts = [byteRange componentsSeparatedByString:@"-"];
NSUInteger start = (NSUInteger)[rangeParts[0] integerValue];
NSUInteger end = rangeParts.count > 1 && ![rangeParts[1] isEqualToString:@""] ? (NSUInteger)[rangeParts[1] integerValue] : [fileLength unsignedIntegerValue] - 1;
range = NSMakeRange(start, end - start + 1);
}

[urlSchemeTask didReceiveResponse:response];
if (data) {
[urlSchemeTask didReceiveData:data];
}
[urlSchemeTask didFinish];
if (range.location != NSNotFound) {
// Ensure range is valid
if (range.location >= [fileLength unsignedIntegerValue] && [self taskActive:urlSchemeTask]) {
headers[@"Content-Range"] = [NSString stringWithFormat:@"bytes */%@", fileLength];
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:req.URL statusCode:416 HTTPVersion:@"HTTP/1.1" headerFields:headers];
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didFinish];

@synchronized(self.handlerMap) {
[self.handlerMap removeObjectForKey:urlSchemeTask];
}
return;
}

[fileHandle seekToFileOffset:range.location];
responseSize = [NSNumber numberWithUnsignedInteger:range.length];
statusCode = 206; // Partial Content
headers[@"Content-Range"] = [NSString stringWithFormat:@"bytes %lu-%lu/%@", (unsigned long)range.location, (unsigned long)(range.location + range.length - 1), fileLength];
headers[@"Content-Length"] = [NSString stringWithFormat:@"%lu", (unsigned long)range.length];
}
}

NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:req.URL statusCode:statusCode HTTPVersion:@"HTTP/1.1" headerFields:headers];
if ([self taskActive:urlSchemeTask]) {
[urlSchemeTask didReceiveResponse:response];
}

while ([self taskActive:urlSchemeTask] && responseSent < [responseSize unsignedIntegerValue]) {
@autoreleasepool {
NSData *data = [self readFromFileHandle:fileHandle upTo:FILE_BUFFER_SIZE error:&error];
if (!data || error) {
if ([self taskActive:urlSchemeTask]) {
[urlSchemeTask didFailWithError:error];
}
break;
}

if ([self taskActive:urlSchemeTask]) {
[urlSchemeTask didReceiveData:data];
}

responseSent += data.length;
}
}

[fileHandle closeFile];

if ([self taskActive:urlSchemeTask]) {
[urlSchemeTask didFinish];
}

[self.handlerMap removeObjectForKey:urlSchemeTask];
@synchronized(self.handlerMap) {
[self.handlerMap removeObjectForKey:urlSchemeTask];
}
}];
}

- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask
{
CDVPlugin <CDVPluginSchemeHandler> *plugin = [self.handlerMap objectForKey:urlSchemeTask];
CDVPlugin <CDVPluginSchemeHandler> *plugin;
@synchronized(self.handlerMap) {
plugin = [self.handlerMap objectForKey:urlSchemeTask];
}

if (![plugin isEqual:[NSNull null]] && [plugin respondsToSelector:@selector(stopSchemeTask:)]) {
[plugin stopSchemeTask:urlSchemeTask];
}

[self.handlerMap removeObjectForKey:urlSchemeTask];
@synchronized(self.handlerMap) {
[self.handlerMap removeObjectForKey:urlSchemeTask];
}
}

-(NSString *) getMimeType:(NSString *)fileExtension {
if (fileExtension && ![fileExtension isEqualToString:@""]) {
NSString *UTI = (__bridge_transfer NSString *)UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)fileExtension, NULL);
NSString *contentType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)UTI, kUTTagClassMIMEType);
return contentType ? contentType : @"application/octet-stream";
#pragma mark - Utility methods

- (NSURL *)fileURLForRequestURL:(NSURL *)url
{
NSURL *resDir = [[NSBundle mainBundle] URLForResource:self.viewController.webContentFolderName withExtension:nil];
NSURL *filePath;

if ([url.path hasPrefix:@"/_app_file_"]) {
NSString *path = [url.path stringByReplacingOccurrencesOfString:@"/_app_file_" withString:@""];
filePath = [resDir URLByAppendingPathComponent:path];
} else {
return @"text/html";
if ([url.path isEqualToString:@""] || [url.pathExtension isEqualToString:@""]) {
filePath = [resDir URLByAppendingPathComponent:self.viewController.startPage];
} else {
filePath = [resDir URLByAppendingPathComponent:url.path];
}
}

return filePath.URLByStandardizingPath;
}

-(BOOL) isMediaExtension:(NSString *) pathExtension {
NSArray * mediaExtensions = @[@"m4v", @"mov", @"mp4",
@"aac", @"ac3", @"aiff", @"au", @"flac", @"m4a", @"mp3", @"wav"];
if ([mediaExtensions containsObject:pathExtension.lowercaseString]) {
return YES;
-(NSString *)getMimeType:(NSURL *)url
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000
if (@available(iOS 14.0, *)) {
UTType *uti;
[url getResourceValue:&uti forKey:NSURLContentTypeKey error:nil];
return [uti preferredMIMEType];
}
return NO;
#endif

NSString *type;
[url getResourceValue:&type forKey:NSURLTypeIdentifierKey error:nil];
return (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)type, kUTTagClassMIMEType);
}

- (nullable NSData *)readFromFileHandle:(NSFileHandle *)handle upTo:(NSUInteger)length error:(NSError **)err
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000
if (@available(iOS 14.0, *)) {
return [handle readDataUpToLength:length error:err];
}
#endif

@try {
return [handle readDataOfLength:length];
}
@catch (NSError *error) {
if (err != nil) {
*err = error;
}
return nil;
}
}

- (BOOL)taskActive:(id <WKURLSchemeTask>)task
{
@synchronized(self.handlerMap) {
return [self.handlerMap objectForKey:task] != nil;
}
}

@end

Original file line number Diff line number Diff line change
Expand Up @@ -165,21 +165,19 @@ - (WKWebViewConfiguration*) createConfigurationFromSettings:(CDVSettingsDictiona

- (void)pluginInitialize
{
// viewController would be available now. we attempt to set all possible delegates to it, by default
CDVViewController* vc = (CDVViewController*)self.viewController;
CDVSettingsDictionary* settings = self.commandDelegate.settings;

NSString *scheme = [settings cordovaSettingForKey:@"scheme"];
NSString *scheme = self.viewController.appScheme;

// If scheme is file or nil, then default to file scheme
self.cdvIsFileScheme = [scheme isEqualToString: @"file"] || scheme == nil;
self.cdvIsFileScheme = [scheme isEqualToString:@"file"] || scheme == nil;

NSString *hostname = @"";
if(!self.cdvIsFileScheme) {
if(scheme == nil || [WKWebView handlesURLScheme:scheme]){
scheme = @"app";
self.viewController.appScheme = scheme;
}
vc.appScheme = scheme;

hostname = [settings cordovaSettingForKey:@"hostname"];
if(hostname == nil){
Expand All @@ -189,7 +187,7 @@ - (void)pluginInitialize
self.CDV_ASSETS_URL = [NSString stringWithFormat:@"%@://%@", scheme, hostname];
}

CDVWebViewUIDelegate* uiDelegate = [[CDVWebViewUIDelegate alloc] initWithViewController:vc];
CDVWebViewUIDelegate* uiDelegate = [[CDVWebViewUIDelegate alloc] initWithViewController:self.viewController];
uiDelegate.title = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"];
uiDelegate.allowNewWindows = [settings cordovaBoolSettingForKey:@"AllowNewWindows" defaultValue:NO];
self.uiDelegate = uiDelegate;
Expand All @@ -213,7 +211,7 @@ - (void)pluginInitialize

// Do not configure the scheme handler if the scheme is default (file)
if(!self.cdvIsFileScheme) {
self.schemeHandler = [[CDVURLSchemeHandler alloc] initWithViewController:vc];
self.schemeHandler = [[CDVURLSchemeHandler alloc] initWithViewController:self.viewController];
[configuration setURLSchemeHandler:self.schemeHandler forURLScheme:scheme];
}

Expand Down
2 changes: 2 additions & 0 deletions CordovaLib/Classes/Public/CDVViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,8 @@ - (void)loadSettings
if (self.startPage == nil) {
self.startPage = @"index.html";
}

self.appScheme = [self.settings cordovaSettingForKey:@"Scheme"] ?: @"app";
}

/// Retrieves the view from a newwly initialized webViewEngine
Expand Down
2 changes: 0 additions & 2 deletions CordovaLib/include/Cordova/CDVPlugin.h
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,6 @@ extern const NSNotificationName CDVViewWillTransitionToSizeNotification;
handling. If this method returns `NO`, Cordova will handle the resource
loading using its default behavior.

Note that all methods of the task object must be called on the main thread.

- Parameters:
- task: The task object that identifies the resource to load. You also use
this object to report the progress of the load operation back to the web
Expand Down
15 changes: 14 additions & 1 deletion CordovaLib/include/Cordova/CDVViewController.h
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,20 @@ NS_ASSUME_NONNULL_BEGIN
*/
@property (nonatomic, readonly, copy) NSArray <CDVPlugin *> *enumerablePlugins;

@property (nonatomic, readwrite, copy) NSString *appScheme;
/*
The scheme being used to load web content from the app bundle into the Cordova
web view.

The default value is `app` but can be customized via the `Scheme` preference
in the Cordova XML configuration file. Setting this to `file` will results in
web content being loaded using the File URL protocol, which has inherent
security limitations. It is encouraged that you use a custom scheme to load
your app content.

It is not valid to set this to an existing protocol scheme such as `http` or
`https`.
*/
@property (nonatomic, nullable, readwrite, copy) NSString *appScheme;

@property (nonatomic, readonly, strong) CDVCommandQueue *commandQueue;
@property (nonatomic, readonly, strong) id <CDVCommandDelegate> commandDelegate;
Expand Down