Loading Offline Resources in WKWebView with a Local Web Server

1. Background

The author mentioned in the article 《WKWebView》 that WKWebView performs network requests in a process independent of the app process, and the requested data does not pass through the main process. Therefore, using NSURLProtocol directly on WKWebView cannot intercept the request. Therefore, if you need to intercept requests, a feasible solution is to use the private API exposed by Apple's open source Webkit2 source code (see section 3 of the original article for details: NSURLProtocol issue).
However, using private API will inevitably bring about the following problems:

  • Audit risk
  • When intercepting http/https, the post request body is lost
  • If ajax hook is used, there may be post header character length limit, Put type request exception, etc.

From this point of view, before the arrival of iOS11 WKURLSchemeHandler [Explore], the private API was not so perfect.
Fortunately, through searching, we found that the iOS system has the ability to build a server, and it is theoretically possible to implement WKWebView offline resource loading.

2. Analysis

Local web server based on iOS currently has the following relatively complete frameworks:

  • CocoaHttpServer (Supports iOS, macOS and various network scenarios)
  • GCDWebServer (based on iOS, does not support https and webSocket)
  • Telegraph (Swift implementation, more complete functions than the above two categories)

Because most APPs currently support ATS, and most domestic project codes are still implemented using OC, this article will conduct experiments based on CocoaHttpSever.
Telegraph was born to supplement the shortcomings of CocoaHttpSever and GCDWebServer. For pure Swift projects, it is recommended to use Telegraph.

3. First Attempt

After adding CocoaHTTPServer to the project:

  1. First implement a service management.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#import "LocalWebServerManager.h"

#import "HTTPServer.h"
#import "MyHTTPConnection.h"

@interface LocalWebServerManager ()
{
HTTPServer *_httpServer;
}
@end

@implementation LocalWebServerManager

+ (instancetype)sharedInstance {
static LocalWebServerManager *_sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_sharedInstance = [[LocalWebServerManager alloc] init];
});
return _sharedInstance;
}

- (void)start {

_port = 60000;

if (!_httpServer) {
_httpServer = [[HTTPServer alloc] init];
[_httpServer setType:@"_http._tcp."];
[_httpServer setPort:_port];
NSString * webLocalPath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"Resource"];
[_httpServer setDocumentRoot:webLocalPath];

NSLog(@"Setting document root: %@", webLocalPath);

}

if (_httpServer && ![_httpServer isRunning]) {
NSError *error;
if([_httpServer start:&error]) {
NSLog(@"start server success in port %d %@", [_httpServer listeningPort], [_httpServer publishedName]);
} else {
NSLog(@"Start failed");
}
}

}

- (void)stop {
if (_httpServer && [_httpServer isRunning]) {
[_httpServer stop];
}
}

@end
  1. Then select its startup time, usually in AppDelegate or before WKWebView request.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- (void)viewDidLoad {
[super viewDidLoad];

//Setup WKWebView
WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
WKUserContentController *controller = [[WKUserContentController alloc] init];
configuration.userContentController = controller;
configuration.processPool = [[WKProcessPool alloc] init];

_wkWebView = [[WKWebView alloc] initWithFrame:self.view.bounds
configuration:configuration];
_wkWebView.navigationDelegate = self;
_wkWebView.UIDelegate = self;

[self.view addSubview:_wkWebView];

//Start local web server
[[LocalWebServerManager sharedInstance] start];

//Local request which use local resource
[self loadLocalRequest];

//Remote request which use local resource
// [self loadRemoteRequest];
}
  1. Add a referenced resource directory (the blue folder) to the project, then create index.html and hi.js in that directory.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Hello</title>
<script type="text/javascript" src="http://localhost:60000/hi.js"></script>
</head>
<body bgcolor="#4F8FFF">
<center>
<h1><br><br><br><br><br><br>Congratulations! The server is running successfully!</h1>
<h5><br>Click the button below to try it out</h5>
<input type='button' value='Calls the method ' in the local js resource onclick='invokeAlert()'/>
</center>
</body>
</html>
1
2
3
function invokeAlert() {
alert('Perfect!')
}
  1. Access http://localhost:60000/index.html through WKWebView.

After the above 4 steps, you can start the iOS local http service and access local html resources through WKWebView.

However, the purpose of this article is "Implementing WKWebView offline resource loading based on LocalWebServer". The so-called offline resource loading refers to: the page resources are on the remote server, and some resources in the page are in the iOS local sandbox.
Based on the above example, that is, index.html should be run on remote services such as nginx or apache, and hi.js should be stored in the resource directory of the app.

Is this feasible? Let's give it a try.
Rename index.html to demo.html and configure it in http://smallfan.net for direct access

1
2
3
4
5
- (void)loadRemoteRequest {
if (_wkWebView) {
[_wkWebView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://smallfan.net/demo.html"]]];
}
}

Experimental result: feasible.
That is to say, it is achieved in the following way: Resources in HTML pages allow requests to local servers.

1
<script type="text/javascript" src="http://localhost:60000/hi.js"></script>

4. Support https

As mentioned before: supporting ATS has become a correct and effective choice.

In Safari and Apple WebKit: In https pages, http requests are not allowed, otherwise they will be blocked.

So if https is already supported, then http://localhost:60000/hi.js in the page must also support https. The problem is that for the https page, we can use a legal certificate issued by the CA for two-way or one-way authentication, but localhost is not a legal host, which means we need to implement a self-signed certificate for it.

4.1 Implement local server self-signed certificate

Because iOS development uses MacOS, the following behavior assumes that OpenSSL has been installed on the system by default.

First go to OpenSSL official website to download the latest version, unzip it and find CA.sh in the app directory, copy it to the root directory, and then run

1
% sh ~/CA.sh -newca

After running, a demoCA directory will be generated, which stores the CA's certificate and private key. At the same time, remember the authorization password you set.
Create a directory

1
% mkdir server

Then create a private key

1
% openssl genrsa -out server/server-key.pem 2048

Create certificate request

1
% openssl req -new -out server/server-req.csr -key server/server-key.pem

At this time, the terminal will ask you to fill in the area, name and other information. Common Name This item must be filled in localhost or 127.0.0.1. If you fill in 127.0.0.1, then the request on the page can only use https://127.0.0.1:60000 and cannot be used. https://localhost:60000, and vice versa.

Self-signed certificate

1
% openssl x509 -req -in server/server-req.csr -out server/server-cert.pem -signkey server/server-key.pem -CA demoCA/cacert.pem -CAkey demoCA/private/cakey.pem -CAcreateserial -days 3650

Export the certificate to .p12 format supported by the browser, remember to export the password (this article is: b123456)

1
% openssl pkcs12 -export -clcerts -in server/server-cert.pem -inkey server/server-key.pem -out server/server.p12

Since then, the self-signed certificate has been generated.

4.2 Configure self-signed certificate

Import the P12 file into the project's resource directory.
Then create MyHttpConnection as a subclass of HttpConnection and override - (BOOL)isSecureServer and sslIdentityAndCertificates.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#import "MyHTTPConnection.h"

@implementation MyHTTPConnection

- (BOOL)isSecureServer {
return YES;
}

- (NSArray *)sslIdentityAndCertificates {

SecIdentityRef identityRef = NULL;
SecCertificateRef certificateRef = NULL;
SecTrustRef trustRef = NULL;

//p12File resource path
NSString *thePath = [[NSBundle bundleWithPath:[[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"Resource"]] pathForResource:@"localhost" ofType:@"p12"];
NSData *PKCS12Data = [[NSData alloc] initWithContentsOfFile:thePath];
CFDataRef inPKCS12Data = (__bridge CFDataRef)PKCS12Data;
//p12File export password
CFStringRef password = CFSTR("b123456");
const void *keys[] = { kSecImportExportPassphrase };
const void *values[] = { password };
CFDictionaryRef optionsDictionary = CFDictionaryCreate(NULL, keys, values, 1, NULL, NULL);
CFArrayRef items = CFArrayCreate(NULL, 0, 0, NULL);

OSStatus securityError = errSecSuccess;
securityError = SecPKCS12Import(inPKCS12Data, optionsDictionary, &items);
if (securityError == 0) {
CFDictionaryRef myIdentityAndTrust = CFArrayGetValueAtIndex (items, 0);
const void *tempIdentity = NULL;
tempIdentity = CFDictionaryGetValue (myIdentityAndTrust, kSecImportItemIdentity);
identityRef = (SecIdentityRef)tempIdentity;
const void *tempTrust = NULL;
tempTrust = CFDictionaryGetValue (myIdentityAndTrust, kSecImportItemTrust);
trustRef = (SecTrustRef)tempTrust;
} else {
NSLog(@"Failed with error code %d",(int)securityError);
return nil;
}

SecIdentityCopyCertificate(identityRef, &certificateRef);
NSArray *result = [[NSArray alloc] initWithObjects:(__bridge id)identityRef, (__bridge id)certificateRef, nil];

return result;
}

@end

Place HTTPConnetion.m in the startConnection method

1
[settings setObject:(NSString *)kCFStreamSocketSecurityLevelNegotiatedSSL forKey:(NSString *)kCFStreamSSLLevel];

Replace with

1
2
[settings setObject:@"2" forKey:GCDAsyncSocketSSLProtocolVersionMin];
[settings setObject:@"2" forKey:GCDAsyncSocketSSLProtocolVersionMax];

At the same time, before starting HTTPServer (calling startServer), use the setConnectionClass method to replace HTTPConnecdtion with MyHTTPConnection

1
[_httpServer setConnectionClass:[MyHTTPConnection class]];

Since then, the https service configuration is completed.

4.3 WKWebView certificate configuration

For self-signed certificates, WKWebView needs to allow in the WKNavagationDelegate method:

1
2
3
4
5
6
7
8
9
-                   (void)webView:(WKWebView *)webView
didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * _Nullable credential))completionHandler {

if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
NSURLCredential *card = [[NSURLCredential alloc] initWithTrust:challenge.protectionSpace.serverTrust];
completionHandler(NSURLSessionAuthChallengeUseCredential, card);
}
}

4.4 ATS settings

In Info.plist, find the App Transport Security Settings item, add one item Allow Arbitrary Loads in Web Content and set it to YES

1
2
3
4
5
6
7
8
9
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<key>NSAllowsArbitraryLoads</key>
<false/>
</dict>

Through the above configuration, you can achieve https request local resources

1
<script type="text/javascript" src="https://localhost:60000/hi.js"></script>
1
2
3
4
5
6
7
8
9
10
11
- (void)loadRemoteRequest {
if (_wkWebView) {
[_wkWebView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://smallfan.net/demo.html"]]];
}
}

- (void)loadLocalRequest {
if (_wkWebView) {
[_wkWebView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"https://localhost:%ld/index.html", [[LocalWebServerManager sharedInstance] port] ] ]]];
}
}

5. Summary

In fact, this method of implementing WKWebView offline loading is a strange trick. Because of Apple's closed ecosystem, it is often difficult to optimize some existing things, and we can only continue to explore.
From this perspective, Python is more fun, 23333…

Regarding some issues in local web server, the author has not had time to conduct more in-depth research in the future. Here are some possible issues. Everyone is welcome to discuss them:

  • Resource access permission security issues
  • When switching between the front and backend of the app, the service restart performance is time consuming.
  • When the service is running, power and CPU usage issues
  • Multi-threading and disk IO issues

Finally, throw out a Demo, Biu...
Demo address: LocalWebServer