diff --git a/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m b/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m index 17e797648..fbb0cf774 100644 --- a/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m +++ b/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m @@ -24,6 +24,12 @@ Licensed to the Apache Software Foundation (ASF) under one #import #import +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 +#import +#endif + +static const NSUInteger FILE_BUFFER_SIZE = 1024 * 1024 * 4; // 4 MiB + @interface CDVURLSchemeHandler () @property (nonatomic, weak) CDVViewController *viewController; @@ -57,86 +63,192 @@ - (void)webView:(WKWebView *)webView startURLSchemeTask:(id )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 *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 )urlSchemeTask { - CDVPlugin *plugin = [self.handlerMap objectForKey:urlSchemeTask]; + CDVPlugin *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 )task +{ + @synchronized(self.handlerMap) { + return [self.handlerMap objectForKey:task] != nil; + } +} @end + diff --git a/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.m b/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.m index 78e066fd2..41b04fc3f 100644 --- a/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.m +++ b/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.m @@ -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){ @@ -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; @@ -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]; } diff --git a/CordovaLib/Classes/Public/CDVViewController.m b/CordovaLib/Classes/Public/CDVViewController.m index 9a74b106d..af4944d4d 100644 --- a/CordovaLib/Classes/Public/CDVViewController.m +++ b/CordovaLib/Classes/Public/CDVViewController.m @@ -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 diff --git a/CordovaLib/include/Cordova/CDVPlugin.h b/CordovaLib/include/Cordova/CDVPlugin.h index d878539be..981675fc5 100644 --- a/CordovaLib/include/Cordova/CDVPlugin.h +++ b/CordovaLib/include/Cordova/CDVPlugin.h @@ -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 diff --git a/CordovaLib/include/Cordova/CDVViewController.h b/CordovaLib/include/Cordova/CDVViewController.h index e9f4751c9..434a085f4 100644 --- a/CordovaLib/include/Cordova/CDVViewController.h +++ b/CordovaLib/include/Cordova/CDVViewController.h @@ -75,7 +75,20 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, readonly, copy) NSArray *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 commandDelegate;