Skip to content

Commit 2cc8678

Browse files
dpoguedodahoho
andauthored
feat(scheme-handler): Improve memory usage & Range support (#1481)
* refactor(schemes): Read files on a background thread Closes GH-909. * feat(schemes): Support range requests Based on code from ionic-team/cordova-plugin-ionic-webview#692 Closes GH-1033. Co-Authored-By: David Holmgren <dodahoho@users.noreply.github.com> --------- Co-authored-by: David Holmgren <dodahoho@users.noreply.github.com>
1 parent 8b60dca commit 2cc8678

File tree

5 files changed

+187
-64
lines changed

5 files changed

+187
-64
lines changed

CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m

Lines changed: 166 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ Licensed to the Apache Software Foundation (ASF) under one
2424
#import <Foundation/Foundation.h>
2525
#import <MobileCoreServices/MobileCoreServices.h>
2626

27+
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000
28+
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
29+
#endif
30+
31+
static const NSUInteger FILE_BUFFER_SIZE = 1024 * 1024 * 4; // 4 MiB
32+
2733
@interface CDVURLSchemeHandler ()
2834

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

66+
67+
NSURLRequest *req = urlSchemeTask.request;
68+
if (![req.URL.scheme isEqualToString:self.viewController.appScheme]) {
69+
return;
70+
}
71+
6072
// Indicate that we are handling this task, by adding an entry with a null plugin
6173
// We do this so that we can (in future) detect if the task is cancelled before we finished feeding it response data
6274
[self.handlerMap setObject:(id)[NSNull null] forKey:urlSchemeTask];
6375

64-
NSString * startPath = [[NSBundle mainBundle] pathForResource:self.viewController.webContentFolderName ofType: nil];
65-
NSURL * url = urlSchemeTask.request.URL;
66-
NSString * stringToLoad = url.path;
67-
NSString * scheme = url.scheme;
76+
[self.viewController.commandDelegate runInBackground:^{
77+
NSURL *fileURL = [self fileURLForRequestURL:req.URL];
78+
NSError *error;
6879

69-
if ([scheme isEqualToString:self.viewController.appScheme]) {
70-
if ([stringToLoad hasPrefix:@"/_app_file_"]) {
71-
startPath = [stringToLoad stringByReplacingOccurrencesOfString:@"/_app_file_" withString:@""];
72-
} else {
73-
if ([stringToLoad isEqualToString:@""] || [url.pathExtension isEqualToString:@""]) {
74-
startPath = [startPath stringByAppendingPathComponent:self.viewController.startPage];
75-
} else {
76-
startPath = [startPath stringByAppendingPathComponent:stringToLoad];
80+
NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingFromURL:fileURL error:&error];
81+
if (!fileHandle || error) {
82+
if ([self taskActive:urlSchemeTask]) {
83+
[urlSchemeTask didFailWithError:error];
7784
}
85+
86+
@synchronized(self.handlerMap) {
87+
[self.handlerMap removeObjectForKey:urlSchemeTask];
88+
}
89+
return;
7890
}
79-
}
8091

81-
NSError * fileError = nil;
82-
NSData * data = nil;
83-
if ([self isMediaExtension:url.pathExtension]) {
84-
data = [NSData dataWithContentsOfFile:startPath options:NSDataReadingMappedIfSafe error:&fileError];
85-
}
86-
if (!data || fileError) {
87-
data = [[NSData alloc] initWithContentsOfFile:startPath];
88-
}
89-
NSInteger statusCode = 200;
90-
if (!data) {
91-
statusCode = 404;
92-
}
93-
NSURL * localUrl = [NSURL URLWithString:url.absoluteString];
94-
NSString * mimeType = [self getMimeType:url.pathExtension];
95-
id response = nil;
96-
if (data && [self isMediaExtension:url.pathExtension]) {
97-
response = [[NSURLResponse alloc] initWithURL:localUrl MIMEType:mimeType expectedContentLength:data.length textEncodingName:nil];
98-
} else {
99-
NSDictionary * headers = @{ @"Content-Type" : mimeType, @"Cache-Control": @"no-cache"};
100-
response = [[NSHTTPURLResponse alloc] initWithURL:localUrl statusCode:statusCode HTTPVersion:nil headerFields:headers];
101-
}
92+
NSInteger statusCode = 200; // Default to 200 OK status
93+
NSString *mimeType = [self getMimeType:fileURL] ?: @"application/octet-stream";
94+
NSNumber *fileLength;
95+
[fileURL getResourceValue:&fileLength forKey:NSURLFileSizeKey error:nil];
96+
97+
NSNumber *responseSize = fileLength;
98+
NSUInteger responseSent = 0;
99+
100+
NSMutableDictionary *headers = [NSMutableDictionary dictionaryWithCapacity:5];
101+
headers[@"Content-Type"] = mimeType;
102+
headers[@"Cache-Control"] = @"no-cache";
103+
headers[@"Content-Length"] = [responseSize stringValue];
104+
105+
// Check for Range header
106+
NSString *rangeHeader = [urlSchemeTask.request valueForHTTPHeaderField:@"Range"];
107+
if (rangeHeader) {
108+
NSRange range = NSMakeRange(NSNotFound, 0);
109+
110+
if ([rangeHeader hasPrefix:@"bytes="]) {
111+
NSString *byteRange = [rangeHeader substringFromIndex:6];
112+
NSArray<NSString *> *rangeParts = [byteRange componentsSeparatedByString:@"-"];
113+
NSUInteger start = (NSUInteger)[rangeParts[0] integerValue];
114+
NSUInteger end = rangeParts.count > 1 && ![rangeParts[1] isEqualToString:@""] ? (NSUInteger)[rangeParts[1] integerValue] : [fileLength unsignedIntegerValue] - 1;
115+
range = NSMakeRange(start, end - start + 1);
116+
}
102117

103-
[urlSchemeTask didReceiveResponse:response];
104-
if (data) {
105-
[urlSchemeTask didReceiveData:data];
106-
}
107-
[urlSchemeTask didFinish];
118+
if (range.location != NSNotFound) {
119+
// Ensure range is valid
120+
if (range.location >= [fileLength unsignedIntegerValue] && [self taskActive:urlSchemeTask]) {
121+
headers[@"Content-Range"] = [NSString stringWithFormat:@"bytes */%@", fileLength];
122+
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:req.URL statusCode:416 HTTPVersion:@"HTTP/1.1" headerFields:headers];
123+
[urlSchemeTask didReceiveResponse:response];
124+
[urlSchemeTask didFinish];
125+
126+
@synchronized(self.handlerMap) {
127+
[self.handlerMap removeObjectForKey:urlSchemeTask];
128+
}
129+
return;
130+
}
131+
132+
[fileHandle seekToFileOffset:range.location];
133+
responseSize = [NSNumber numberWithUnsignedInteger:range.length];
134+
statusCode = 206; // Partial Content
135+
headers[@"Content-Range"] = [NSString stringWithFormat:@"bytes %lu-%lu/%@", (unsigned long)range.location, (unsigned long)(range.location + range.length - 1), fileLength];
136+
headers[@"Content-Length"] = [NSString stringWithFormat:@"%lu", (unsigned long)range.length];
137+
}
138+
}
139+
140+
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:req.URL statusCode:statusCode HTTPVersion:@"HTTP/1.1" headerFields:headers];
141+
if ([self taskActive:urlSchemeTask]) {
142+
[urlSchemeTask didReceiveResponse:response];
143+
}
144+
145+
while ([self taskActive:urlSchemeTask] && responseSent < [responseSize unsignedIntegerValue]) {
146+
@autoreleasepool {
147+
NSData *data = [self readFromFileHandle:fileHandle upTo:FILE_BUFFER_SIZE error:&error];
148+
if (!data || error) {
149+
if ([self taskActive:urlSchemeTask]) {
150+
[urlSchemeTask didFailWithError:error];
151+
}
152+
break;
153+
}
154+
155+
if ([self taskActive:urlSchemeTask]) {
156+
[urlSchemeTask didReceiveData:data];
157+
}
158+
159+
responseSent += data.length;
160+
}
161+
}
162+
163+
[fileHandle closeFile];
164+
165+
if ([self taskActive:urlSchemeTask]) {
166+
[urlSchemeTask didFinish];
167+
}
108168

109-
[self.handlerMap removeObjectForKey:urlSchemeTask];
169+
@synchronized(self.handlerMap) {
170+
[self.handlerMap removeObjectForKey:urlSchemeTask];
171+
}
172+
}];
110173
}
111174

112175
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask
113176
{
114-
CDVPlugin <CDVPluginSchemeHandler> *plugin = [self.handlerMap objectForKey:urlSchemeTask];
177+
CDVPlugin <CDVPluginSchemeHandler> *plugin;
178+
@synchronized(self.handlerMap) {
179+
plugin = [self.handlerMap objectForKey:urlSchemeTask];
180+
}
181+
115182
if (![plugin isEqual:[NSNull null]] && [plugin respondsToSelector:@selector(stopSchemeTask:)]) {
116183
[plugin stopSchemeTask:urlSchemeTask];
117184
}
118185

119-
[self.handlerMap removeObjectForKey:urlSchemeTask];
186+
@synchronized(self.handlerMap) {
187+
[self.handlerMap removeObjectForKey:urlSchemeTask];
188+
}
120189
}
121190

122-
-(NSString *) getMimeType:(NSString *)fileExtension {
123-
if (fileExtension && ![fileExtension isEqualToString:@""]) {
124-
NSString *UTI = (__bridge_transfer NSString *)UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)fileExtension, NULL);
125-
NSString *contentType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)UTI, kUTTagClassMIMEType);
126-
return contentType ? contentType : @"application/octet-stream";
191+
#pragma mark - Utility methods
192+
193+
- (NSURL *)fileURLForRequestURL:(NSURL *)url
194+
{
195+
NSURL *resDir = [[NSBundle mainBundle] URLForResource:self.viewController.webContentFolderName withExtension:nil];
196+
NSURL *filePath;
197+
198+
if ([url.path hasPrefix:@"/_app_file_"]) {
199+
NSString *path = [url.path stringByReplacingOccurrencesOfString:@"/_app_file_" withString:@""];
200+
filePath = [resDir URLByAppendingPathComponent:path];
127201
} else {
128-
return @"text/html";
202+
if ([url.path isEqualToString:@""] || [url.pathExtension isEqualToString:@""]) {
203+
filePath = [resDir URLByAppendingPathComponent:self.viewController.startPage];
204+
} else {
205+
filePath = [resDir URLByAppendingPathComponent:url.path];
206+
}
129207
}
208+
209+
return filePath.URLByStandardizingPath;
130210
}
131211

132-
-(BOOL) isMediaExtension:(NSString *) pathExtension {
133-
NSArray * mediaExtensions = @[@"m4v", @"mov", @"mp4",
134-
@"aac", @"ac3", @"aiff", @"au", @"flac", @"m4a", @"mp3", @"wav"];
135-
if ([mediaExtensions containsObject:pathExtension.lowercaseString]) {
136-
return YES;
212+
-(NSString *)getMimeType:(NSURL *)url
213+
{
214+
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000
215+
if (@available(iOS 14.0, *)) {
216+
UTType *uti;
217+
[url getResourceValue:&uti forKey:NSURLContentTypeKey error:nil];
218+
return [uti preferredMIMEType];
137219
}
138-
return NO;
220+
#endif
221+
222+
NSString *type;
223+
[url getResourceValue:&type forKey:NSURLTypeIdentifierKey error:nil];
224+
return (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)type, kUTTagClassMIMEType);
139225
}
140226

227+
- (nullable NSData *)readFromFileHandle:(NSFileHandle *)handle upTo:(NSUInteger)length error:(NSError **)err
228+
{
229+
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000
230+
if (@available(iOS 14.0, *)) {
231+
return [handle readDataUpToLength:length error:err];
232+
}
233+
#endif
234+
235+
@try {
236+
return [handle readDataOfLength:length];
237+
}
238+
@catch (NSError *error) {
239+
if (err != nil) {
240+
*err = error;
241+
}
242+
return nil;
243+
}
244+
}
245+
246+
- (BOOL)taskActive:(id <WKURLSchemeTask>)task
247+
{
248+
@synchronized(self.handlerMap) {
249+
return [self.handlerMap objectForKey:task] != nil;
250+
}
251+
}
141252

142253
@end
254+

CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.m

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,21 +165,19 @@ - (WKWebViewConfiguration*) createConfigurationFromSettings:(CDVSettingsDictiona
165165

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

172-
NSString *scheme = [settings cordovaSettingForKey:@"scheme"];
170+
NSString *scheme = self.viewController.appScheme;
173171

174172
// If scheme is file or nil, then default to file scheme
175-
self.cdvIsFileScheme = [scheme isEqualToString: @"file"] || scheme == nil;
173+
self.cdvIsFileScheme = [scheme isEqualToString:@"file"] || scheme == nil;
176174

177175
NSString *hostname = @"";
178176
if(!self.cdvIsFileScheme) {
179177
if(scheme == nil || [WKWebView handlesURLScheme:scheme]){
180178
scheme = @"app";
179+
self.viewController.appScheme = scheme;
181180
}
182-
vc.appScheme = scheme;
183181

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

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

214212
// Do not configure the scheme handler if the scheme is default (file)
215213
if(!self.cdvIsFileScheme) {
216-
self.schemeHandler = [[CDVURLSchemeHandler alloc] initWithViewController:vc];
214+
self.schemeHandler = [[CDVURLSchemeHandler alloc] initWithViewController:self.viewController];
217215
[configuration setURLSchemeHandler:self.schemeHandler forURLScheme:scheme];
218216
}
219217

CordovaLib/Classes/Public/CDVViewController.m

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,8 @@ - (void)loadSettings
523523
if (self.startPage == nil) {
524524
self.startPage = @"index.html";
525525
}
526+
527+
self.appScheme = [self.settings cordovaSettingForKey:@"Scheme"] ?: @"app";
526528
}
527529

528530
/// Retrieves the view from a newwly initialized webViewEngine

CordovaLib/include/Cordova/CDVPlugin.h

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,6 @@ extern const NSNotificationName CDVViewWillTransitionToSizeNotification;
111111
handling. If this method returns `NO`, Cordova will handle the resource
112112
loading using its default behavior.
113113
114-
Note that all methods of the task object must be called on the main thread.
115-
116114
- Parameters:
117115
- task: The task object that identifies the resource to load. You also use
118116
this object to report the progress of the load operation back to the web

CordovaLib/include/Cordova/CDVViewController.h

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,20 @@ NS_ASSUME_NONNULL_BEGIN
7575
*/
7676
@property (nonatomic, readonly, copy) NSArray <CDVPlugin *> *enumerablePlugins;
7777

78-
@property (nonatomic, readwrite, copy) NSString *appScheme;
78+
/*
79+
The scheme being used to load web content from the app bundle into the Cordova
80+
web view.
81+
82+
The default value is `app` but can be customized via the `Scheme` preference
83+
in the Cordova XML configuration file. Setting this to `file` will results in
84+
web content being loaded using the File URL protocol, which has inherent
85+
security limitations. It is encouraged that you use a custom scheme to load
86+
your app content.
87+
88+
It is not valid to set this to an existing protocol scheme such as `http` or
89+
`https`.
90+
*/
91+
@property (nonatomic, nullable, readwrite, copy) NSString *appScheme;
7992

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

0 commit comments

Comments
 (0)