瀏覽代碼

Add more state tracking to library

This involved moving a lot of logic out of Psiphon Browser and into the library.
While doing this the sample app was improved to support app lifecycle considerations.
Adam Pritchard 8 年之前
父節點
當前提交
6786a758e0

+ 38 - 7
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h

@@ -48,7 +48,7 @@ FOUNDATION_EXPORT const unsigned char PsiphonTunnelVersionString[];
  */
 typedef NS_ENUM(NSInteger, PsiphonConnectionState)
 {
-    PsiphonConnectionStateDisconnected,
+    PsiphonConnectionStateDisconnected = 0,
     PsiphonConnectionStateConnecting,
     PsiphonConnectionStateConnected,
     PsiphonConnectionStateWaitingForNetwork
@@ -68,7 +68,7 @@ typedef NS_ENUM(NSInteger, PsiphonConnectionState)
 @required
 
 /*!
- Called when tunnel is started to get the library consumer's desired configuration.
+ Called when tunnel is starting to get the library consumer's desired configuration.
 
  @code
  Required fields:
@@ -113,12 +113,18 @@ typedef NS_ENUM(NSInteger, PsiphonConnectionState)
  See the tunnel-core config code for details about the fields.
  https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/master/psiphon/config.go
  
- @return NSString  JSON string with config that should used to run the Psiphon tunnel, or nil on error.
+ @return  JSON string with config that should used to run the Psiphon tunnel, or NULL on error.
  
  Swift: @code func getPsiphonConfig() -> String? @endcode
  */
 - (NSString * _Nullable)getPsiphonConfig;
 
+/*!
+ Called when the tunnel is starting to get the initial server entries (typically embedded in the app) that will be used to bootstrap the Psiphon tunnel connection. This value is in a particular format and will be supplied by Psiphon Inc.
+ @return  Pre-existing server entries to use when attempting to connect to a server. Must return an empty string if there are no embedded server entries. Must return NULL if there is an error and the tunnel starting should abort.
+ */
+- (NSString * _Nullable)getEmbeddedServerEntries;
+
 //
 // Optional delegate methods. Note that some of these are probably necessary for
 // for a functioning app to implement, for example `onConnected`.
@@ -169,6 +175,17 @@ typedef NS_ENUM(NSInteger, PsiphonConnectionState)
  */
 - (void)onExiting;
 
+/*!
+ Called when the device's Internet connection state is interrupted.
+ This may mean that it had connectivity and now doesn't, or went from Wi-Fi to
+ WWAN or vice versa. 
+ @note For many/most apps, the response to this callback should be to restart 
+ the Psiphon tunnel. It will eventually notice and begin reconnecting, but it
+ may take much longer, depending on attempts to use the tunnel.
+ Swift: @code func onDeviceInternetConnectivityInterrupted() @endcode
+ */
+- (void)onDeviceInternetConnectivityInterrupted;
+
 /*!
  Called when tunnel-core determines which server egress regions are available
  for use. This can be used for updating the UI which provides the options to
@@ -287,12 +304,12 @@ typedef NS_ENUM(NSInteger, PsiphonConnectionState)
 + (PsiphonTunnel * _Nonnull)newPsiphonTunnel:(id<TunneledAppDelegate> _Nonnull)tunneledAppDelegate;
 
 /*!
- Start connecting the PsiphonTunnel. Returns before connection is complete -- delegate callbacks (such as `onConnected`) are used to indicate progress and state.
- @param embeddedServerEntries  Pre-existing server entries to use when attempting to connect to a server. May be null if there are no embedded server entries.
+ Start connecting the PsiphonTunnel. Returns before connection is complete -- delegate callbacks (such as `onConnected` and `onConnectionStateChanged`) are used to indicate progress and state.
+ @param ifNeeded  If TRUE, the tunnel will only be started if it's not already connected and healthy. If FALSE, the tunnel will be forced to stop and reconnect.
  @return TRUE if the connection start was successful, FALSE otherwise.
- Swift: @code open func start(_ embeddedServerEntries: String?) -> Bool @endcode
+ Swift: @code open func start(_ ifNeeded: Bool) -> Bool @endcode
  */
-- (BOOL)start:(NSString * _Nullable)embeddedServerEntries;
+- (BOOL)start:(BOOL)ifNeeded;
 
 /*!
  Stop the tunnel (regardless of its current connection state). Returns before full stop is complete -- `TunneledAppDelegate::onExiting` is called when complete.
@@ -307,6 +324,20 @@ typedef NS_ENUM(NSInteger, PsiphonConnectionState)
  */
 - (PsiphonConnectionState)getConnectionState;
 
+/*!
+ Provides the port number of the local SOCKS proxy. Only valid when currently connected.
+ @return  The current local SOCKS proxy port number.
+ Swift: @code open func getLocalSocksProxyPort() -> Int @endcode
+ */
+-(NSInteger)getLocalSocksProxyPort;
+
+/*!
+ Provides the port number of the local HTTP proxy. Only valid when currently connected.
+ @return  The current local HTTP proxy port number.
+ Swift: @code open func getLocalHttpProxyPort() -> Int @endcode
+ */
+-(NSInteger)getLocalHttpProxyPort;
+
 /*!
  Upload a feedback package to Psiphon Inc. The app collects feedback and diagnostics information in a particular format, then calls this function to upload it for later investigation.
  @note The key, server, path, and headers must be provided by Psiphon Inc.

+ 183 - 36
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -17,6 +17,7 @@
  *
  */
 
+#import <arpa/inet.h>
 #import <net/if.h>
 #import <stdatomic.h>
 #import <CoreTelephony/CTTelephonyNetworkInfo.h>
@@ -35,14 +36,19 @@
 @end
 
 @implementation PsiphonTunnel {
-    _Atomic BOOL isWaitingForNetworkConnectivity;
     _Atomic PsiphonConnectionState connectionState;
 
+    _Atomic NSInteger localSocksProxyPort;
+    _Atomic NSInteger localHttpProxyPort;
+
+    Reachability* reachability;
+    NetworkStatus previousNetworkStatus;
 }
 
 - (id)init {
-    atomic_init(&isWaitingForNetworkConnectivity, NO);
     atomic_init(&connectionState, PsiphonConnectionStateDisconnected);
+    reachability = [Reachability reachabilityForInternetConnection];
+
     return self;
 }
 
@@ -68,7 +74,15 @@
 }
 
 // See comment in header
--(BOOL) start:(NSString * _Nullable)embeddedServerEntries {
+-(BOOL) start:(BOOL)ifNeeded {
+    if (ifNeeded) {
+        return [self startIfNeeded];
+    }
+
+    return [self start];
+}
+
+-(BOOL) start {
     @synchronized (PsiphonTunnel.self) {
         [self stop];
         [self logMessage:@"Starting Psiphon library"];
@@ -81,14 +95,21 @@
         
         NSString *configStr = [self getConfig];
         if (configStr == nil) {
+            [self logMessage:@"Error getting config from delegate"];
             return FALSE;
         }
 
-        [self changeConnectionStateTo:PsiphonConnectionStateConnecting];
+        NSString *embeddedServerEntries = [self.tunneledAppDelegate getEmbeddedServerEntries];
+        if (embeddedServerEntries == nil) {
+            [self logMessage:@"Error getting embedded server entries from delegate"];
+            return FALSE;
+        }
+
+        [self changeConnectionStateTo:PsiphonConnectionStateConnecting evenIfSameState:NO];
 
         @try {
             NSError *e = nil;
-            
+
             BOOL res = GoPsiStart(
                            configStr,
                            embeddedServerEntries,
@@ -101,31 +122,57 @@
             
             if (e != nil) {
                 [self logMessage:[NSString stringWithFormat: @"Psiphon tunnel start failed: %@", e.localizedDescription]];
-                [self changeConnectionStateTo:PsiphonConnectionStateDisconnected];
+                [self changeConnectionStateTo:PsiphonConnectionStateDisconnected evenIfSameState:NO];
                 return FALSE;
             }
         }
         @catch(NSException *exception) {
             [self logMessage:[NSString stringWithFormat: @"Failed to start Psiphon library: %@", exception.reason]];
-            [self changeConnectionStateTo:PsiphonConnectionStateDisconnected];
+            [self changeConnectionStateTo:PsiphonConnectionStateDisconnected evenIfSameState:NO];
             return FALSE;
         }
 
+        [self startInternetReachabilityMonitoring];
+
         [self logMessage:@"Psiphon tunnel started"];
         
         return TRUE;
     }
 }
 
+-(BOOL) startIfNeeded {
+    PsiphonConnectionState connState = [self getConnectionState];
+    BOOL localProxyAlive = [self isLocalProxyAlive];
+
+    // We have found that on iOS, the local proxies will get killed before the
+    // tunnel gets disconnected (or before it realizes it's dead). So we need to
+    // start if we either in a disconnected state or if our local proxies are dead.
+    if ((connState == PsiphonConnectionStateDisconnected) ||
+        (connState == PsiphonConnectionStateConnected && !localProxyAlive)) {
+        return [self start];
+    }
+
+    // Otherwise we're already connected, so let the app know via the same signaling
+    // that we'd use if we were doing a connection sequence.
+    dispatch_async(dispatch_get_main_queue(), ^{
+        [self changeConnectionStateTo:connState evenIfSameState:YES];
+    });
+
+    return TRUE;
+}
+
 // See comment in header.
 -(void) stop {
     @synchronized (PsiphonTunnel.self) {
         [self logMessage: @"Stopping Psiphon library"];
+
+        [self stopInternetReachabilityMonitoring];
+
         GoPsiStop();
+        
         [self logMessage: @"Psiphon library stopped"];
 
-        atomic_store(&isWaitingForNetworkConnectivity, NO);
-        [self changeConnectionStateTo:PsiphonConnectionStateDisconnected];
+        [self changeConnectionStateTo:PsiphonConnectionStateDisconnected evenIfSameState:NO];
     }
 }
 
@@ -134,6 +181,16 @@
     return atomic_load(&connectionState);
 }
 
+// See comment in header.
+-(NSInteger) getLocalSocksProxyPort {
+    return atomic_load(&localSocksProxyPort);
+}
+
+// See comment in header.
+-(NSInteger) getLocalHttpProxyPort {
+    return atomic_load(&localHttpProxyPort);
+}
+
 // See comment in header.
 - (void)sendFeedback:(NSString * _Nonnull)feedbackJson
            publicKey:(NSString * _Nonnull)b64EncodedPublicKey
@@ -430,15 +487,9 @@
         }
 
         if ([count integerValue] > 0) {
-            if ([self.tunneledAppDelegate respondsToSelector:@selector(onConnected)]) {
-                [self changeConnectionStateTo:PsiphonConnectionStateConnected];
-                [self.tunneledAppDelegate onConnected];
-            }
+            [self changeConnectionStateTo:PsiphonConnectionStateConnected evenIfSameState:NO];
         } else {
-            if ([self.tunneledAppDelegate respondsToSelector:@selector(onConnecting)]) {
-                [self changeConnectionStateTo:PsiphonConnectionStateConnecting];
-                [self.tunneledAppDelegate onConnecting];
-            }
+            [self changeConnectionStateTo:PsiphonConnectionStateConnecting evenIfSameState:NO];
         }
     }
     else if ([noticeType isEqualToString:@"Exiting"]) {
@@ -474,7 +525,7 @@
             [self logMessage:[NSString stringWithFormat: @"HttpProxyPortInUse notice missing data.port: %@", noticeJSON]];
             return;
         }
-        
+
         if ([self.tunneledAppDelegate respondsToSelector:@selector(onHttpProxyPortInUse:)]) {
             [self.tunneledAppDelegate onHttpProxyPortInUse:[port integerValue]];
         }
@@ -485,9 +536,13 @@
             [self logMessage:[NSString stringWithFormat: @"ListeningSocksProxyPort notice missing data.port: %@", noticeJSON]];
             return;
         }
-        
+
+        NSInteger portInt = [port integerValue];
+
+        atomic_store(&localSocksProxyPort, portInt);
+
         if ([self.tunneledAppDelegate respondsToSelector:@selector(onListeningSocksProxyPort:)]) {
-            [self.tunneledAppDelegate onListeningSocksProxyPort:[port integerValue]];
+            [self.tunneledAppDelegate onListeningSocksProxyPort:portInt];
         }
     }
     else if ([noticeType isEqualToString:@"ListeningHttpProxyPort"]) {
@@ -496,9 +551,13 @@
             [self logMessage:[NSString stringWithFormat: @"ListeningHttpProxyPort notice missing data.port: %@", noticeJSON]];
             return;
         }
-        
+
+        NSInteger portInt = [port integerValue];
+
+        atomic_store(&localHttpProxyPort, portInt);
+
         if ([self.tunneledAppDelegate respondsToSelector:@selector(onListeningHttpProxyPort:)]) {
-            [self.tunneledAppDelegate onListeningHttpProxyPort:[port integerValue]];
+            [self.tunneledAppDelegate onListeningHttpProxyPort:portInt];
         }
     }
     else if ([noticeType isEqualToString:@"UpstreamProxyError"]) {
@@ -632,20 +691,12 @@
 }
 
 - (long)hasNetworkConnectivity {
-    Reachability *reachability = [Reachability reachabilityForInternetConnection];
     BOOL hasConnectivity = [reachability currentReachabilityStatus] != NotReachable;
 
-    // If we had connectivity and now we've lost it, let the app know by calling onStartedWaitingForNetworkConnectivity.
-    BOOL wasWaitingForNetworkConnectivity = atomic_exchange(&isWaitingForNetworkConnectivity, !hasConnectivity);
-    if (!hasConnectivity && !wasWaitingForNetworkConnectivity) {
-        [self changeConnectionStateTo:PsiphonConnectionStateWaitingForNetwork];
-
-        // HasNetworkConnectivity may be called many times, but only call
-        // onStartedWaitingForNetworkConnectivity once per loss of connectivity,
-        // so the library consumer may log a single message.
-        if ([self.tunneledAppDelegate respondsToSelector:@selector(onStartedWaitingForNetworkConnectivity)]) {
-            [self.tunneledAppDelegate onStartedWaitingForNetworkConnectivity];
-        }
+    if (!hasConnectivity) {
+        // changeConnectionStateTo self-throttles, so even if called multiple
+        // times it won't send multiple messages to the app.
+        [self changeConnectionStateTo:PsiphonConnectionStateWaitingForNetwork evenIfSameState:NO];
     }
 
     return hasConnectivity;
@@ -675,18 +726,114 @@
     }
 }
 
-- (void)changeConnectionStateTo:(PsiphonConnectionState)newState {
+- (void)changeConnectionStateTo:(PsiphonConnectionState)newState evenIfSameState:(BOOL)forceNotification {
     // Store the new state and get the old state.
     PsiphonConnectionState oldState = atomic_exchange(&connectionState, newState);
 
     // If the state has changed, inform the app.
-    if (oldState != newState) {
+    if (forceNotification || oldState != newState) {
         if ([self.tunneledAppDelegate respondsToSelector:@selector(onConnectionStateChangedFrom:to:)]) {
             [self.tunneledAppDelegate onConnectionStateChangedFrom:oldState to:newState];
         }
+
+        if (newState == PsiphonConnectionStateDisconnected) {
+            // This isn't a message sent to the app.
+        }
+        else if (newState == PsiphonConnectionStateConnecting &&
+                 [self.tunneledAppDelegate respondsToSelector:@selector(onConnecting)]) {
+            [self.tunneledAppDelegate onConnecting];
+        }
+        else if (newState == PsiphonConnectionStateConnected &&
+                 [self.tunneledAppDelegate respondsToSelector:@selector(onConnected)]) {
+            [self.tunneledAppDelegate onConnected];
+        }
+        else if (newState == PsiphonConnectionStateWaitingForNetwork &&
+                 [self.tunneledAppDelegate respondsToSelector:@selector(onStartedWaitingForNetworkConnectivity)]) {
+            [self.tunneledAppDelegate onStartedWaitingForNetworkConnectivity];
+        }
     }
 }
 
+/*!
+ Checks if the local SOCKS proxy is responding. 
+ NOTE: This must only be called when there's a valid SOCKS proxy port (i.e., when
+ we're in a connected state.)
+ @return  TRUE if the local proxy is responding, FALSE otherwise.
+ */
+- (BOOL)isLocalProxyAlive {
+    CFSocketRef sockfd;
+    sockfd = CFSocketCreate(NULL, AF_INET, SOCK_STREAM, IPPROTO_TCP, 0, NULL, NULL);
+    if (sockfd == NULL) {
+        // An error occurred creating the socket. It's impossible to complete
+        // the test. We'll be optimistic.
+        return YES;
+    }
+
+    struct sockaddr_in servaddr;
+    memset(&servaddr, 0, sizeof(servaddr));
+    servaddr.sin_len = sizeof(servaddr);
+    servaddr.sin_family = AF_INET;
+    servaddr.sin_port = htons([self getLocalSocksProxyPort]);
+    inet_pton(AF_INET, [@"127.0.0.1" cStringUsingEncoding:NSUTF8StringEncoding], &servaddr.sin_addr);
+
+    CFDataRef connectAddr = CFDataCreate(NULL, (unsigned char *)&servaddr, sizeof(servaddr));
+    if (connectAddr == NULL) {
+        CFSocketInvalidate(sockfd);
+        CFRelease(sockfd);
+        // Again, be optimistic.
+        return YES;
+    }
+
+    BOOL proxyTestSuccess = YES;
+    if (CFSocketConnectToAddress(sockfd, connectAddr, 1) != kCFSocketSuccess) {
+        proxyTestSuccess = NO;
+    }
+
+    CFSocketInvalidate(sockfd);
+    CFRelease(sockfd);
+    CFRelease(connectAddr);
+
+    return proxyTestSuccess;
+}
+
+// We are going to do our own monitoring of the network reachability, rather
+// than relying on the tunnel to inform us. This is because it can take a long
+// time for the tunnel to notice the network is gone (depending on attempts to
+// use the tunnel).
+- (void)startInternetReachabilityMonitoring {
+    previousNetworkStatus = [reachability currentReachabilityStatus];
+
+    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(internetReachabilityChanged:) name:kReachabilityChangedNotification object:nil];
+    [reachability startNotifier];
+}
+
+- (void)stopInternetReachabilityMonitoring {
+    [reachability stopNotifier];
+    [[NSNotificationCenter defaultCenter] removeObserver:self name:kReachabilityChangedNotification object:nil];
+}
+
+- (void)internetReachabilityChanged:(NSNotification *)note
+{
+    // If we lose network while connected, we're going to force a reconnect in
+    // order to trigger the waiting-for-network state. The reason we don't wait
+    // for the tunnel to notice the network loss is that it might take 30 seconds.
+
+    Reachability* currentReachability = [note object];
+    NetworkStatus networkStatus = [currentReachability currentReachabilityStatus];
+
+    PsiphonConnectionState currentConnectionState = [self getConnectionState];
+
+    if (currentConnectionState == PsiphonConnectionStateConnected &&
+        previousNetworkStatus != NotReachable &&
+        previousNetworkStatus != networkStatus) {
+        if ([self.tunneledAppDelegate respondsToSelector:@selector(onDeviceInternetConnectivityInterrupted)]) {
+            [self.tunneledAppDelegate onDeviceInternetConnectivityInterrupted];
+        }
+    }
+
+    previousNetworkStatus = networkStatus;
+}
+
 /*!
  Determine the device's region. Makes a best guess based on available info.
  @returns The two-letter country code that the device is probably located in.

+ 256 - 0
MobileLibrary/iOS/SampleApps/TunneledWebRequest/TunneledWebRequest/AppDelegate.swift

@@ -9,14 +9,22 @@ https://creativecommons.org/publicdomain/zero/1.0/
 
 import UIKit
 
+import PsiphonTunnel
+
+
 @UIApplicationMain
 class AppDelegate: UIResponder, UIApplicationDelegate {
 
     var window: UIWindow?
 
+	// The instance of PsiphonTunnel we'll use for connecting.
+	var psiphonTunnel: PsiphonTunnel?
 
     func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
         // Override point for customization after application launch.
+
+		self.psiphonTunnel = PsiphonTunnel.newPsiphonTunnel(self)
+
         return true
     }
 
@@ -36,12 +44,260 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
     func applicationDidBecomeActive(_ application: UIApplication) {
         // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
+
+		DispatchQueue.global(qos: .default).async {
+			// Start up the tunnel and begin connecting.
+			// This could be started elsewhere or earlier.
+			NSLog("Starting tunnel")
+
+			guard let success = self.psiphonTunnel?.start(true), success else {
+				NSLog("psiphonTunnel.start returned false")
+				return
+			}
+
+			// The Psiphon Library exposes reachability functions, which can be used for detecting internet status.
+			let reachability = Reachability.forInternetConnection()
+			let networkStatus = reachability?.currentReachabilityStatus()
+			NSLog("Internet is reachable? \(networkStatus != NotReachable)")
+		}
     }
 
     func applicationWillTerminate(_ application: UIApplication) {
         // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
+
+		// Clean up the tunnel
+		NSLog("Stopping tunnel")
+		self.psiphonTunnel?.stop()
     }
 
+	/// Request URL using URLSession configured to use the current proxy.
+	/// * parameters:
+	///   - url: The URL to request.
+	///   - completion: A callback function that will received the string obtained
+	///     from the request, or nil if there's an error.
+	/// * returns: The string obtained from the request, or nil if there's an error.
+	func makeRequestViaUrlSessionProxy(_ url: String, completion: @escaping (_ result: String?) -> ()) {
+		let socksProxyPort = self.psiphonTunnel!.getLocalSocksProxyPort()
+		assert(socksProxyPort > 0)
+
+		let request = URLRequest(url: URL(string: url)!)
+
+		let config = URLSessionConfiguration.ephemeral
+		config.requestCachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData
+		config.connectionProxyDictionary = [AnyHashable: Any]()
+
+		// Enable and set the SOCKS proxy values.
+		config.connectionProxyDictionary?[kCFStreamPropertySOCKSProxy as String] = 1
+		config.connectionProxyDictionary?[kCFStreamPropertySOCKSProxyHost as String] = "127.0.0.1"
+		config.connectionProxyDictionary?[kCFStreamPropertySOCKSProxyPort as String] = socksProxyPort
+
+		// Alternatively, the HTTP proxy can be used. Below are the settings for that.
+		// The HTTPS key constants are mismatched and Xcode gives deprecation warnings, but they seem to be necessary to proxy HTTPS requests. This is probably a bug on Apple's side; see: https://forums.developer.apple.com/thread/19356#131446
+		// config.connectionProxyDictionary?[kCFNetworkProxiesHTTPEnable as String] = 1
+		// config.connectionProxyDictionary?[kCFNetworkProxiesHTTPProxy as String] = "127.0.0.1"
+		// config.connectionProxyDictionary?[kCFNetworkProxiesHTTPPort as String] = self.httpProxyPort
+		// config.connectionProxyDictionary?[kCFStreamPropertyHTTPSProxyHost as String] = "127.0.0.1"
+		// config.connectionProxyDictionary?[kCFStreamPropertyHTTPSProxyPort as String] = self.httpProxyPort
+
+		let session = URLSession.init(configuration: config, delegate: nil, delegateQueue: OperationQueue.current)
+
+		// Create the URLSession task that will make the request via the tunnel proxy.
+		let task = session.dataTask(with: request) {
+			(data: Data?, response: URLResponse?, error: Error?) in
+			if error != nil {
+				NSLog("Client-side error in request to \(url): \(String(describing: error))")
+				// Invoke the callback indicating error.
+				completion(nil)
+				return
+			}
+
+			if data == nil {
+				NSLog("Data from request to \(url) is nil")
+				// Invoke the callback indicating error.
+				completion(nil)
+				return
+			}
+
+			let httpResponse = response as? HTTPURLResponse
+			if httpResponse?.statusCode != 200 {
+				NSLog("Server-side error in request to \(url): \(String(describing: httpResponse))")
+				// Invoke the callback indicating error.
+				completion(nil)
+				return
+			}
+
+			let encodingName = response?.textEncodingName != nil ? response?.textEncodingName : "utf-8"
+			let encoding = CFStringConvertEncodingToNSStringEncoding(CFStringConvertIANACharSetNameToEncoding(encodingName as CFString!))
+
+			let stringData = String(data: data!, encoding: String.Encoding(rawValue: UInt(encoding)))
+
+			// Make sure the session is cleaned up.
+			session.invalidateAndCancel()
+
+			// Invoke the callback with the result.
+			completion(stringData)
+		}
+
+		// Start the request task.
+		task.resume()
+	}
+
+	/// Request URL using Psiphon's "URL proxy" mode.
+	/// For details, see the comment near the top of:
+	/// https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/master/psiphon/httpProxy.go
+	/// * parameters:
+	///   - url: The URL to request.
+	///   - completion: A callback function that will received the string obtained
+	///     from the request, or nil if there's an error.
+	/// * returns: The string obtained from the request, or nil if there's an error.
+	func makeRequestViaUrlProxy(_ url: String, completion: @escaping (_ result: String?) -> ()) {
+		let httpProxyPort = self.psiphonTunnel!.getLocalHttpProxyPort()
+		assert(httpProxyPort > 0)
+
+		// The target URL must be encoded so as to be valid within a query parameter.
+		// See this SO answer for why we're using this CharacterSet (and not using: https://stackoverflow.com/a/24888789
+		let queryParamCharsAllowed = CharacterSet.init(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
+
+		let encodedTargetURL = url.addingPercentEncoding(withAllowedCharacters: queryParamCharsAllowed)
+
+		let proxiedURL = "http://127.0.0.1:\(httpProxyPort)/tunneled/\(encodedTargetURL!)"
+
+		let task = URLSession.shared.dataTask(with: URL(string: proxiedURL)!) {
+			(data: Data?, response: URLResponse?, error: Error?) in
+			if error != nil {
+				NSLog("Client-side error in request to \(url): \(String(describing: error))")
+				// Invoke the callback indicating error.
+				completion(nil)
+				return
+			}
 
+			if data == nil {
+				NSLog("Data from request to \(url) is nil")
+				// Invoke the callback indicating error.
+				completion(nil)
+				return
+			}
+
+			let httpResponse = response as? HTTPURLResponse
+			if httpResponse?.statusCode != 200 {
+				NSLog("Server-side error in request to \(url): \(String(describing: httpResponse))")
+				// Invoke the callback indicating error.
+				completion(nil)
+				return
+			}
+
+			let encodingName = response?.textEncodingName != nil ? response?.textEncodingName : "utf-8"
+			let encoding = CFStringConvertEncodingToNSStringEncoding(CFStringConvertIANACharSetNameToEncoding(encodingName as CFString!))
+
+			let stringData = String(data: data!, encoding: String.Encoding(rawValue: UInt(encoding)))
+
+			// Invoke the callback with the result.
+			completion(stringData)
+		}
+
+		// Start the request task.
+		task.resume()
+	}
 }
 
+
+// MARK: TunneledAppDelegate implementation
+// See the protocol definition for details about the methods.
+// Note that we're excluding all the optional methods that we aren't using,
+// however your needs may be different.
+extension AppDelegate: TunneledAppDelegate {
+	func getPsiphonConfig() -> String? {
+		// In this example, we're going to retrieve our Psiphon config from a file in the app bundle.
+		// Alternatively, it could be a string literal in the code, or whatever makes sense.
+
+		guard let psiphonConfigUrl = Bundle.main.url(forResource: "psiphon-config", withExtension: "json") else {
+			NSLog("Error getting Psiphon config resource file URL!")
+			return nil
+		}
+
+		do {
+			return try String.init(contentsOf: psiphonConfigUrl)
+		} catch {
+			NSLog("Error reading Psiphon config resource file!")
+			return nil
+		}
+	}
+
+    /// Read the Psiphon embedded server entries resource file and return the contents.
+    /// * returns: The string of the contents of the file.
+    func getEmbeddedServerEntries() -> String? {
+        guard let psiphonEmbeddedServerEntriesUrl = Bundle.main.url(forResource: "psiphon-embedded-server-entries", withExtension: "txt") else {
+            NSLog("Error getting Psiphon embedded server entries resource file URL!")
+            return nil
+        }
+
+        do {
+            return try String.init(contentsOf: psiphonEmbeddedServerEntriesUrl)
+        } catch {
+            NSLog("Error reading Psiphon embedded server entries resource file!")
+            return nil
+        }
+    }
+
+	func onDiagnosticMessage(_ message: String) {
+		NSLog("onDiagnosticMessage: %@", message)
+	}
+
+	func onConnected() {
+		NSLog("onConnected")
+
+		// After we're connected, make tunneled requests and populate the webview.
+
+		DispatchQueue.global(qos: .default).async {
+			let mainView = self.window?.rootViewController as! ViewController
+			
+			// First we'll make a "what is my IP" request via makeRequestViaUrlSessionProxy().
+			let url = "https://freegeoip.net/json/"
+			self.makeRequestViaUrlSessionProxy(url) {
+				(_ result: String?) in
+
+				if result == nil {
+					NSLog("Failed to get \(url)")
+					return
+				}
+
+				// Do a little pretty-printing.
+				let prettyResult = result?.replacingOccurrences(of: ",", with: ",\n  ")
+					.replacingOccurrences(of: "{", with: "{\n  ")
+					.replacingOccurrences(of: "}", with: "\n}")
+
+				DispatchQueue.main.sync {
+					// Load the result into the view.
+					mainView.appendToView("Result from \(url):\n\(prettyResult!)")
+				}
+
+				// Then we'll make a different "what is my IP" request via makeRequestViaUrlProxy().
+				DispatchQueue.global(qos: .default).async {
+					let url = "https://ifconfig.co/json"
+					self.makeRequestViaUrlProxy(url) {
+						(_ result: String?) in
+
+						if result == nil {
+							NSLog("Failed to get \(url)")
+							return
+						}
+
+						// Do a little pretty-printing.
+						let prettyResult = result?.replacingOccurrences(of: ",", with: ",\n  ")
+							.replacingOccurrences(of: "{", with: "{\n  ")
+							.replacingOccurrences(of: "}", with: "\n}")
+
+						DispatchQueue.main.sync {
+							// Load the result into the view.
+							mainView.appendToView("Result from \(url):\n\(prettyResult!)")
+						}
+
+						// We'll leave the tunnel open for when we want to make
+						// more requests. It will get stopped by `applicationWillTerminate`.
+					}
+				}
+
+			}
+		}
+	}
+}

+ 20 - 4
MobileLibrary/iOS/SampleApps/TunneledWebRequest/TunneledWebRequest/Base.lproj/Main.storyboard

@@ -1,14 +1,18 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="11134" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16F73" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
+    <device id="retina4_7" orientation="portrait">
+        <adaptation id="fullscreen"/>
+    </device>
     <dependencies>
-        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="11106"/>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
         <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
     </dependencies>
     <scenes>
         <!--View Controller-->
         <scene sceneID="tne-QT-ifu">
             <objects>
-                <viewController id="BYZ-38-t0r" customClass="ViewController" customModuleProvider="target" sceneMemberID="viewController">
+                <viewController id="BYZ-38-t0r" customClass="ViewController" customModule="TunneledWebRequest" customModuleProvider="target" sceneMemberID="viewController">
                     <layoutGuides>
                         <viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
                         <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
@@ -16,11 +20,23 @@
                     <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
                         <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
                         <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <subviews>
+                            <webView contentMode="scaleToFill" fixedFrame="YES" allowsInlineMediaPlayback="NO" mediaPlaybackRequiresUserAction="NO" mediaPlaybackAllowsAirPlay="NO" keyboardDisplayRequiresUserAction="NO" translatesAutoresizingMaskIntoConstraints="NO" id="LFR-V1-aLA" userLabel="WebView">
+                                <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
+                                <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                                <color key="backgroundColor" red="0.36078431370000003" green="0.38823529410000002" blue="0.4039215686" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
+                                <dataDetectorType key="dataDetectorTypes"/>
+                            </webView>
+                        </subviews>
                         <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                     </view>
+                    <connections>
+                        <outlet property="webView" destination="LFR-V1-aLA" id="FRO-zc-IdT"/>
+                    </connections>
                 </viewController>
                 <placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
             </objects>
+            <point key="canvasLocation" x="133.59999999999999" y="113.79310344827587"/>
         </scene>
     </scenes>
 </document>

+ 12 - 284
MobileLibrary/iOS/SampleApps/TunneledWebRequest/TunneledWebRequest/ViewController.swift

@@ -10,305 +10,33 @@ https://creativecommons.org/publicdomain/zero/1.0/
 
 import UIKit
 
-import PsiphonTunnel
-
 class ViewController: UIViewController {
     
-    var webView: UIWebView!
-    
-    // The instance of PsiphonTunnel we'll use for connecting.
-    var psiphonTunnel: PsiphonTunnel?
-    
-    // These are the ports that we can proxy through.
-    var socksProxyPort = -1
-    var httpProxyPort = -1
-    
-    override func loadView() {
-        // Make our whole view the webview.
-        webView = UIWebView()
-        view = webView
-        
-        self.psiphonTunnel = PsiphonTunnel.newPsiphonTunnel(self)
-    }
-    
+	@IBOutlet var webView: UIWebView!
+
+	var viewText: String = ""
+	
     override func viewDidLoad() {
         super.viewDidLoad()
-        
-        // Start up the tunnel and begin connecting.
-        // This could be started elsewhere or earlier.
-        NSLog("Starting tunnel")
 
-        guard let embeddedServerEntries = getEmbeddedServerEntries() else {
-            NSLog("getEmbeddedServerEntries failed!")
-            return
-        }
+		webView.isUserInteractionEnabled = true
+		webView.scrollView.isScrollEnabled = true
+	}
 
-        guard let success = self.psiphonTunnel?.start(embeddedServerEntries), success else {
-            NSLog("psiphonTunnel.start returned false")
-            return
-        }
-        
-        // The Psiphon Library exposes reachability functions, which can be used for detecting internet status.
-        let reachability = Reachability.forInternetConnection()
-        let networkStatus = reachability?.currentReachabilityStatus()
-        NSLog("Internet is reachable? \(networkStatus != NotReachable)")
-        
-        // The Psiphon Library exposes a function to test if the device is jailbroken. 
-        let jailbroken = JailbreakCheck.isDeviceJailbroken()
-        NSLog("Device is jailbroken? \(jailbroken)")
-    }
-    
     override func didReceiveMemoryWarning() {
         super.didReceiveMemoryWarning()
         // Dispose of any resources that can be recreated.
     }
     
     func appendToView(_ text: String) {
-        let escapedText = text.replacingOccurrences(of: "\n", with: "\\n")
-                              .replacingOccurrences(of: "\r", with: "")
-        self.webView.stringByEvaluatingJavaScript(from: String.init(format: "document.body.innerHTML+='<br><pre>%@</pre><br>'", arguments: [escapedText]))
-    }
-
-    /// Read the Psiphon embedded server entries resource file and return the contents.
-    /// * returns: The string of the contents of the file.
-    func getEmbeddedServerEntries() -> String? {
-        guard let psiphonEmbeddedServerEntriesUrl = Bundle.main.url(forResource: "psiphon-embedded-server-entries", withExtension: "txt") else {
-            NSLog("Error getting Psiphon embedded server entries resource file URL!")
-            return nil
-        }
-
-        do {
-            return try String.init(contentsOf: psiphonEmbeddedServerEntriesUrl)
-        } catch {
-            NSLog("Error reading Psiphon embedded server entries resource file!")
-            return nil
-        }
-    }
-    
-    /// Request URL using URLSession configured to use the current proxy.
-    /// * parameters:
-    ///   - url: The URL to request.
-    ///   - completion: A callback function that will received the string obtained
-    ///     from the request, or nil if there's an error.
-    /// * returns: The string obtained from the request, or nil if there's an error.
-    func makeRequestViaUrlSessionProxy(_ url: String, completion: @escaping (_ result: String?) -> ()) {
-        assert(self.httpProxyPort > 0)
-        
-        let request = URLRequest(url: URL(string: url)!)
-        
-        let config = URLSessionConfiguration.ephemeral
-        config.requestCachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData
-        config.connectionProxyDictionary = [AnyHashable: Any]()
-        
-        // Enable and set the SOCKS proxy values.
-        config.connectionProxyDictionary?[kCFStreamPropertySOCKSProxy as String] = 1
-        config.connectionProxyDictionary?[kCFStreamPropertySOCKSProxyHost as String] = "127.0.0.1"
-        config.connectionProxyDictionary?[kCFStreamPropertySOCKSProxyPort as String] = self.socksProxyPort
-        
-        // Alternatively, the HTTP proxy can be used. Below are the settings for that.
-        // The HTTPS key constants are mismatched and Xcode gives deprecation warnings, but they seem to be necessary to proxy HTTPS requests. This is probably a bug on Apple's side; see: https://forums.developer.apple.com/thread/19356#131446
-        // config.connectionProxyDictionary?[kCFNetworkProxiesHTTPEnable as String] = 1
-        // config.connectionProxyDictionary?[kCFNetworkProxiesHTTPProxy as String] = "127.0.0.1"
-        // config.connectionProxyDictionary?[kCFNetworkProxiesHTTPPort as String] = self.httpProxyPort
-        // config.connectionProxyDictionary?[kCFStreamPropertyHTTPSProxyHost as String] = "127.0.0.1"
-        // config.connectionProxyDictionary?[kCFStreamPropertyHTTPSProxyPort as String] = self.httpProxyPort
-        
-        let session = URLSession.init(configuration: config, delegate: nil, delegateQueue: OperationQueue.current)
-        
-        // Create the URLSession task that will make the request via the tunnel proxy.
-        let task = session.dataTask(with: request) {
-            (data: Data?, response: URLResponse?, error: Error?) in
-            if error != nil {
-                NSLog("Client-side error in request to \(url): \(String(describing: error))")
-                // Invoke the callback indicating error.
-                completion(nil)
-                return
-            }
-            
-            if data == nil {
-                NSLog("Data from request to \(url) is nil")
-                // Invoke the callback indicating error.
-                completion(nil)
-                return
-            }
-            
-            let httpResponse = response as? HTTPURLResponse
-            if httpResponse?.statusCode != 200 {
-                NSLog("Server-side error in request to \(url): \(String(describing: httpResponse))")
-                // Invoke the callback indicating error.
-                completion(nil)
-                return
-            }
-            
-            let encodingName = response?.textEncodingName != nil ? response?.textEncodingName : "utf-8"
-            let encoding = CFStringConvertEncodingToNSStringEncoding(CFStringConvertIANACharSetNameToEncoding(encodingName as CFString!))
-            
-            let stringData = String(data: data!, encoding: String.Encoding(rawValue: UInt(encoding)))
-            
-            // Make sure the session is cleaned up.
-            session.invalidateAndCancel()
-            
-            // Invoke the callback with the result.
-            completion(stringData)
-        }
-        
-        // Start the request task.
-        task.resume()
-    }
+        let escapedText = text.replacingOccurrences(of: "\r", with: "")
 
-    /// Request URL using Psiphon's "URL proxy" mode. 
-    /// For details, see the comment near the top of:
-    /// https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/master/psiphon/httpProxy.go
-    /// * parameters:
-    ///   - url: The URL to request.
-    ///   - completion: A callback function that will received the string obtained
-    ///     from the request, or nil if there's an error.
-    /// * returns: The string obtained from the request, or nil if there's an error.
-    func makeRequestViaUrlProxy(_ url: String, completion: @escaping (_ result: String?) -> ()) {
-        assert(self.httpProxyPort > 0)
-        
-        // The target URL must be encoded so as to be valid within a query parameter.
-        // See this SO answer for why we're using this CharacterSet (and not using: https://stackoverflow.com/a/24888789
-        let queryParamCharsAllowed = CharacterSet.init(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
+		self.viewText += "\n\n"
+		self.viewText += escapedText
 
-        let encodedTargetURL = url.addingPercentEncoding(withAllowedCharacters: queryParamCharsAllowed)
-        
-        let proxiedURL = "http://127.0.0.1:\(self.httpProxyPort)/tunneled/\(encodedTargetURL!)"
-        
-        let task = URLSession.shared.dataTask(with: URL(string: proxiedURL)!) {
-            (data: Data?, response: URLResponse?, error: Error?) in
-            if error != nil {
-                NSLog("Client-side error in request to \(url): \(String(describing: error))")
-                // Invoke the callback indicating error.
-                completion(nil)
-                return
-            }
-            
-            if data == nil {
-                NSLog("Data from request to \(url) is nil")
-                // Invoke the callback indicating error.
-                completion(nil)
-                return
-            }
-            
-            let httpResponse = response as? HTTPURLResponse
-            if httpResponse?.statusCode != 200 {
-                NSLog("Server-side error in request to \(url): \(String(describing: httpResponse))")
-                // Invoke the callback indicating error.
-                completion(nil)
-                return
-            }
-            
-            let encodingName = response?.textEncodingName != nil ? response?.textEncodingName : "utf-8"
-            let encoding = CFStringConvertEncodingToNSStringEncoding(CFStringConvertIANACharSetNameToEncoding(encodingName as CFString!))
-            
-            let stringData = String(data: data!, encoding: String.Encoding(rawValue: UInt(encoding)))
-            
-            // Invoke the callback with the result.
-            completion(stringData)
-        }
-        
-        // Start the request task.
-        task.resume()
-    }
-}
+		let html = "<pre>" + self.viewText + "</pre>"
 
-// MARK: TunneledAppDelegate implementation
-// See the protocol definition for details about the methods.
-// Note that we're excluding all the optional methods that we aren't using,
-// however your needs may be different.
-extension ViewController: TunneledAppDelegate {
-    func getPsiphonConfig() -> String? {
-        // In this example, we're going to retrieve our Psiphon config from a file in the app bundle.
-        // Alternatively, it could be a string literal in the code, or whatever makes sense.
-        
-        guard let psiphonConfigUrl = Bundle.main.url(forResource: "psiphon-config", withExtension: "json") else {
-            NSLog("Error getting Psiphon config resource file URL!")
-            return nil
-        }
-        
-        do {
-            return try String.init(contentsOf: psiphonConfigUrl)
-        } catch {
-            NSLog("Error reading Psiphon config resource file!")
-            return nil
-        }
+		self.webView.loadHTMLString(html, baseURL: nil)
     }
-    
-    func onDiagnosticMessage(_ message: String) {
-        NSLog("onDiagnosticMessage: %@", message)
-    }
-    
-    func onConnected() {
-        NSLog("onConnected")
-        
-        // After we're connected, make tunneled requests and populate the webview.
-        
-        DispatchQueue.global(qos: .default).async {
-            // First we'll make a "what is my IP" request via makeRequestViaUrlSessionProxy().
-            let url = "https://freegeoip.net/json/"
-            self.makeRequestViaUrlSessionProxy(url) {
-                (_ result: String?) in
-                
-                if result == nil {
-                    NSLog("Failed to get \(url)")
-                    return
-                }
 
-                // Do a little pretty-printing.
-                let prettyResult = result?.replacingOccurrences(of: ",", with: ",\n  ")
-                                          .replacingOccurrences(of: "{", with: "{\n  ")
-                                          .replacingOccurrences(of: "}", with: "\n}")
-                
-                DispatchQueue.main.sync {
-                    // Load the result into the view.
-                    self.appendToView("Result from \(url):\n\(prettyResult!)")
-                }
-                
-                // Then we'll make a different "what is my IP" request via makeRequestViaUrlProxy().
-                DispatchQueue.global(qos: .default).async {
-                    let url = "http://ifconfig.co/json"
-                    self.makeRequestViaUrlProxy(url) {
-                        (_ result: String?) in
-                        
-                        if result == nil {
-                            NSLog("Failed to get \(url)")
-                            return
-                        }
-                        
-						// Do a little pretty-printing.
-						let prettyResult = result?.replacingOccurrences(of: ",", with: ",\n  ")
-							.replacingOccurrences(of: "{", with: "{\n  ")
-							.replacingOccurrences(of: "}", with: "\n}")
-
-                        DispatchQueue.main.sync {
-                            // Load the result into the view.
-                            self.appendToView("Result from \(url):\n\(prettyResult!)")
-                        }
-                        
-                        // We're done with the Psiphon tunnel, so stop it.
-                        // In a real app, we would keep this alive for as long as we need it.
-                        self.psiphonTunnel?.stop()
-                    }
-                }
-                
-            }
-        }
-    }
-
-    func onListeningSocksProxyPort(_ port: Int) {
-        NSLog("onListeningSocksProxyPort: %d", port)
-        // Record the port being used so that we can proxy through it later.
-        DispatchQueue.main.async {
-            self.socksProxyPort = port
-        }
-    }
-    
-    func onListeningHttpProxyPort(_ port: Int) {
-        NSLog("onListeningHttpProxyPort: %d", port)
-        // Record the port being used so that we can proxy through it later.
-        DispatchQueue.main.async {
-            self.httpProxyPort = port
-        }
-    }
 }