diff --git a/webchannel/objc/WebChannel.podspec b/webchannel/objc/WebChannel.podspec new file mode 100644 index 0000000..5a56124 --- /dev/null +++ b/webchannel/objc/WebChannel.podspec @@ -0,0 +1,26 @@ +Pod::Spec.new do |s| + s.name = 'WebChannel' + s.version = '0.1.0' + s.summary = 'Objective-C client for WebChannel' + s.description = <<-DESC +Objective-C client implementation for WebChannel, providing robust bidirectional +communication over HTTP. + DESC + s.homepage = 'https://github.com/google/net_http' + s.license = { :type => 'Apache License, Version 2.0', :file => '../../LICENSE' } + s.author = { 'Google LLC' => 'webchannel-dev@google.com' } + s.source = { :git => 'https://github.com/google/net_http.git', :tag => s.version.to_s } + + s.ios.deployment_target = '12.0' + s.osx.deployment_target = '10.15' + + s.source_files = 'imported_src/**/*.{h,m}' + s.exclude_files = 'imported_src/Tests/**/*.{h,m}' + + s.dependency 'GTMSessionFetcher', '~> 3.0' + + s.test_spec 'Tests' do |test_spec| + test_spec.source_files = 'imported_src/Tests/**/*.{h,m}' + test_spec.dependency 'OCMock', '~> 3.0' + end +end diff --git a/webchannel/objc/imported_src/Support/WCDefaultHTTPRequest.h b/webchannel/objc/imported_src/Support/WCDefaultHTTPRequest.h new file mode 100644 index 0000000..062ce17 --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCDefaultHTTPRequest.h @@ -0,0 +1,13 @@ +#import + +#import "WCHTTPRequest.h" + +@class GTMSessionFetcherService; + +@interface WCDefaultHTTPRequest : NSObject + +- (instancetype)initWithStateChangeHandler:(id)handler + dispatchQueue:(dispatch_queue_t)dispatchQueue + fetcherService:(nonnull GTMSessionFetcherService *)fetcherService; + +@end diff --git a/webchannel/objc/imported_src/Support/WCDefaultHTTPRequest.m b/webchannel/objc/imported_src/Support/WCDefaultHTTPRequest.m new file mode 100644 index 0000000..957f44e --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCDefaultHTTPRequest.m @@ -0,0 +1,135 @@ +#import "WCDefaultHTTPRequest.h" + +#import "WCHTTPRequest.h" +#import +#import + +@implementation WCDefaultHTTPRequest { + // Internal queue used to handle all Webchannel logic to ensure thread safety. + dispatch_queue_t _dispatchQueue; + GTMSessionFetcherService *_fetcherService; + + GTMSessionFetcher *_fetcher; + NSHTTPURLResponse *_Nullable _response; +} + +@synthesize requestReadyStateChangeHandler = _requestReadyStateChangeHandler; +@synthesize requestReadyState = _requestReadyState; +@synthesize requestErrorCode = _requestErrorCode; + +- (instancetype)initWithStateChangeHandler:(id)handler + dispatchQueue:(dispatch_queue_t)dispatchQueue + fetcherService:(nonnull GTMSessionFetcherService *)fetcherService { + self = [super init]; + if (self) { + _requestReadyStateChangeHandler = handler; + _dispatchQueue = dispatchQueue; + _fetcherService = fetcherService; + _requestErrorCode = WCRequestErrorCodeNoError; + } + return self; +} + +- (NSString *)responseHeaderForName:(NSString *)name { + return [_response valueForHTTPHeaderField:name]; +} + +- (int)status { + return (int)_fetcher.statusCode; +} + +#pragma mark - WCHTTPRequest + +- (void)sendPOST:(NSURL *)URL + withData:(NSData *)POSTData + withHeaders:(NSDictionary *)headers + timeout:(NSTimeInterval)timeout { + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:URL]; + request.HTTPMethod = @"POST"; + request.timeoutInterval = timeout; + request.HTTPBody = POSTData; + request.allHTTPHeaderFields = headers; + + [self beginFetchWithRequest:request]; +} + +- (void)sendGET:(NSURL *)URL + withHeaders:(NSDictionary *)headers + timeout:(NSTimeInterval)timeout { + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:URL]; + request.HTTPMethod = @"GET"; + request.timeoutInterval = timeout; + request.allHTTPHeaderFields = headers; + + [self beginFetchWithRequest:request]; +} + +- (void)abort { + [_fetcher stopFetching]; +} + +- (void)beginFetchWithRequest:(NSURLRequest *)request { + _fetcher = [_fetcherService fetcherWithRequest:request]; + _fetcher.callbackQueue = _dispatchQueue; +#if DEBUG + _fetcher.allowLocalhostRequest = YES; +#endif + + __weak WCDefaultHTTPRequest *weakSelf = self; + _fetcher.didReceiveResponseBlock = + ^(NSURLResponse *response, + GTMSessionFetcherDidReceiveResponseDispositionBlock completionHandler) { + WCDefaultHTTPRequest *strongSelf = weakSelf; + if (strongSelf) { + strongSelf->_response = (NSHTTPURLResponse *)response; + strongSelf->_requestReadyState = WCRequestReadyStateLoaded; + [strongSelf->_requestReadyStateChangeHandler stateChangedForRequest:strongSelf + responseData:NULL]; + } + completionHandler(NSURLSessionResponseAllow); + }; + + _fetcher.accumulateDataBlock = ^(NSData *data) { + WCDefaultHTTPRequest *strongSelf = weakSelf; + if (strongSelf) { + strongSelf->_requestReadyState = WCRequestReadyStateInteractive; + [strongSelf->_requestReadyStateChangeHandler stateChangedForRequest:strongSelf + responseData:data]; + } + }; + + [_fetcher beginFetchWithCompletionHandler:^(NSData *data, NSError *error) { + WCDefaultHTTPRequest *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + if (error) { + strongSelf->_requestErrorCode = [strongSelf errorCodeForError:error]; + } else { + if (strongSelf->_fetcher.statusCode != 200) { + strongSelf->_requestErrorCode = WCRequestErrorCodeHTTPError; + } + } + strongSelf->_requestReadyState = WCRequestReadyStateComplete; + [strongSelf->_requestReadyStateChangeHandler stateChangedForRequest:strongSelf + responseData:NULL]; + }]; +} + +- (WCRequestErrorCode)errorCodeForError:(NSError *)error { + if ([error.domain isEqual:kGTMSessionFetcherStatusDomain]) { + return WCRequestErrorCodeHTTPError; + } else if ([error.domain isEqual:NSURLErrorDomain]) { + if (error.code == NSURLErrorTimedOut) { + return WCRequestErrorCodeTimeout; + } else if (error.code == NSURLErrorCancelled) { + return WCRequestErrorCodeAbort; + } else { + return WCRequestErrorCodeException; + } + } else { + return WCRequestErrorCodeException; + } +} + +@end diff --git a/webchannel/objc/imported_src/Support/WCDefaultJSONDecoder.h b/webchannel/objc/imported_src/Support/WCDefaultJSONDecoder.h new file mode 100644 index 0000000..7b69894 --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCDefaultJSONDecoder.h @@ -0,0 +1,6 @@ +#import + +#import "WCJSONDecoder.h" + +@interface WCDefaultJSONDecoder : NSObject +@end diff --git a/webchannel/objc/imported_src/Support/WCDefaultJSONDecoder.m b/webchannel/objc/imported_src/Support/WCDefaultJSONDecoder.m new file mode 100644 index 0000000..e2625fe --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCDefaultJSONDecoder.m @@ -0,0 +1,18 @@ +#import "WCDefaultJSONDecoder.h" + +@implementation WCDefaultJSONDecoder + +- (NSArray *)decodeData:(NSString *)data maxDepth:(int)maxDepth { + // TODO(eryu): Refactor to avoid inefficient NSData->NSString->NSData conversion. + // WCChannelRequest converts network response from NSData to NSString before + // calling its delegate's -didReceiveInput:withRequest: method. The input + // eventually reaches this method as NSString, which is converted back to + // NSData because the underlying JSON parser (NSJSONSerialization) requires + // NSData. This data path should be optimized to pass NSData directly to + // eliminate the redundant conversions. + NSData *encodingData = [data dataUsingEncoding:NSUTF8StringEncoding]; + NSArray *JSONArray = [NSJSONSerialization JSONObjectWithData:encodingData options:0 error:nil]; + return JSONArray; +} + +@end diff --git a/webchannel/objc/imported_src/Support/WCDefaultLogger.h b/webchannel/objc/imported_src/Support/WCDefaultLogger.h new file mode 100644 index 0000000..35ef098 --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCDefaultLogger.h @@ -0,0 +1,42 @@ +#import + +#import "WCLogger.h" + +extern BOOL gWCVerboseLoggingEnabled; + +/** Convenience macro of Debug. */ +#ifndef WCDefaultLoggerDebug + +#define WCDefaultLoggerDebug(...) \ + if (gWCVerboseLoggingEnabled) { \ + NSLog(__VA_ARGS__); \ + } + +#ifndef DEBUG +#undef WCDefaultLoggerDebug +#define WCDefaultLoggerDebug(...) \ + do { \ + } while (0) +#endif + +#endif + +/** Convenience macro of Info. */ +#ifndef WCDefaultLoggerInfo + +#define WCDefaultLoggerInfo(...) NSLog(__VA_ARGS__); + +#endif + +/** Convenience macro of Error. */ +#ifndef WCDefaultLoggerError + +#define WCDefaultLoggerError(...) \ + if (gWCVerboseLoggingEnabled) { \ + NSLog(__VA_ARGS__); \ + } + +#endif + +@interface WCDefaultLogger : NSObject +@end diff --git a/webchannel/objc/imported_src/Support/WCDefaultLogger.m b/webchannel/objc/imported_src/Support/WCDefaultLogger.m new file mode 100644 index 0000000..411ef27 --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCDefaultLogger.m @@ -0,0 +1,83 @@ +#import "WCDefaultLogger.h" +#import "WCHTTPRequest.h" + +#if WC_VERBOSE_LOGGING +BOOL gWCVerboseLoggingEnabled = YES; +#else +BOOL gWCVerboseLoggingEnabled = NO; +#endif // WC_VERBOSE_LOGGING + +@implementation WCDefaultLogger + +- (void)logInfo:(NSString *)message { + WCDefaultLoggerInfo(@"%@", message); +} + +- (void)logDebug:(NSString *)message { + WCDefaultLoggerDebug(@"%@", message); +} + +- (void)logWarning:(NSString *)message { + WCDefaultLoggerInfo(@"Warning: %@", message); +} + +- (void)dumpException:(NSException *)e withMessage:(NSString *)message { + WCDefaultLoggerError(@"Exception:%@: %@", e.name, message); +} + +- (void)logError:(NSString *)message { + WCDefaultLoggerError(@"%@", message); +} + +- (void)logHTTPRequest:(NSString *)verb + URL:(NSURL *)URL + ID:(NSString *)ID + attempt:(int64_t)attempt + postData:(NSString *)postData { + NSString *text = [NSString stringWithFormat:@"XMLHTTP REQ (%@) [attempt %lld ]: %@ \n %@ \n %@", ID, + attempt, verb, URL.absoluteString, postData]; + [self logDebug:text]; +} + +- (void)logHTTPChannelResponseMetaData:(NSString *)verb + URL:(NSURL *)URL + ID:(NSString *)ID + attempt:(int64_t)attempt + state:(WCRequestReadyState)state + statusCode:(int32_t)code { + NSString *text = + [NSString stringWithFormat:@"XMLHTTP RESP (%@) [attempt %lld ]: %@ \n %@ \n %@ %d", ID, + attempt, verb, URL.absoluteString, + [self formatRequestReadyStateToString:state], code]; + [self logDebug:text]; +} + +- (NSString*)formatRequestReadyStateToString:(WCRequestReadyState)state { + NSString *result = nil; + switch(state) { + case WCRequestReadyStateUninitialized: + result = @"RequestReadyState Uninitialized"; + break; + case WCRequestReadyStateLoaded: + result = @"WCRequestReadyState Loaded"; + break; + case WCRequestReadyStateInteractive: + result = @"WCRequestReadyState Interactive"; + break; + case WCRequestReadyStateComplete: + result = @"RequestReadyState Complete"; + break; + default: + result = @"Unexpected state"; + } + return result; +} + +- (void)logHTTPChannelResponseText:(NSString *)responseText + ID:(NSString *)ID + desc:(NSString *)desc { + NSString *text = [NSString stringWithFormat:@"XMLHTTP RESP (%@): %@ %@", ID, responseText, desc]; + [self logDebug:text]; +} + +@end diff --git a/webchannel/objc/imported_src/Support/WCDefaultURLEncoder.h b/webchannel/objc/imported_src/Support/WCDefaultURLEncoder.h new file mode 100644 index 0000000..f8bcbe9 --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCDefaultURLEncoder.h @@ -0,0 +1,6 @@ +#import + +#import "WCURLEncoder.h" + +@interface WCDefaultURLEncoder : NSObject +@end diff --git a/webchannel/objc/imported_src/Support/WCDefaultURLEncoder.m b/webchannel/objc/imported_src/Support/WCDefaultURLEncoder.m new file mode 100644 index 0000000..9d91407 --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCDefaultURLEncoder.m @@ -0,0 +1,10 @@ +#import "WCDefaultURLEncoder.h" + +@implementation WCDefaultURLEncoder + +- (NSString *)encode:(NSString *)data { + return [data + stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet alphanumericCharacterSet]]; +} + +@end diff --git a/webchannel/objc/imported_src/Support/WCFakeHTTPRequest.h b/webchannel/objc/imported_src/Support/WCFakeHTTPRequest.h new file mode 100644 index 0000000..d6208de --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCFakeHTTPRequest.h @@ -0,0 +1,7 @@ +#import + +#import "WCHTTPRequest.h" + +@interface WCFakeHTTPRequest : NSObject + +@end diff --git a/webchannel/objc/imported_src/Support/WCFakeHTTPRequest.m b/webchannel/objc/imported_src/Support/WCFakeHTTPRequest.m new file mode 100644 index 0000000..66eb9f8 --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCFakeHTTPRequest.m @@ -0,0 +1,33 @@ +#import "WCFakeHTTPRequest.h" + +#import "WCHTTPRequest.h" + +@implementation WCFakeHTTPRequest + +@synthesize requestReadyStateChangeHandler = _requestReadyStateChangeHandler; +@synthesize requestReadyState = _requestReadyState; +@synthesize requestErrorCode = _requestErrorCode; + +- (NSString *)responseHeaderForName:(NSString *)name { + return @""; +} + +- (int)status { + return 200; +} + +- (void)sendPOST:(NSURL *)URL + withData:(nullable NSData *)postData + withHeaders:(nullable NSDictionary *)headers + timeout:(NSTimeInterval)timeout{ + [_requestReadyStateChangeHandler stateChangedForRequest:self responseData:nil]; +} + +- (void)sendGET:(NSURL *)URL withHeaders:(nullable NSDictionary *)headers timeout:(NSTimeInterval)timeout { + [_requestReadyStateChangeHandler stateChangedForRequest:self responseData:nil]; +} + +- (void)abort { +} + +@end diff --git a/webchannel/objc/imported_src/Support/WCHTTPRequest.h b/webchannel/objc/imported_src/Support/WCHTTPRequest.h new file mode 100644 index 0000000..ba43802 --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCHTTPRequest.h @@ -0,0 +1,105 @@ +#import + +@protocol WCRequestStateChangedHandler; + +/** Types of request ready state enum */ +typedef NS_ENUM(NSInteger, WCRequestReadyState) { + WCRequestReadyStateUninitialized, + WCRequestReadyStateLoaded, + WCRequestReadyStateInteractive, + WCRequestReadyStateComplete, +}; + +/** Types of request error code enum */ +typedef NS_ENUM(NSInteger, WCRequestErrorCode) { + WCRequestErrorCodeNoError, + WCRequestErrorCodeAccessDenied, + WCRequestErrorCodeFileNotFound, + WCRequestErrorCodeFfSlientError, + WCRequestErrorCodeCustomError, + WCRequestErrorCodeException, + WCRequestErrorCodeHTTPError, + WCRequestErrorCodeAbort, + WCRequestErrorCodeTimeout, + WCRequestErrorCodeOffline, +}; + +typedef NS_ENUM(NSInteger, WCRequestStat) { + WCRequestStatConnectAttempt, + WCRequestStatErrorNetwork, + WCRequestStatErrorOther, + WCRequestStatTestStageOneStart, + WCRequestStatTestStageTwoStart, + WCRequestStatTestStageTwoDataOne, + WCRequestStatTestStageTwoDataTwo, + WCRequestStatTestStageTwoDataBoth, + WCRequestStatTestStageOneFailed, + WCRequestStatTestStageTwoFailed, + WCRequestStatProxy, + WCRequestStatNoProxy, + WCRequestStatRequestUnknownSessionId, + WCRequestStatRequestBadStatus, + WCRequestStatRequestIncompleteData, + WCRequestStatRequestBadData, + WCRequestStatRequestNoData, + WCRequestStatRequestTimeout, + WCRequestStatBackChannelMissing, + WCRequestStatBackChannelDead, + WCRequestStatBrowserOffline +}; + +@protocol WCHTTPRequest + +@property(nonatomic) id requestReadyStateChangeHandler; +@property(nonatomic, readonly) WCRequestReadyState requestReadyState; +@property(nonatomic, readonly) WCRequestErrorCode requestErrorCode; + +/** + * Get a specific field's value from HTTP response header. + * + * @param name The specific field name. + */ +- (NSString *)responseHeaderForName:(NSString *)name; + +/** Get HTTP status code. */ +- (int)status; + +/** + * Send a POST request. + * + * @param URL The request URL. + * @param postData The request body data. + * @param headers The request headers. + * @param timeout The request timeout. + */ +- (void)sendPOST:(NSURL *)URL + withData:(nullable NSData *)postData + withHeaders:(nullable NSDictionary *)headers + timeout:(NSTimeInterval)timeout; + +/** + * Send a GET request. + * + * @param URL The request URL. + * @param headers The request headers. + * @param timeout The request timeout. + */ +- (void)sendGET:(NSURL *)URL + withHeaders:(nullable NSDictionary *)headers + timeout:(NSTimeInterval)timeout; + +/** Cancel the current request. */ +- (void)abort; + +@end + +@protocol WCRequestStateChangedHandler + +/** + * Handle @WCRequestReadyState changes. + * + * @param request The request be listened. + * @param data The response data. + */ +- (void)stateChangedForRequest:(id)request responseData:(NSData *)data; +@end diff --git a/webchannel/objc/imported_src/Support/WCJSONDecoder.h b/webchannel/objc/imported_src/Support/WCJSONDecoder.h new file mode 100644 index 0000000..0bee18a --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCJSONDecoder.h @@ -0,0 +1,13 @@ +#import + +@protocol WCJSONDecoder + +/** + * Decode an array up to the specified maxDepth (maximum being 3). + * + * @param data Data input. + * @param maxDepth The level to flatten the nested data. + */ +- (NSArray *)decodeData:(NSData *)data maxDepth:(int)maxDepth; + +@end diff --git a/webchannel/objc/imported_src/Support/WCLogger.h b/webchannel/objc/imported_src/Support/WCLogger.h new file mode 100644 index 0000000..54f895e --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCLogger.h @@ -0,0 +1,68 @@ +#import + +#import "WCHTTPRequest.h" + +/** + * To enable assert for WC in your DEBUG build, either build the project with the + * @c WC_DEV_ASSERT preprocessor symbol defined or set the @c gWCDevAssertEnabled global to + * YES. + * + * Example of YouTube Usage: + * @code + * googlemac/iPhone/YouTube/Tools/Bazel/GenerateMyProject.py \ + * --project=YouTube \ + * --objccopt=WC_DEV_ASSERT=1 + * @endcode + */ +extern BOOL gWCDevAssertEnabled; + +/** The convenience macros are only defined if they haven't already been defined. */ +#ifndef WCDevAssert + +/** Convenience macro that calls the NSAssert if |MDXDevAssertEnabled| is YES. */ +#define WCDevAssert(condition, ...) \ + if (gWCDevAssertEnabled) { \ + NSAssert(condition, __VA_ARGS__); \ + } + +/** + * If we're not in a debug build, remove the WCDevAssert statements. This makes calls to + * WCDevAssert "compile out" of Release builds. + */ +#ifndef DEBUG +#undef WCDevAssert +#define WCDevAssert(...) \ + do { \ + } while (0) +#endif + +#endif // !defined(WCDevAssert) + +@protocol WCLogger + +- (void)logInfo:(NSString *)message; + +- (void)logDebug:(NSString *)message; + +- (void)logWarning:(NSString *)message; + +- (void)dumpException:(NSException *)e withMessage:(NSString *)message; + +- (void)logError:(NSString *)message; + +- (void)logHTTPRequest:(NSString *)verb + URL:(NSURL *)URL + ID:(NSString *)ID + attempt:(int64_t)attempt + postData:(NSString *)postData; + +- (void)logHTTPChannelResponseMetaData:(NSString *)verb + URL:(NSURL *)URL + ID:(NSString *)ID + attempt:(int64_t)attempt + state:(WCRequestReadyState)state + statusCode:(int32_t)code; + +- (void)logHTTPChannelResponseText:(NSString *)text ID:(NSString *)ID desc:(NSString *)desc; + +@end diff --git a/webchannel/objc/imported_src/Support/WCLogger.m b/webchannel/objc/imported_src/Support/WCLogger.m new file mode 100644 index 0000000..36c3ff5 --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCLogger.m @@ -0,0 +1,7 @@ +#import "WCLogger.h" + +#if WC_DEV_ASSERT +BOOL gWCDevAssertEnabled = YES; +#else +BOOL gWCDevAssertEnabled = NO; +#endif // WC_DEV_ASSERT diff --git a/webchannel/objc/imported_src/Support/WCURLEncoder.h b/webchannel/objc/imported_src/Support/WCURLEncoder.h new file mode 100644 index 0000000..5e711e5 --- /dev/null +++ b/webchannel/objc/imported_src/Support/WCURLEncoder.h @@ -0,0 +1,7 @@ +#import + +@protocol WCURLEncoder + +- (NSString *)encode:(NSString *)data; + +@end diff --git a/webchannel/objc/imported_src/Tests/WCChannelRequestTest.m b/webchannel/objc/imported_src/Tests/WCChannelRequestTest.m new file mode 100644 index 0000000..2452aa5 --- /dev/null +++ b/webchannel/objc/imported_src/Tests/WCChannelRequestTest.m @@ -0,0 +1,135 @@ +#import "WCHTTPRequest.h" +#import "WCChannelRequest.h" + +#import + +#import "WCSupport.h" +#import "WCTimer.h" +#import "WCWebChannelClientInternal.h" +#import +#import + +static const long kChannelRequestDefaultTimeout = 45; +static NSString *const kFakePOSTResponse = @"7\n[0,0,7]"; +static NSString *const kFakeGETResponse = @"14\n[[1,[\"noop\"]]]14\n[[2,[\"noop\"]]]"; + +@interface WCChannelRequestTest : XCTestCase +@end + +@implementation WCChannelRequestTest { + WCChannelRequest *_request; + id _mockSupport; + id _mockHTTPInternalHandler; + id _mockHttpRequest; +} +- (void)setUp { + [super setUp]; + + _mockSupport = OCMProtocolMock(@protocol(WCSupport)); + _mockHTTPInternalHandler = OCMProtocolMock(@protocol(WCWebChannelInternalHTTPHandler)); + _mockHttpRequest = OCMProtocolMock(@protocol(WCHTTPRequest)); + OCMStub([_mockSupport HTTPRequest:[OCMArg any]]).andReturn(_mockHttpRequest); + OCMStub(_mockSupport.dispatchQueue).andReturn(dispatch_get_main_queue()); + id dummyTimer = OCMProtocolMock(@protocol(WCTimer)); + OCMStub([_mockSupport setTimeout:0 block:[OCMArg any]]) + .ignoringNonObjectArgs() + .andReturn(dummyTimer) + .andDo(^(NSInvocation *invocation) { + NSTimeInterval timeout; + [invocation getArgument:&timeout atIndex:2]; + __unsafe_unretained void (^block)(void); + [invocation getArgument:&block atIndex:3]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC)), + dispatch_get_main_queue(), block); + }); + OCMStub([_mockSupport clearTimeout:[OCMArg any]]); + + _request = [[WCChannelRequest alloc] initWithSessionID:@"sessionID" + requestID:@"requestID" + support:_mockSupport + delegate:_mockHTTPInternalHandler]; +} + +- (void)testSendPOSTSuccess { + NSURLComponents *URLComponent = [NSURLComponents componentsWithString:@"url"]; + NSData *data = [@"data" dataUsingEncoding:NSUTF8StringEncoding]; + OCMExpect([_mockHttpRequest sendPOST:URLComponent.URL + withData:data + withHeaders:@{@"Content-Type" : @"application/x-www-form-urlencoded"} + timeout:kChannelRequestDefaultTimeout]) + .ignoringNonObjectArgs; + + [_request sendPOST:URLComponent withData:@"data" chunkDecoded:YES]; + + XCTAssertTrue(_request.POST); + XCTAssertEqualObjects(_request.POSTData, data); +} + +- (void)testSendPOSTBinarySuccess { + NSURLComponents *URLComponent = [NSURLComponents componentsWithString:@"url"]; + NSData *data = [@"binaryData" dataUsingEncoding:NSUTF8StringEncoding]; + _request.isBinaryMessage = YES; + + OCMExpect([_mockHttpRequest + sendPOST:URLComponent.URL + withData:data + withHeaders:@{@"Content-Type" : @"application/vnd.google.octet-stream-compressible"} + timeout:kChannelRequestDefaultTimeout]) + .ignoringNonObjectArgs; + + [_request sendPOST:URLComponent withPostData:data chunkDecoded:YES]; + + XCTAssertTrue(_request.POST); + XCTAssertEqualObjects(_request.POSTData, data); + XCTAssertTrue(_request.isBinaryMessage); + OCMVerifyAll((id)_mockHttpRequest); +} + +- (void)testSendGETSuccess { + NSURLComponents *URLComponent = [NSURLComponents componentsWithString:@"url"]; + OCMExpect([_mockHttpRequest sendGET:URLComponent.URL + withHeaders:@{} + timeout:kChannelRequestDefaultTimeout]) + .ignoringNonObjectArgs; + + [_request sendGET:URLComponent chunkDecoded:YES]; + XCTAssertFalse(_request.POST); +} + +- (void)testDecodePOSTResponseChunkSuccess { + OCMExpect([_mockHTTPInternalHandler didReceivedFirstByteOfRequest:_request + responseText:kFakePOSTResponse]); + OCMExpect([_mockHTTPInternalHandler didReceiveInput:@"[0,0,7]" withRequest:_request]); + [_request decodeNextChunks:kFakePOSTResponse state:WCRequestReadyStateComplete]; +} + +- (void)testDecodeGETResponseChunkSuccess { + OCMExpect([_mockHTTPInternalHandler didReceiveInput:@"[[1,[\"noop\"]]]" withRequest:_request]); + OCMExpect([_mockHTTPInternalHandler didReceiveInput:@"[[2,[\"noop\"]]]" withRequest:_request]); + OCMExpect([_mockHTTPInternalHandler didReceivedFirstByteOfRequest:_request + responseText:kFakeGETResponse]); + + [_request decodeNextChunks:kFakeGETResponse state:WCRequestReadyStateComplete]; +} + +- (void)testDecodeInvalidChunks { + [_request decodeNextChunks:@"" state:WCRequestReadyStateComplete]; + XCTAssertFalse(_request.isSuccessful); +} + +- (void)testDecodeInvalidChunksWithNegativeSize { + // A corrupted stream where a wrong chunk size (10 instead of 5) + // causes the parser to land on the negative number in the next JSON payload, + // parsing it as a negative chunk size (but fails format validation). + NSString *corruptedResponse = @"10\n12345\n12\n[-5,\"data\"]\n"; + [_request decodeNextChunks:corruptedResponse state:WCRequestReadyStateInteractive]; + XCTAssertFalse(_request.isSuccessful); +} + +- (void)testDecodeNegativeChunkSize { + // Directly tests the negative chunk size safety check. + NSString *negativeSizeResponse = @"-5\n"; + [_request decodeNextChunks:negativeSizeResponse state:WCRequestReadyStateInteractive]; + XCTAssertFalse(_request.isSuccessful); +} +@end diff --git a/webchannel/objc/imported_src/Tests/WCDefaultSupportTest.m b/webchannel/objc/imported_src/Tests/WCDefaultSupportTest.m new file mode 100644 index 0000000..4138a86 --- /dev/null +++ b/webchannel/objc/imported_src/Tests/WCDefaultSupportTest.m @@ -0,0 +1,92 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import + +#import "WCDefaultSupport.h" +#import "WCSupport.h" +#import "WCTimer.h" +#import + +@interface WCDefaultSupportTest : XCTestCase +@end + +@implementation WCDefaultSupportTest + +- (void)testTimerFiresOnBackgroundGCDQueueWithoutRunLoop { + dispatch_queue_t backgroundQueue = + dispatch_queue_create("WCDefaultSupportTestBackground", DISPATCH_QUEUE_SERIAL); + void *queueKey = &queueKey; + dispatch_queue_set_specific(backgroundQueue, queueKey, (__bridge void *)backgroundQueue, nil); + + GTMSessionFetcherService *fetcherService = + [GTMSessionFetcherService mockFetcherServiceWithFakedData:[NSData data] fakedError:nil]; + WCDefaultSupport *support = [[WCDefaultSupport alloc] initWithDispatchQueue:backgroundQueue + fetcherService:fetcherService]; + + XCTestExpectation *expectation = + [self expectationWithDescription:@"Timer fires on background queue"]; + + __block BOOL fired = NO; + __block BOOL isOnCorrectQueue = NO; + + id timer = [support setTimeout:0.01 + block:^{ + fired = YES; + void *specificValue = dispatch_get_specific(queueKey); + if (specificValue == (__bridge void *)backgroundQueue) { + isOnCorrectQueue = YES; + } + [expectation fulfill]; + }]; + XCTAssertNotNil(timer); + + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertTrue(fired, @"Timer must fire"); + XCTAssertTrue(isOnCorrectQueue, @"Timer block must execute on designated background queue"); +} + +- (void)testTimerCanBeCancelled { + dispatch_queue_t backgroundQueue = + dispatch_queue_create("WCDefaultSupportTestCancel", DISPATCH_QUEUE_SERIAL); + GTMSessionFetcherService *fetcherService = + [GTMSessionFetcherService mockFetcherServiceWithFakedData:[NSData data] fakedError:nil]; + WCDefaultSupport *support = [[WCDefaultSupport alloc] initWithDispatchQueue:backgroundQueue + fetcherService:fetcherService]; + + __block BOOL fired = NO; + + id timer = [support setTimeout:0.05 + block:^{ + fired = YES; + }]; + + // Cancel the timer immediately using clearTimeout: + [support clearTimeout:timer]; + + // Wait long enough to make sure it would have fired if not cancelled + XCTestExpectation *waitExpectation = + [self expectationWithDescription:@"Waiting for cancellation check"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), backgroundQueue, + ^{ + [waitExpectation fulfill]; + }); + + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertFalse(fired, @"Timer must not fire after cancellation"); +} + +@end diff --git a/webchannel/objc/imported_src/Tests/WCWebChannelClientTest.m b/webchannel/objc/imported_src/Tests/WCWebChannelClientTest.m new file mode 100644 index 0000000..cbe3c42 --- /dev/null +++ b/webchannel/objc/imported_src/Tests/WCWebChannelClientTest.m @@ -0,0 +1,380 @@ +#import "WCDefaultJSONDecoder.h" +#import "WCFakeHTTPRequest.h" +#import "WCHTTPRequest.h" +#import "WCJSONDecoder.h" +#import "WCChannelRequest.h" +#import "WCForwardChannelRequestPool.h" +#import "WCOptions.h" +#import "WCQueuedMap.h" +#import "WCSupport.h" +#import "WCTimer.h" +#import "WCWebChannelClient.h" +#import "WCWebChannelClientInternal.h" + +#import + +#import +#import +#import + +static const int kRealServerVersion = 8; +static const double kRunLoopDelay = 0.1; + +@interface WCWebChannelClientInternal () +@end + +@interface WCWebChannelClientTest : XCTestCase +@end + +@implementation WCWebChannelClientTest { + WCWebChannelClientInternal *_channel; + id _mockSupport; + id _mockDelegate; + GTMSessionFetcherService *_fetcherService; +} + +- (void)setUp { + [super setUp]; + _mockSupport = OCMProtocolMock(@protocol(WCSupport)); + _mockDelegate = OCMProtocolMock(@protocol(WCWebChannelClientHandlerDelegate)); + _fetcherService = [GTMSessionFetcherService mockFetcherServiceWithFakedData:[NSData data] + fakedError:nil]; + id _fakeRequest = [[WCFakeHTTPRequest alloc] init]; + id _decoder = [[WCDefaultJSONDecoder alloc] init]; + OCMStub(_mockSupport.JSONDecoder).andReturn(_decoder); + OCMStub([_mockSupport HTTPRequest:OCMOCK_ANY]).andReturn(_fakeRequest); + OCMStub(_mockSupport.dispatchQueue).andReturn(dispatch_get_main_queue()); + + id dummyTimer = OCMProtocolMock(@protocol(WCTimer)); + OCMStub([_mockSupport setTimeout:0 block:[OCMArg any]]) + .ignoringNonObjectArgs() + .andReturn(dummyTimer) + .andDo(^(NSInvocation *invocation) { + NSTimeInterval timeout; + [invocation getArgument:&timeout atIndex:2]; + __unsafe_unretained void (^block)(void); + [invocation getArgument:&block atIndex:3]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC)), + dispatch_get_main_queue(), block); + }); + OCMStub([_mockSupport clearTimeout:[OCMArg any]]); + + _channel = [[WCWebChannelClientInternal alloc] initWithURL:@"" + options:nil + connectionState:nil + clientVersion:1 + delegate:_mockDelegate + support:_mockSupport]; +} + +- (void)testOpen { + [self open]; + XCTAssertEqual(WCWebChannelClientStateOpened, _channel.state); + XCTAssertEqual(kWCLastChannelVersion, _channel.channelVersion); + XCTAssertNil(_channel.backChannelRequest); +} + +- (void)testSend { + [self open]; + [self send:@"foo" value:@"bar"]; + XCTAssertTrue([_channel.outgoingMaps[0].map[@"__data__"] isEqualToString:@"foo:bar"]); +} + +- (void)testSendAndReceive { + OCMStub([_mockSupport notifyTimingEventWithSize:0 withRTT:0 withRetries:0]).ignoringNonObjectArgs; + [self open]; + [self send:@"foo" value:@"bar"]; + [self receivedResponse]; + [self receive:@"[\"the server reply\"]"]; + XCTAssertNil(_channel.backChannelRequest); +} + +- (void)testReceive { + [self open]; + [self receive:@"\"[message from server]\""]; + XCTAssertNil(_channel.backChannelRequest); +} + +- (void)testReceiveAndSend { + [self open]; + [self receive:@"[\"the server reply\"]"]; + [self send:@"foo" value:@"bar"]; + [self receivedResponse]; + XCTAssertNil(_channel.backChannelRequest); +} + +- (void)testForwardRequestTimeout { + [self open]; + [self send:@"foo" value:@"bar"]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + + BOOL found = NO; + const NSTimeInterval kDefaultTimeout = 45.0; + const NSTimeInterval kMaxExpectedTimeout = 20.0; + + for (WCChannelRequest *req in _channel.forwardChannelRequestPool.requestPool) { + // Skip the handshake request which uses the default timeout of 45 seconds. + if (req.timeout != kDefaultTimeout) { + // With rand() normalized to 0..1, the timeout should be: + // base (10s) + base (10s) * (0..1) = 10..20 seconds. + XCTAssertLessThanOrEqual(req.timeout, kMaxExpectedTimeout); + found = YES; + } + } + XCTAssertTrue(found, @"Should have found a new forward request with non-default timeout"); +} + +- (void)testBufferingProxyDetectionNoImmediateFallback { + WCOptions *options = [[WCOptions alloc] init]; + options.detectBufferingProxy = YES; + + _channel = [[WCWebChannelClientInternal alloc] initWithURL:@"" + options:options + connectionState:nil + clientVersion:1 + delegate:_mockDelegate + support:_mockSupport]; + + [self open]; + + // Handshake takes ~0.1s in tests, so RTT is ~0.1s. + // Timeout is 2 * RTT = ~0.2s. + // Wait 0.1s, the timer should not have fired yet. + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + + XCTAssertTrue(_channel.isStreamingEnabled, @"Streaming should still be enabled before timeout"); +} + +- (void)testOutgoingMapsAwaitResponse { + [self open]; + XCTAssertEqual(0, _channel.outgoingMaps.count); + + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + [self send:@"foo1" value:@"bar"]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + XCTAssertEqual(0, _channel.outgoingMaps.count); + [self send:@"foo2" value:@"bar"]; + XCTAssertEqual(1, _channel.outgoingMaps.count); + [self send:@"foo3" value:@"bar"]; + XCTAssertEqual(2, _channel.outgoingMaps.count); + [self send:@"foo4" value:@"bar"]; + XCTAssertEqual(3, _channel.outgoingMaps.count); + [self receivedResponse]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + XCTAssertEqual(0, _channel.outgoingMaps.count); +} + +- (void)testUndeliveredMapsDoesNotNotifyWhenSuccessful { + OCMStub([_mockDelegate webChannelClosed:nil]); + [self open]; + [self send:@"foo1" value:@"bar1"]; + [self receivedResponse]; + [self send:@"foo2" value:@"bar2"]; + [self receivedResponse]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + [self close]; +} + +- (void)testUndeliveredMapsClearsPendingMapsAfterClosing { + [self open]; + [self send:@"foo1" value:@"bar1"]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + [self send:@"foo2" value:@"bar2"]; + [self send:@"foo3" value:@"bar3"]; + XCTAssertEqual(1, _channel.forwardChannelRequestPool.pendingMessages.count); + XCTAssertEqual(2, _channel.outgoingMaps.count); + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + [self close]; + XCTAssertEqual(0, _channel.forwardChannelRequestPool.pendingMessages.count); + XCTAssertEqual(0, _channel.outgoingMaps.count); +} + +- (void)testResponseWithNoArraySent { + [self connectForwardChannel]; + [self completeBackChannel]; + [self send:@"foo1" value:@"bar1"]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + [self response:[NSNumber numberWithInt:-1] dataSize:[NSNumber numberWithInt:0]]; + XCTAssertEqual(1, _channel.lastResponseCount); + XCTAssertEqual(-1, _channel.lastPostResponseCount); +} + +- (void)testDidReceiveMessageWithMetadataHeaders { + OCMStub([_mockDelegate webChannel:nil didReceiveHeaders:OCMOCK_ANY statusCode:404]); + + [self open]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + NSString *responseString = + @"[[5,{\"__headers__\":{\"test_header\":\"test_vlaue\",\"x-webchannel-metadata\":" + @"\"echo the headers/status\"},\"__status__\":404}],[6,{\"message\":\"abc\"}]]"; + [_channel didReceiveInput:responseString withRequest:_channel.backChannelRequest]; + [_channel handleCompleteRequest:_channel.backChannelRequest]; + XCTAssertNil(_channel.backChannelRequest); +} + +- (void)testDidReceiveMessageWithMetadata { + NSArray *> *> *expectedMetadata = @[ @[ @{ + @"error" : @{ + @"code" : @409, + @"details" : @[ @{ + @"@type" : @"type.googleapis.com/google.rpc.DebugInfo", + @"detail" : @"[ORIGINAL ERROR] generic::aborted: Aborted from server side." + } ], + @"message" : @"The operation was aborted.", + @"status" : @"ABORTED", + } + } ] ]; + OCMStub([_mockDelegate webChannel:nil didReceiveMetadata:expectedMetadata key:@"status"]); + OCMStub([_mockDelegate webChannelClosed:nil]); + + [self open]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + // See go/webchannel-oneplatform#message-formats + NSString *response = + @"[" + @" [2," + @" {\"__sm__\": {" + @" \"status\": [[{" + @" \"error\": {" + @" \"code\": 409," + @" \"details\": [{" + @" \"@type\": \"type.googleapis.com/google.rpc.DebugInfo\"," + @" \"detail\": \"[ORIGINAL ERROR] generic::aborted: Aborted from server side.\"" + @" }]," + @" \"message\": \"The operation was aborted.\"," + @" \"status\": \"ABORTED\"" + @" }" + @" }]]" + @" }}" + @" ]," + @" [3,[\"close\"]]" + @"]"; + [_channel didReceiveInput:response withRequest:_channel.backChannelRequest]; + [_channel handleCompleteRequest:_channel.backChannelRequest]; + XCTAssertNil(_channel.backChannelRequest); +} + +- (void)testInitialAndMessageHeaders { + WCOptions *options = [[WCOptions alloc] init]; + options.initialMessageHeaders = @{@"initial_header" : @"initial_value"}; + options.messageHeaders = @{@"test_header" : @"test_value"}; + _channel = [[WCWebChannelClientInternal alloc] initWithURL:@"" + options:options + connectionState:nil + clientVersion:1 + delegate:_mockDelegate + support:_mockSupport]; + + // 1. Open channel + [_channel open]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + + // Check open channel request headers + WCChannelRequest *openChannelRequest = [self getSingleForwardRequest]; + XCTAssertNotNil(openChannelRequest.extraHeaders); + XCTAssertEqualObjects(@"initial_value", openChannelRequest.extraHeaders[@"initial_header"]); + XCTAssertEqualObjects(@"test_value", openChannelRequest.extraHeaders[@"test_header"]); + + // 2. Complete open channel request to trigger back channel request + [self completeForwardChannel]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + + // Check back channel request headers + XCTAssertNotNil(_channel.backChannelRequest.extraHeaders); + XCTAssertEqualObjects(@"test_value", _channel.backChannelRequest.extraHeaders[@"test_header"]); + + // 3. Complete back channel + [self completeBackChannel]; + + // 4. Send message to trigger another forward channel request + [self send:@"foo" value:@"bar"]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + WCChannelRequest *forwardRequest = [self getSingleForwardRequest]; + XCTAssertNotNil(forwardRequest.extraHeaders); + XCTAssertEqualObjects(@"test_value", forwardRequest.extraHeaders[@"test_header"]); +} + +- (void)testUnknownSessionIdErrorPropagation { + OCMStub([_mockDelegate webChannel:nil encounteredError:WCWebChannelClientErrorUnknownSessionID]); + + [self connectForwardChannel]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + + // Simulate an Unknown Session ID error on the backchannel request + WCChannelRequest *request = _channel.backChannelRequest; + XCTAssertNotNil(request); + [request setValue:@(WCChannelRequestErrorUnknownSessionId) forKey:@"lastError"]; + id mockRequest = OCMPartialMock(request); + OCMStub([mockRequest isSuccessful]).andReturn(NO); + XCTAssertTrue([mockRequest isLastErrorFatal]); + [_channel setValue:mockRequest forKey:@"backChannelRequest"]; + + [_channel handleCompleteRequest:mockRequest]; + + OCMVerifyAll((id)_mockDelegate); +} + +#pragma Private + +- (void)open { + [self connectForwardChannel]; + [self completeBackChannel]; +} + +- (void)connectForwardChannel { + [_channel open]; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + [self completeForwardChannel]; +} + +- (void)completeForwardChannel { + NSString *serverVersionString = [NSString stringWithFormat:@"%d", kRealServerVersion]; + NSString *responseData = [NSString + stringWithFormat:@"[[0,[\"c\",\"1234567890ABCDEF\",\"null\", %@]]]", serverVersionString]; + WCChannelRequest *forwardRequest = [self getSingleForwardRequest]; + WCChannelRequest *mockForwardRequest = OCMPartialMock(forwardRequest); + OCMStub([mockForwardRequest isSuccessful]).andReturn(YES); + [_channel handleCompleteRequest:mockForwardRequest]; + [_channel didReceiveInput:responseData withRequest:forwardRequest]; +} + +- (WCChannelRequest *)getSingleForwardRequest { + return _channel.forwardChannelRequestPool.requestPool.anyObject; +} + +- (void)completeBackChannel { + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopDelay]]; + [_channel didReceiveInput:@"[[1,[\"foo\"]]]" withRequest:_channel.backChannelRequest]; + [_channel handleCompleteRequest:_channel.backChannelRequest]; +} + +- (void)send:(NSString *)key value:(NSString *)value { + [_channel send:[NSString stringWithFormat:@"%@:%@", key, value]]; +} + +- (void)receive:(NSString *)data { + [_channel didReceiveInput:[NSString stringWithFormat:@"[[1,%@]]", data] + withRequest:_channel.backChannelRequest]; + [_channel handleCompleteRequest:_channel.backChannelRequest]; +} + +- (void)receivedResponse { + WCChannelRequest *forwardRequest = [self getSingleForwardRequest]; + [_channel didReceiveInput:@"[1,0,0]" withRequest:forwardRequest]; + [_channel handleCompleteRequest:forwardRequest]; +} + +- (void)response:(NSNumber *)lastResponseID dataSize:(NSNumber *)dataSize { + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:@[ @1, lastResponseID, dataSize ] + options:NSJSONWritingPrettyPrinted + error:nil]; + NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + WCChannelRequest *request = [self getSingleForwardRequest]; + [_channel didReceiveInput:jsonString withRequest:request]; + [_channel handleCompleteRequest:request]; +} + +- (void)close { + [_channel close]; +} +@end diff --git a/webchannel/objc/imported_src/Tests/WCWireV8BinaryTest.m b/webchannel/objc/imported_src/Tests/WCWireV8BinaryTest.m new file mode 100644 index 0000000..0d4925d --- /dev/null +++ b/webchannel/objc/imported_src/Tests/WCWireV8BinaryTest.m @@ -0,0 +1,73 @@ +#import +#import "WCQueuedMap.h" +#import "WCSupport.h" +#import "WCWireV8Binary.h" +#import + +@interface WCWireV8BinaryTest : XCTestCase +@end + +@implementation WCWireV8BinaryTest { + WCWireV8Binary *_wire; + id _mockSupport; +} + +- (void)setUp { + [super setUp]; + _mockSupport = OCMProtocolMock(@protocol(WCSupport)); + _wire = [[WCWireV8Binary alloc] initWithSupport:_mockSupport]; +} + +- (void)testEncodeEmptyMessageQueue { + NSArray *messages = @[]; + NSData *encoded = [_wire encodeMessageQueue:messages numOfMessages:0]; + NSString *encodedString = [[NSString alloc] initWithData:encoded encoding:NSUTF8StringEncoding]; + XCTAssertEqualObjects(encodedString, @"count=0&\r\n"); +} + +- (void)testEncodeSingleMessage { + NSDictionary *map1 = @{@"__data__" : @"message1"}; + WCQueuedMap *qMap1 = [[WCQueuedMap alloc] initWithMapID:101 map:map1 context:nil]; + NSArray *messages = @[ qMap1 ]; + + NSData *encoded = [_wire encodeMessageQueue:messages numOfMessages:1]; + NSString *encodedString = [[NSString alloc] initWithData:encoded encoding:NSUTF8StringEncoding]; + + NSString *expected = @"count=1&ofs=101\r\nid=0&size=8\r\nmessage1"; + XCTAssertEqualObjects(encodedString, expected); +} + +- (void)testEncodeMultipleMessages { + NSDictionary *map1 = @{@"__data__" : @"message1"}; + WCQueuedMap *qMap1 = [[WCQueuedMap alloc] initWithMapID:101 map:map1 context:nil]; + + NSDictionary *map2 = @{@"__data__" : @"msg2"}; + WCQueuedMap *qMap2 = [[WCQueuedMap alloc] initWithMapID:102 map:map2 context:nil]; + + NSArray *messages = @[ qMap1, qMap2 ]; + + NSData *encoded = [_wire encodeMessageQueue:messages numOfMessages:2]; + NSString *encodedString = [[NSString alloc] initWithData:encoded encoding:NSUTF8StringEncoding]; + + NSString *expected = @"count=2&ofs=101\r\nid=0&size=8\r\nmessage1id=1&size=4\r\nmsg2"; + XCTAssertEqualObjects(encodedString, expected); +} + +- (void)testEncodeSingleBinaryMessage { + char bytes[] = {0x00, 0x01, 0xFF, 0xFE}; + NSData *binaryData = [NSData dataWithBytes:bytes length:4]; + NSDictionary *map1 = @{@"__data__" : binaryData}; + WCQueuedMap *qMap1 = [[WCQueuedMap alloc] initWithMapID:101 map:map1 context:nil]; + NSArray *messages = @[ qMap1 ]; + + NSData *encoded = [_wire encodeMessageQueue:messages numOfMessages:1]; + + NSMutableData *expected = [NSMutableData data]; + [expected + appendData:[@"count=1&ofs=101\r\nid=0&size=4\r\n" dataUsingEncoding:NSUTF8StringEncoding]]; + [expected appendData:binaryData]; + + XCTAssertEqualObjects(encoded, expected); +} + +@end diff --git a/webchannel/objc/imported_src/WCChannelRequest.h b/webchannel/objc/imported_src/WCChannelRequest.h new file mode 100644 index 0000000..7b25f5a --- /dev/null +++ b/webchannel/objc/imported_src/WCChannelRequest.h @@ -0,0 +1,129 @@ +#import + +#import "WCHTTPRequest.h" + +@protocol WCWebChannelInternalHTTPHandler; +@protocol WCHTTPRequest; +@protocol WCSupport; +@class WCQueuedMap; +@class GTMSessionFetcherService; + +typedef NS_ENUM(NSInteger, WCChannelRequestError) { + WCChannelRequestErrorUnknown, + WCChannelRequestErrorStatus, + WCChannelRequestErrorNoData, + WCChannelRequestErrorTimeout, + WCChannelRequestErrorUnknownSessionId, + WCChannelRequestErrorBadData, + WCChannelRequestErrorHandlerException, + WCChannelRequestErrorBrowserOffline +}; + +/** + * A request object that holds params for a request, provide functions of sending GET/POST requests, + * and handle events. + */ +@interface WCChannelRequest : NSObject + +@property(nonatomic, readonly) NSString *sessionID; +@property(nonatomic, readonly) NSString *requestID; +@property(nonatomic, readonly) int retryID; +@property(nonatomic) NSMutableDictionary *extraHeaders; +@property(nonatomic) NSMutableArray *pendingMessages; +@property(nonatomic, readonly) id request; +@property(nonatomic) NSTimeInterval timeout; +@property(nonatomic, readonly) int lastStatusCode; +@property(nonatomic, readonly, getter=isSuccessful) BOOL successful; +@property(nonatomic, readonly) BOOL firstByteReceived; +@property(nonatomic, readonly) BOOL cancelled; +@property(nonatomic, readonly, getter=isPOST) BOOL POST; +@property(nonatomic, readonly) NSData *POSTData; +@property(nonatomic, readonly) NSDate *requestStartTime; +@property(nonatomic, readonly) NSMutableData *responseData; +@property(nonatomic, readonly) WCChannelRequestError lastError; +@property(nonatomic, readonly, getter=isLastErrorFatal) BOOL lastErrorFatal; +@property(nonatomic, getter=isInitialResponseDecoded) BOOL initialResponseDecoded; +@property(nonatomic) BOOL isBinaryMessage; + +/** + * @param sessionID The ID of current webChannel session. + * @param requestID The ID of this request, starting from a random value and incrementing. + * @param support The Support instance provides all the utils. + * @param delegate The @WCWebChannelInternalHTTPHandler handle inputs, completion, and error for the + * channel. + * @param retryID The ID of retry. + */ +- (instancetype)initWithSessionID:(NSString *)sessionID + requestID:(NSString *)requestID + support:(id)support + delegate:(id)delegate + retryID:(int)retryID; + +/** + * @param sessionID The ID of current webChannel session. + * @param requestID The ID of this request, starting from a random value and incrementing. + * @param support The Support instance provides all the utils. + * @param delegate The @WCWebChannelInternalHTTPHandler handle inputs, completion, and error for the + * channel. + */ +- (instancetype)initWithSessionID:(NSString *)sessionID + requestID:(NSString *)requestID + support:(id)support + delegate:(id)delegate; + +/** + * Send POST request. + * + * @param URLComponents The request URL in components, including baseURL, queryParams, etc. + * @param postData The data sending out. + * @param chunkDecoded Indicate whether chunk should be decoded. + */ +- (void)sendPOST:(NSURLComponents *)URLComponents + withData:(NSString *)postData + chunkDecoded:(BOOL)chunkDecoded; + +/** + * Send POST request with binary data. + * + * @param URLComponents The request URL in components, including baseURL, queryParams, etc. + * @param postData The data sending out. + * @param chunkDecoded Indicate whether chunk should be decoded. + */ +- (void)sendPOST:(NSURLComponents *)URLComponents + withPostData:(NSData *)postData + chunkDecoded:(BOOL)chunkDecoded; + +/** + * Send GET request. + * + * @param URLComponents The request URL in components, including baseURL, queryParams, etc. + * @param chunkDecoded Indicate whether chunk should be decoded. + */ +- (void)sendGET:(NSURLComponents *)URLComponents chunkDecoded:(BOOL)chunkDecoded; + +/** + * Send a GET request with type=close. + * + * @param URLComponents The request URL in components, including baseURL, queryParams, etc. + */ +- (void)closeWithURLComponents:(NSURLComponents *)URLComponents; + +/** Cancel current request. */ +- (void)cancel; + +/** + * Format @WCChannelRequestError enum to string for log convenience. + * + * @param error The @WCChannelRequestError to be formatted. + */ +- (NSString *)formatErrorToString:(WCChannelRequestError)error; + +/** + * Decode chunks of responses. Exposed for testing. + * + * @param responseText The responses sent back from server. + * @param readyState The enum indicate if the request is complete. + */ +- (void)decodeNextChunks:(NSString *)responseText state:(WCRequestReadyState)readyState; + +@end diff --git a/webchannel/objc/imported_src/WCChannelRequest.m b/webchannel/objc/imported_src/WCChannelRequest.m new file mode 100644 index 0000000..24a0b6f --- /dev/null +++ b/webchannel/objc/imported_src/WCChannelRequest.m @@ -0,0 +1,493 @@ +#import "WCChannelRequest.h" + +#import "WCHTTPRequest.h" +#import "WCLogger.h" +#import "WCSupport.h" +#import "WCTimer.h" +#import "WCWebChannelClientInternal.h" + +typedef NS_ENUM(NSInteger, WCChannelRequestType) { + WCChannelRequestTypeSendRequest, + WCChannelRequestTypeCloseRequest, +}; + +typedef NS_ENUM(NSInteger, WCChannelRequestDecodeResult) { + WCChannelRequestDecodeResultSuccess, + WCChannelRequestDecodeResultIncomplete, + WCChannelRequestDecodeResultInvalid, +}; + +static const long kChannelRequestDefaultTimeout = 45; +static const int kDefaultRetryID = 1; +static const int kStatusCodeOK = 200; +static const int kStatusCodeBadRequest = 400; + +static NSString *const kQueryItemNameT = @"t"; +static NSString *const kHeaderContentTypeValue = @"application/x-www-form-urlencoded"; +static NSString *const kHeaderBinaryContentTypeValue = + @"application/vnd.google.octet-stream-compressible"; +static NSString *const kHeaderContentTypeKey = @"Content-Type"; +static NSString *const kHTTPMethodGET = @"GET"; +static NSString *const kHTTPMethodPOST = @"POST"; +static NSString *const kUnknownSessionID = @"Unknown SID"; +static NSString *const kPageIDKey = @"X-Goog-PageId"; + +@implementation WCChannelRequest { + id _support; + __weak id _delegate; + BOOL _decodeInitialResponse; + BOOL _chunkDecoded; + + // True if request "clean up" has already happened and no more callebacks should be handled. + BOOL _cleanedUp; + + id _readyStateTimer; + int _chunkStart; + WCChannelRequestType _type; + NSURLComponents *_baseURLComponent; +} + +- (instancetype)initWithSessionID:(NSString *)sessionID + requestID:(NSString *)requestID + support:(id)support + delegate:(id)delegate + retryID:(int)retryID { + self = [super init]; + if (self) { + _sessionID = sessionID; + _requestID = requestID; + _retryID = retryID; + _support = support; + _delegate = delegate; + _timeout = kChannelRequestDefaultTimeout; + _responseData = [[NSMutableData alloc] init]; + _lastStatusCode = -1; + } + return self; +} + +- (instancetype)initWithSessionID:(NSString *)sessionID + requestID:(NSString *)requestID + support:(id)support + delegate:(id)delegate { + return [self initWithSessionID:sessionID + requestID:requestID + support:support + delegate:delegate + retryID:kDefaultRetryID]; +} + +- (void)sendPOST:(NSURLComponents *)URLComponents + withData:(NSString *)data + chunkDecoded:(BOOL)chunkDecoded { + _type = WCChannelRequestTypeSendRequest; + _baseURLComponent = URLComponents; + _POSTData = [data dataUsingEncoding:NSUTF8StringEncoding]; + _chunkDecoded = chunkDecoded; + _POST = YES; + [self send]; +} + +- (void)sendPOST:(NSURLComponents *)URLComponents + withPostData:(NSData *)postData + chunkDecoded:(BOOL)chunkDecoded { + _type = WCChannelRequestTypeSendRequest; + _baseURLComponent = URLComponents; + _POSTData = postData; + _chunkDecoded = chunkDecoded; + _POST = YES; + [self send]; +} + +- (void)sendGET:(NSURLComponents *)URLComponents chunkDecoded:(BOOL)chunkDecoded { + _type = WCChannelRequestTypeSendRequest; + _baseURLComponent = URLComponents; + _chunkDecoded = chunkDecoded; + _POST = NO; + [self send]; +} + +- (void)closeWithURLComponents:(NSURLComponents *)URLComponents { + _type = WCChannelRequestTypeCloseRequest; + _baseURLComponent = URLComponents; + _POST = NO; + + _request = [_support HTTPRequest:self]; + [_request sendGET:_baseURLComponent.URL withHeaders:nil timeout:_timeout]; + _requestStartTime = [NSDate now]; + [self startReadyStateTimer]; +} + +- (void)cancel { + _cancelled = YES; + [self cleanup]; +} + +- (BOOL)isLastErrorFatal { + return _lastError == WCChannelRequestErrorUnknownSessionId || + (_lastError == WCChannelRequestErrorStatus && _lastStatusCode > 0); +} + +- (NSString *)formatErrorToString:(WCChannelRequestError)error { + NSString *result = nil; + switch (error) { + case WCChannelRequestErrorStatus: + result = @"WCChannelRequestError Status"; + break; + case WCChannelRequestErrorNoData: + result = @"WCChannelRequestError NoData"; + break; + case WCChannelRequestErrorTimeout: + result = @"WCChannelRequestError Timeout"; + break; + case WCChannelRequestErrorUnknownSessionId: + result = @"WCChannelRequestErrorUnknown SessionId"; + break; + case WCChannelRequestErrorBadData: + result = @"WCChannelRequestError BadData"; + break; + case WCChannelRequestErrorHandlerException: + result = @"WCChannelRequestError HandlerException"; + break; + default: + result = @"WCChannelRequestError BrowserOffline"; + } + return result; +} + +#pragma mark - WCRequestStateChangedHandler + +- (void)stateChangedForRequest:(id)request responseData:(NSData *)data { + if (_cleanedUp) { + // Stop handling new state changes when request has been cleaned up. + return; + } + + if (![request isEqual:_request]) { + [_support.logger logWarning:[NSString stringWithFormat:@"Called back with an unexpected http request%@, %@", request, _request]]; + return; + } + [_responseData appendData:data]; + [self handleReadyStateChange]; +} + +#pragma mark - Private + +- (void)send { + _requestStartTime = [NSDate date]; + [self startReadyStateTimer]; + + NSURLQueryItem *queryItem = + [NSURLQueryItem queryItemWithName:kQueryItemNameT value:[NSString stringWithFormat:@"%d", _retryID]]; + NSMutableArray *queryItems = [_baseURLComponent.queryItems mutableCopy]; + [queryItems addObject:queryItem]; + [_baseURLComponent setQueryItems:queryItems]; + _chunkStart = 0; + + _request = [_support HTTPRequest:self]; + + NSMutableDictionary *headers = [NSMutableDictionary dictionary]; + if (_extraHeaders != nil) { + [headers addEntriesFromDictionary:_extraHeaders]; + } + if (_POST) { + if (_isBinaryMessage) { + [headers setObject:kHeaderBinaryContentTypeValue forKey:kHeaderContentTypeKey]; + } else { + [headers setObject:kHeaderContentTypeValue forKey:kHeaderContentTypeKey]; + } + [_request sendPOST:_baseURLComponent.URL withData:_POSTData withHeaders:headers timeout:_timeout]; + } else { + [_request sendGET:_baseURLComponent.URL withHeaders:headers timeout:_timeout]; + } + [_support notifyServerReachabilityEvent:WCServerReachabilityRequestMade]; + [_support.logger logHTTPRequest:_POST ? kHTTPMethodPOST : kHTTPMethodGET + URL:_baseURLComponent.URL + ID:_requestID + attempt:_retryID + postData:_POSTData.description]; +} + +- (void)cleanup { + [self cancelReadyStateTimer]; + if (_request) { + [_request abort]; + _request = nil; + + // Setting `_request.requestReadyStateChangeHandler` => `nil` will cause the retain count of + // this request object to go to 0 and cause it to be immediately dealloc'ed, causing crashes. + // Instead, a "clean up" bit is used to indicate that no further request callbacks will be + // handled. + // + // This request object will dealloc'ed when dealloc of the underlying `_request` happens (there + // won't be a retain cycle since `request` is set to `nil` above). + _cleanedUp = YES; + } +} + +- (void)cancelReadyStateTimer { + [_support clearTimeout:_readyStateTimer]; + _readyStateTimer = nil; +} + +- (void)startReadyStateTimer { + __weak __typeof__(self) weakSelf = self; + _readyStateTimer = [_support setTimeout:_timeout + block:^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf) { + [strongSelf handleTimeout]; + } + }]; +} + +- (void)handleTimeout { + if (_successful) { + [_support.logger logError:@"Received readyStateTimer timeout even though request loaded successfully"]; + } + [_support.logger + logInfo:[NSString stringWithFormat:@"TIMEOUT: %@", _baseURLComponent.URL.absoluteString]]; + + if (_type != WCChannelRequestTypeCloseRequest) { + [_support notifyServerReachabilityEvent:WCServerReachabilityFailed]; + [_support notifyStatEvent:WCRequestStatRequestTimeout]; + } + + [self cleanup]; + _lastError = WCChannelRequestErrorTimeout; + [self dispatchFailure]; +} + +- (void)dispatchFailure { + if (_cancelled) { + return; + } + [_delegate handleCompleteRequest:self]; +} + +- (void)handleReadyStateChange { + WCRequestReadyState readyState = _request.requestReadyState; + WCRequestErrorCode errorCode = _request.requestErrorCode; + int statusCode = _request.status; + if (readyState < WCRequestReadyStateInteractive || + (readyState == WCRequestReadyStateInteractive && _responseData.length == 0)) { + return; + } + + [self notifyServerReachabilityOnReadyState:readyState errorCode:errorCode statusCode:statusCode]; + + [self cancelReadyStateTimer]; + + _lastStatusCode = statusCode; + if (_responseData.length == 0) { + [_support.logger logDebug:[NSString stringWithFormat:@"No response text for uri %@ status %d.", + _baseURLComponent.string, statusCode]]; + } + _successful = statusCode == kStatusCodeOK; + + [_support.logger logHTTPChannelResponseMetaData:_POST ? kHTTPMethodPOST : kHTTPMethodGET + URL:_baseURLComponent.URL + ID:_requestID + attempt:_retryID + state:readyState + statusCode:statusCode]; + + NSString *responseText = [[NSString alloc] initWithData:_responseData + encoding:NSUTF8StringEncoding]; + if (!_successful) { + [self handleReadyStateChangedFailureStatusCode:statusCode responseText:responseText]; + return; + } + + if ([self shouldCheckInitialResponse] && ![self checkInitialResponse]) { + return; + } + + if (_chunkDecoded) { + [self decodeNextChunks:responseText state:readyState]; + } else { + [_support.logger logHTTPChannelResponseText:_responseData.description ID:_requestID desc:nil]; + [self didReceiveRequestData:responseText]; + } + + if (readyState == WCRequestReadyStateComplete) { + [self cleanup]; + } + + if (!_successful) { + return; + } + + if (!_cancelled) { + [self handleReadyStateChangeNotCancelled:readyState]; + } +} + +- (void)handleReadyStateChangeNotCancelled:(WCRequestReadyState)readyState { + if (readyState == WCRequestReadyStateComplete) { + [_delegate handleCompleteRequest:self]; + } else { + _successful = NO; + [self startReadyStateTimer]; + } +} + +- (void)handleReadyStateChangedFailureStatusCode:(int)statusCode + responseText:(NSString *)responseText { + if (statusCode == kStatusCodeBadRequest && + [responseText rangeOfString:kUnknownSessionID].location != NSNotFound) { + _lastError = WCChannelRequestErrorUnknownSessionId; + [_support notifyStatEvent:WCRequestStatRequestUnknownSessionId]; + } else { + _lastError = WCChannelRequestErrorStatus; + [_support notifyStatEvent:WCRequestStatRequestBadStatus]; + } + [self cleanup]; + [self dispatchFailure]; +} + +- (void)notifyServerReachabilityOnReadyState:(WCRequestReadyState)readyState + errorCode:(WCRequestErrorCode)errorCode + statusCode:(int)statusCode { + if (!_cancelled && readyState == WCRequestReadyStateComplete && + errorCode != WCRequestErrorCodeAbort) { + if (errorCode == WCRequestErrorCodeTimeout || statusCode <= 0) { + [_support notifyServerReachabilityEvent:WCServerReachabilityFailed]; + } else { + [_support notifyServerReachabilityEvent:WCServerReachabilitySucceed]; + } + } +} + +- (BOOL)shouldCheckInitialResponse { + return _decodeInitialResponse && !_initialResponseDecoded; +} + +- (BOOL)checkInitialResponse { + NSString *initialResponse = [self getInitialResponse]; + if (initialResponse == nil) { + _successful = NO; + _lastError = WCChannelRequestErrorUnknownSessionId; + [_support notifyStatEvent:WCRequestStatRequestUnknownSessionId]; + [self cleanup]; + [self dispatchFailure]; + return NO; + } + [_support.logger + logHTTPChannelResponseText:initialResponse + ID:_requestID + desc:[NSString stringWithFormat:@"Initial handshake response via %@.", + kWCXHTTPInitialResponse]]; + + _initialResponseDecoded = YES; + [self didReceiveRequestData:initialResponse]; + return YES; +} + +- (void)didReceiveRequestData:(NSString *)data { + [_delegate didReceiveInput:data withRequest:self]; + + [_support notifyServerReachabilityEvent:WCServerReachabilityBackChannelActivity]; +} + +- (NSString *)getInitialResponse { + return [_request responseHeaderForName:kWCXHTTPInitialResponse]; +} + +- (void)decodeNextChunks:(NSString *)responseText state:(WCRequestReadyState)readyState { + BOOL decodeNextChunksSuccessful = YES; + while (!_cancelled && _chunkStart < responseText.length) { + WCChannelRequestDecodeResult result; + NSString *chunkText = [self nextChunkFromResponseText:responseText result:&result]; + if (result == WCChannelRequestDecodeResultIncomplete) { + if (readyState == WCRequestReadyStateComplete) { + _lastError = WCChannelRequestErrorBadData; + [_support notifyStatEvent:WCRequestStatRequestIncompleteData]; + decodeNextChunksSuccessful = NO; + } + [_support.logger logHTTPChannelResponseText:responseText + ID:_requestID + desc:@"[Incomplete Response]"]; + break; + } else if (result == WCChannelRequestDecodeResultInvalid) { + _lastError = WCChannelRequestErrorBadData; + [_support notifyStatEvent:WCRequestStatRequestBadData]; + [_support.logger logHTTPChannelResponseText:responseText + ID:_requestID + desc:@"[Invalid Chunk]"]; + decodeNextChunksSuccessful = NO; + break; + } else { + NSString *chunkTextBuffer = [chunkText mutableCopy]; + [_support.logger logHTTPChannelResponseText:chunkTextBuffer ID:_requestID desc:nil]; + [self didReceiveRequestData:chunkTextBuffer]; + } + } + if (readyState == WCRequestReadyStateComplete && responseText.length == 0) { + _lastError = WCChannelRequestErrorNoData; + [_support notifyStatEvent:WCRequestStatRequestNoData]; + decodeNextChunksSuccessful = NO; + } + _successful = _successful && decodeNextChunksSuccessful; + if (!decodeNextChunksSuccessful) { + [_support.logger logHTTPChannelResponseText:responseText ID:_requestID desc:@"[Invalid Chunk]"]; + [self cleanup]; + [self dispatchFailure]; + } else { + if (responseText.length > 0 && !_firstByteReceived) { + _firstByteReceived = YES; + [_delegate didReceivedFirstByteOfRequest:self responseText:responseText]; + } + } +} + +- (NSString *)nextChunkFromResponseText:(NSString *)responseText + result:(WCChannelRequestDecodeResult *)result { + int sizeStartIndex = _chunkStart; + int sizeEndIndex = + [responseText rangeOfString:@"\n" + options:0 + range:NSMakeRange(sizeStartIndex, responseText.length - sizeStartIndex)] + .location; + if (sizeEndIndex == NSNotFound) { + if (result) { + *result = WCChannelRequestDecodeResultIncomplete; + } + return nil; + } + + NSString *sizeAsString = + [responseText substringWithRange:NSMakeRange(sizeStartIndex, sizeEndIndex - sizeStartIndex)]; + NSScanner *scanner = [NSScanner scannerWithString:sizeAsString]; + int size = 0; + if (![scanner scanInt:&size] || ![scanner isAtEnd]) { + if (result) { + *result = WCChannelRequestDecodeResultInvalid; + } + return nil; + } + + if (size < 0) { + if (result) { + *result = WCChannelRequestDecodeResultInvalid; + } + return nil; + } + + int chunkStartIndex = sizeEndIndex + 1; + if (chunkStartIndex + size > responseText.length) { + if (result) { + *result = WCChannelRequestDecodeResultIncomplete; + } + return nil; + } + + NSString *chunkText = [responseText substringWithRange:NSMakeRange(chunkStartIndex, size)]; + _chunkStart = chunkStartIndex + size; + if (result) { + *result = WCChannelRequestDecodeResultSuccess; + } + return chunkText; +} + +@end diff --git a/webchannel/objc/imported_src/WCConnectionState.h b/webchannel/objc/imported_src/WCConnectionState.h new file mode 100644 index 0000000..051c378 --- /dev/null +++ b/webchannel/objc/imported_src/WCConnectionState.h @@ -0,0 +1,5 @@ +#import + +/** A object that holds the state of handshake's and buffer proxy's result. */ +@interface WCConnectionState : NSObject +@end diff --git a/webchannel/objc/imported_src/WCConnectionState.m b/webchannel/objc/imported_src/WCConnectionState.m new file mode 100644 index 0000000..d0c2470 --- /dev/null +++ b/webchannel/objc/imported_src/WCConnectionState.m @@ -0,0 +1,5 @@ +#import "WCConnectionState.h" + +@implementation WCConnectionState + +@end diff --git a/webchannel/objc/imported_src/WCDefaultSupport.h b/webchannel/objc/imported_src/WCDefaultSupport.h new file mode 100644 index 0000000..c904931 --- /dev/null +++ b/webchannel/objc/imported_src/WCDefaultSupport.h @@ -0,0 +1,24 @@ +#import + +#import "WCSupport.h" + +@class GTMSessionFetcherService; + +@interface WCDefaultSupport : NSObject + +- (instancetype)init; + +/** + * @param fetcherService The fetcher service to use for network requests. + */ +- (instancetype)initWithFetcherService:(nonnull GTMSessionFetcherService *)fetcherService; + +/** + * @param dispatchQueue A dedicated dispatch queue where webchannel logic is run on. + * @param fetcherService The fetcher service to use for network requests. + */ +- (instancetype)initWithDispatchQueue:(dispatch_queue_t)dispatchQueue + fetcherService:(nonnull GTMSessionFetcherService *)fetcherService + NS_DESIGNATED_INITIALIZER; + +@end diff --git a/webchannel/objc/imported_src/WCDefaultSupport.m b/webchannel/objc/imported_src/WCDefaultSupport.m new file mode 100644 index 0000000..5debb1c --- /dev/null +++ b/webchannel/objc/imported_src/WCDefaultSupport.m @@ -0,0 +1,144 @@ +#import "WCDefaultSupport.h" + +#import "WCDefaultHTTPRequest.h" +#import "WCDefaultJSONDecoder.h" +#import "WCDefaultLogger.h" +#import "WCDefaultURLEncoder.h" +#import "WCHTTPRequest.h" +#import "WCEventNotification.h" +#import "WCFailureRecoveryContext.h" +#import "WCSupport.h" +#import "WCTimer.h" +#import + +/** Internal timer token implementation. */ +@interface WCDefaultTimer : NSObject { + @public + dispatch_source_t _timerSource; +} + +/** The failure recovery context, if any. */ +@property(nonatomic, strong, nullable) WCFailureRecoveryContext *context; + +- (void)startWithQueue:(dispatch_queue_t)queue + timeout:(NSTimeInterval)timeout + block:(void (^)(void))block; +@end + +@implementation WCDefaultTimer +- (void)startWithQueue:(dispatch_queue_t)queue + timeout:(NSTimeInterval)timeout + block:(void (^)(void))block { + _timerSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); + if (_timerSource) { + dispatch_source_set_timer( + _timerSource, + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC)), + DISPATCH_TIME_FOREVER, + 0); + __weak typeof(self) weakSelf = self; + dispatch_source_set_event_handler(_timerSource, ^{ + block(); + __strong typeof(self) strongSelf = weakSelf; + if (strongSelf) { + [strongSelf cancel]; + } + }); + dispatch_resume(_timerSource); + } +} + +- (void)cancel { + if (_timerSource) { + dispatch_source_cancel(_timerSource); + _timerSource = nil; + } +} + +- (void)dealloc { + [self cancel]; +} +@end + +@implementation WCDefaultSupport { + dispatch_queue_t _dispatchQueue; + GTMSessionFetcherService *_fetcherService; +} + +@synthesize URLEncoder = _URLEncoder; +@synthesize JSONDecoder = _JSONDecoder; +@synthesize logger = _logger; +@synthesize dispatchQueue = _dispatchQueue; + +static dispatch_queue_t CreateDefaultQueue() { + return dispatch_queue_create("WebChannelClient", DISPATCH_QUEUE_SERIAL); +} + +- (instancetype)init { + GTMSessionFetcherService *fetcherService = [[GTMSessionFetcherService alloc] init]; + fetcherService.cookieStorage = [[GTMSessionCookieStorage alloc] init]; + return [self initWithDispatchQueue:CreateDefaultQueue() fetcherService:fetcherService]; +} + +- (instancetype)initWithFetcherService:(nonnull GTMSessionFetcherService *)fetcherService { + return [self initWithDispatchQueue:CreateDefaultQueue() fetcherService:fetcherService]; +} + +- (instancetype)initWithDispatchQueue:(dispatch_queue_t)dispatchQueue + fetcherService:(nonnull GTMSessionFetcherService *)fetcherService { + self = [super init]; + if (self) { + _URLEncoder = [[WCDefaultURLEncoder alloc] init]; + _JSONDecoder = [[WCDefaultJSONDecoder alloc] init]; + _logger = [[WCDefaultLogger alloc] init]; + _dispatchQueue = dispatchQueue; + _fetcherService = fetcherService; + } + return self; +} + +- (id)HTTPRequest:(id)handler { + return [[WCDefaultHTTPRequest alloc] initWithStateChangeHandler:handler + dispatchQueue:_dispatchQueue + fetcherService:_fetcherService]; +} + +- (void)notifyStatEvent:(WCRequestStat)event { + // optional +} + +- (void)notifyServerReachabilityEvent:(WCServerReachability)event { + // optional +} + +- (void)notifyTimingEventWithSize:(int)size withRTT:(NSTimeInterval)RTT withRetries:(int)retries { + // optional +} + +- (void)notifyHandshakeTimingEventWithRtt:(NSTimeInterval)rtt { + [[NSNotificationCenter defaultCenter] + postNotificationName:kWCEventNotificationName + object:self + userInfo:@{kWCEventNotificationHandshakeRttKey : @(rtt)}]; +} + +- (id)setTimeout:(NSTimeInterval)timeout block:(void (^)(void))block { + WCDefaultTimer *timerToken = [[WCDefaultTimer alloc] init]; + [timerToken startWithQueue:_dispatchQueue timeout:timeout block:block]; + return timerToken; +} + +- (id)setTimeout:(NSTimeInterval)timeout + block:(void (^)(void))block + context:(nonnull WCFailureRecoveryContext *)context { + WCDefaultTimer *timerToken = [[WCDefaultTimer alloc] init]; + [timerToken startWithQueue:_dispatchQueue timeout:timeout block:block]; + timerToken.context = context; + return timerToken; +} + +- (void)clearTimeout:(id)timer { + [timer cancel]; +} + +@end diff --git a/webchannel/objc/imported_src/WCDispatcher.h b/webchannel/objc/imported_src/WCDispatcher.h new file mode 100644 index 0000000..d8121df --- /dev/null +++ b/webchannel/objc/imported_src/WCDispatcher.h @@ -0,0 +1,44 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * WCDispatcher is an object wrapper forwarding all method invocations to a target object on the + * specified queue via dispatch_async. This has two benefits: + * 1. Creates a layer of abstraction for hiding the underlying async dispatch details and + * object's interface identity. + * 2. Ensures all method calls to an object occurs on a specific queue. + */ +@interface WCDispatcher : NSProxy + +/** + * Convenient function to create an instance of WCDispatcher that strongly retain the given + * target. + * + * @param target Target object that the dispatcher will forward method invocation to. Strongly + * held by the dispatcher. + * @param protocol Protocol to which the target object conforms. + * @param dispatchQueue Dispatch queue where all method invocations will be dispatched onto. + * @return An instance of WCDispatcher conforming to the specified protocol. + */ ++ (id)strongDispatcherWithTarget:(id)target + protocol:(Protocol *)protocol + dispatchQueue:(dispatch_queue_t)dispatchQueue; + +- (instancetype)init NS_UNAVAILABLE; + +/** + * Creates and returns an instance of WCDispatcher. + * + * @param target Target object that the dispatcher will forward method invocation to. + * @param protocol Protocol to which the target object conforms. + * @param dispatchQueue Dispatch queue where all method invocations will be dispatched onto. + * @return An instance of WCDispatcher conforming to the specified protocol. + */ +- (instancetype)initWithTarget:(id)target + protocol:(Protocol *)protocol + dispatchQueue:(dispatch_queue_t)dispatchQueue; + +@end + +NS_ASSUME_NONNULL_END diff --git a/webchannel/objc/imported_src/WCDispatcher.m b/webchannel/objc/imported_src/WCDispatcher.m new file mode 100644 index 0000000..97f456b --- /dev/null +++ b/webchannel/objc/imported_src/WCDispatcher.m @@ -0,0 +1,63 @@ +#import "WCDispatcher.h" + +#import "WCLogger.h" + +#include + +@implementation WCDispatcher { + id _target; + Protocol *_protocol; + dispatch_queue_t _dispatchQueue; +} + ++ (id)strongDispatcherWithTarget:(id)target + protocol:(Protocol *)protocol + dispatchQueue:(dispatch_queue_t)dispatchQueue { + return [[WCDispatcher alloc] initWithTarget:target protocol:protocol dispatchQueue:dispatchQueue]; +} + +- (instancetype)initWithTarget:(id)target + protocol:(Protocol *)protocol + dispatchQueue:(dispatch_queue_t)dispatchQueue { + if (![target conformsToProtocol:protocol]) { + WCDevAssert(NO, @"target object must conforms to protocol."); + return nil; + } + _protocol = protocol; + _dispatchQueue = dispatchQueue; + _target = target; + return self; +} + +#pragma mark - NSProxy + +- (void)forwardInvocation:(NSInvocation *)invocation { + [invocation retainArguments]; + dispatch_async(_dispatchQueue, ^{ + [invocation invokeWithTarget:_target]; + }); +} + +- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector { + struct objc_method_description methodDescription = + protocol_getMethodDescription(_protocol, selector, YES, YES); + if (methodDescription.name == NULL) { + methodDescription = protocol_getMethodDescription(_protocol, selector, NO, YES); + if (methodDescription.name == NULL) { + return nil; + } + } + return [NSMethodSignature signatureWithObjCTypes:methodDescription.types]; +} + +#pragma mark - NSObject + +- (BOOL)conformsToProtocol:(Protocol *)protocol { + return protocol_conformsToProtocol(_protocol, protocol); +} + +- (BOOL)respondsToSelector:(SEL)selector { + return [self methodSignatureForSelector:selector] != nil; +} + +@end diff --git a/webchannel/objc/imported_src/WCEventNotification.h b/webchannel/objc/imported_src/WCEventNotification.h new file mode 100644 index 0000000..37ee330 --- /dev/null +++ b/webchannel/objc/imported_src/WCEventNotification.h @@ -0,0 +1,11 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/** Name of the NSNotification posted when WebChannel lifecycle events occur. */ +FOUNDATION_EXTERN NSNotificationName const kWCEventNotificationName; + +/** Key in userInfo dictionary containing the handshake RTT as an NSNumber (NSTimeInterval). */ +FOUNDATION_EXTERN NSString *const kWCEventNotificationHandshakeRttKey; + +NS_ASSUME_NONNULL_END diff --git a/webchannel/objc/imported_src/WCEventNotification.m b/webchannel/objc/imported_src/WCEventNotification.m new file mode 100644 index 0000000..db8ad40 --- /dev/null +++ b/webchannel/objc/imported_src/WCEventNotification.m @@ -0,0 +1,4 @@ +#import "WCEventNotification.h" + +NSNotificationName const kWCEventNotificationName = @"com.google.webchannel.EventNotification"; +NSString *const kWCEventNotificationHandshakeRttKey = @"WCEventNotificationHandshakeRttKey"; diff --git a/webchannel/objc/imported_src/WCFailureRecoveryContext.h b/webchannel/objc/imported_src/WCFailureRecoveryContext.h new file mode 100644 index 0000000..d6cab7b --- /dev/null +++ b/webchannel/objc/imported_src/WCFailureRecoveryContext.h @@ -0,0 +1,12 @@ +#import + +#import "WCChannelRequest.h" +#import "WCSupport.h" + +@interface WCFailureRecoveryContext : NSObject + +@property(nonatomic) WCChannelRequestError error; +@property(nonatomic) int64_t attempt; +@property(nonatomic) WCChannelType channelType; + +@end diff --git a/webchannel/objc/imported_src/WCFailureRecoveryContext.m b/webchannel/objc/imported_src/WCFailureRecoveryContext.m new file mode 100644 index 0000000..5bdd668 --- /dev/null +++ b/webchannel/objc/imported_src/WCFailureRecoveryContext.m @@ -0,0 +1,4 @@ +#import "WCFailureRecoveryContext.h" + +@implementation WCFailureRecoveryContext +@end diff --git a/webchannel/objc/imported_src/WCForwardChannelRequestPool.h b/webchannel/objc/imported_src/WCForwardChannelRequestPool.h new file mode 100644 index 0000000..89b3529 --- /dev/null +++ b/webchannel/objc/imported_src/WCForwardChannelRequestPool.h @@ -0,0 +1,63 @@ +#import + +@class WCChannelRequest; +@class WCQueuedMap; + +/** A pool streaming all pending forward channel requests. */ +@interface WCForwardChannelRequestPool : NSObject + +@property(nonatomic, readonly) int maxSize; +@property(nonatomic, readonly, getter=isFull) BOOL full; +@property(nonatomic, readonly) int requestCount; +@property(nonatomic, readonly) NSMutableArray *pendingMessages; +@property(nonatomic, readonly) NSMutableSet *requestPool; + +/** + * @param size The max number of requests the pool can have. + */ +- (instancetype)initWithMaxPoolSize:(int)size; + +/* + * Add a request to the pool. + * + * @param request The request to be added. + */ +- (void)addRequest:(WCChannelRequest *)request; + +/* + * Check if a request exist in the pool. + * + * @param request The request to be checked. + */ +- (BOOL)hasRequest:(WCChannelRequest *)request; + +/** + * Remove a request from the pool. + * + * @param request The request to be removed. + */ +- (BOOL)removeRequest:(WCChannelRequest *)request; + +/** + * Add more pending messages to the pool's pending messages. + * + * @param pendingMessages The messages to be added. + */ +- (void)addPendingMessages:(NSMutableArray *)pendingMessages; + +/** Clear all the pending messages in the pool. */ +- (void)clearPendingMessages; + +/** + * Once we know the client protocol (from the handshake), check if we need + * enable the request pool accordingly. This is more robust than using + * browser-internal APIs (specific to Chrome). + * + * @param clientProtocol The client protocol. + */ +- (void)applyClientProtocol:(NSString *)clientProtocol; + +/** Clears the pool and cancel all the pending requests. */ +- (void)cancel; + +@end diff --git a/webchannel/objc/imported_src/WCForwardChannelRequestPool.m b/webchannel/objc/imported_src/WCForwardChannelRequestPool.m new file mode 100644 index 0000000..e04f384 --- /dev/null +++ b/webchannel/objc/imported_src/WCForwardChannelRequestPool.m @@ -0,0 +1,84 @@ +#import "WCForwardChannelRequestPool.h" + +#import "WCChannelRequest.h" +#import "WCQueuedMap.h" + +static const int kWCMaxPoolSize = 10; + +@implementation WCForwardChannelRequestPool + +@synthesize pendingMessages = _pendingMessages; + +- (instancetype)initWithMaxPoolSize:(int)size { + self = [super init]; + if (self) { + _maxSize = (size > 0) ? size : kWCMaxPoolSize; + _requestPool = [NSMutableSet set]; + _pendingMessages = [NSMutableArray array]; + } + return self; +} + +- (BOOL)isFull { + if (_requestPool != nil) { + return _requestPool.count >= _maxSize; + } + return NO; +} + +- (void)addRequest:(WCChannelRequest *)request { + if (_requestPool != nil) { + [_requestPool addObject:request]; + } +} + +- (BOOL)hasRequest:(WCChannelRequest *)request { + if (_requestPool != nil) { + return [_requestPool containsObject:request]; + } + return NO; +} + +- (BOOL)removeRequest:(WCChannelRequest *)request { + if ([_requestPool containsObject:request]) { + [_requestPool removeObject:request]; + return YES; + } + return NO; +} + +- (void)addPendingMessages:(NSMutableArray *)pendingMessages { + [_pendingMessages addObjectsFromArray:pendingMessages]; +} + +- (NSMutableArray *)pendingMessages { + NSMutableArray *result = [NSMutableArray arrayWithArray:_pendingMessages]; + for (WCChannelRequest *request in _requestPool) { + [result addObjectsFromArray:request.pendingMessages]; + } + return result; +} + +- (void)clearPendingMessages { + [_pendingMessages removeAllObjects]; +} + +- (int)requestCount { + if (_requestPool != nil) { + return _requestPool.count; + } + return 0; +} + +- (void)applyClientProtocol:(NSString *)clientProtocol { + // no-op +} + +- (void)cancel { + for (WCChannelRequest *request in _requestPool) { + [request cancel]; + } + [_requestPool removeAllObjects]; +} + +@end diff --git a/webchannel/objc/imported_src/WCInternalChannelParams.h b/webchannel/objc/imported_src/WCInternalChannelParams.h new file mode 100644 index 0000000..ba0c96f --- /dev/null +++ b/webchannel/objc/imported_src/WCInternalChannelParams.h @@ -0,0 +1,12 @@ +#import + +/** For configuring the webchannel parameters. */ +@interface WCInternalChannelParams : NSObject + +@property(nonatomic, getter=shouldFailFast) BOOL failFast; +@property(nonatomic) NSTimeInterval baseRetryDelay; +@property(nonatomic) NSTimeInterval retryDelaySeed; +@property(nonatomic) int forwardChannelMaxRetries; +@property(nonatomic) NSTimeInterval forwardChannelRequestTimeout; + +@end diff --git a/webchannel/objc/imported_src/WCInternalChannelParams.m b/webchannel/objc/imported_src/WCInternalChannelParams.m new file mode 100644 index 0000000..b3a28fb --- /dev/null +++ b/webchannel/objc/imported_src/WCInternalChannelParams.m @@ -0,0 +1,18 @@ +#import "WCInternalChannelParams.h" + +#import "WCWebChannelClientInternal.h" + +@implementation WCInternalChannelParams +- (instancetype)init { + self = [super init]; + if (self) { + _failFast = NO; + _baseRetryDelay = kWCDefaultBaseRetryDelay; + _retryDelaySeed = kWCDefaultRetryDelaySeed; + _forwardChannelMaxRetries = kWCDefaultForwardChannelMaxRetries; + _forwardChannelRequestTimeout = kWCDefaultForwardChannelRequestTimeout; + } + return self; +} + +@end diff --git a/webchannel/objc/imported_src/WCOptions.h b/webchannel/objc/imported_src/WCOptions.h new file mode 100644 index 0000000..24ccd64 --- /dev/null +++ b/webchannel/objc/imported_src/WCOptions.h @@ -0,0 +1,25 @@ +#import + +@class WCInternalChannelParams; + +/** For configuring the webchannel runtime behavior. */ +@interface WCOptions : NSObject + +@property(nonatomic, copy) NSDictionary *messageHeaders; +@property(nonatomic, copy) NSDictionary *initialMessageHeaders; +@property(nonatomic, copy) NSString *messageContentType; +@property(nonatomic, copy) NSDictionary *messageURLParams; +@property(nonatomic) BOOL clientProtocolHeaderRequired; +@property(nonatomic) int concurrentRequestLimit; +@property(nonatomic, getter=isSendingRawJSON) BOOL sendingRawJSON; +@property(nonatomic, copy) NSString *HTTPSessionIDParam; +@property(nonatomic, getter=isLongPollingForced) BOOL longPollingForced; +@property(nonatomic, getter=shouldDetectBufferingProxy) BOOL detectBufferingProxy; +@property(nonatomic, getter=isFastHandshake) BOOL fastHandshake; +@property(nonatomic, getter=isBlockingHandshake) BOOL blockingHandshake; +@property(nonatomic) BOOL enableBinaryEncoding; +@property(nonatomic, readonly, getter=isRedactDisabled) BOOL redactDisabled; +@property(nonatomic, copy) NSString *clientProfile; +@property(nonatomic) WCInternalChannelParams *internalChannelParams; + +@end diff --git a/webchannel/objc/imported_src/WCOptions.m b/webchannel/objc/imported_src/WCOptions.m new file mode 100644 index 0000000..7ecded3 --- /dev/null +++ b/webchannel/objc/imported_src/WCOptions.m @@ -0,0 +1,4 @@ +#import "WCOptions.h" + +@implementation WCOptions +@end diff --git a/webchannel/objc/imported_src/WCQueuedMap.h b/webchannel/objc/imported_src/WCQueuedMap.h new file mode 100644 index 0000000..043d2d6 --- /dev/null +++ b/webchannel/objc/imported_src/WCQueuedMap.h @@ -0,0 +1,17 @@ +#import + +typedef void (^WCQueuedMapBadMessageHandler)(NSDictionary *); + +/** The data structure for holding all out going messages. */ +@interface WCQueuedMap : NSObject + +@property(nonatomic, readonly) int rawDataSize; +@property(nonatomic, readonly) int mapID; +@property(nonatomic, readonly) NSDictionary *map; +@property(nonatomic, readonly) NSObject *context; + +- (instancetype)initWithMapID:(int)ID + map:(NSDictionary *)map + context:(NSObject *)context; + +@end diff --git a/webchannel/objc/imported_src/WCQueuedMap.m b/webchannel/objc/imported_src/WCQueuedMap.m new file mode 100644 index 0000000..d30da42 --- /dev/null +++ b/webchannel/objc/imported_src/WCQueuedMap.m @@ -0,0 +1,25 @@ +#import "WCQueuedMap.h" + +@implementation WCQueuedMap + +- (instancetype)initWithMapID:(int)ID + map:(NSDictionary *)map + context:(NSObject *)context { + self = [super init]; + if (self) { + _mapID = ID; + _map = map; + _context = context; + id data = map[@"__data__"]; + if ([data isKindOfClass:[NSString class]]) { + _rawDataSize = (int)[(NSString *)data lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + } else if ([data isKindOfClass:[NSData class]]) { + _rawDataSize = (int)[(NSData *)data length]; + } else { + _rawDataSize = 0; + } + } + return self; +} + +@end diff --git a/webchannel/objc/imported_src/WCRuntimeProperties+Private.h b/webchannel/objc/imported_src/WCRuntimeProperties+Private.h new file mode 100644 index 0000000..58fdb4b --- /dev/null +++ b/webchannel/objc/imported_src/WCRuntimeProperties+Private.h @@ -0,0 +1,14 @@ +#import + +#import "WCRuntimeProperties.h" + +@class WCWebChannelClientInternal; + +@interface WCRuntimeProperties (Private) + +/** + * @param channel The channel client. + */ +- (instancetype)initWithChannel:(WCWebChannelClientInternal *)channel; + +@end diff --git a/webchannel/objc/imported_src/WCRuntimeProperties.h b/webchannel/objc/imported_src/WCRuntimeProperties.h new file mode 100644 index 0000000..7ae768e --- /dev/null +++ b/webchannel/objc/imported_src/WCRuntimeProperties.h @@ -0,0 +1,28 @@ +#import + + +typedef void (^WCAckCommitCallbackBlock)(void); + +/** The runtime properties of the WebChannel instance. */ +@interface WCRuntimeProperties : NSObject + +/** The last HTTP status code received by the channel. */ +@property(nonatomic, readonly) int lastStatusCode; + +/** + * The list of messages that have not received commit-ack from the server; + * or if no commit has been issued, the list of messages that have not been + * delivered to the server application. + */ +@property(nonatomic, readonly) NSArray *nonAckedMessages; + +/** + * Generates an in-band commit request to the server. + * + * @param ackCommitCallbackBlock The callback will be invoked once an acknowledgement + * has been received for the current commit or any newly issued commit. + * @see http://shortn/_AW5F0PZ789 + */ +- (void)commit:(WCAckCommitCallbackBlock)ackCommitCallbackBlock; + +@end diff --git a/webchannel/objc/imported_src/WCRuntimeProperties.m b/webchannel/objc/imported_src/WCRuntimeProperties.m new file mode 100644 index 0000000..f742dbb --- /dev/null +++ b/webchannel/objc/imported_src/WCRuntimeProperties.m @@ -0,0 +1,39 @@ +#import "WCRuntimeProperties.h" + +#import "WCQueuedMap.h" +#import "WCWebChannelClientInternal.h" + +@interface WCRuntimeProperties () +@end + +@implementation WCRuntimeProperties { + __weak WCWebChannelClientInternal *_channel; +} + +- (instancetype)initWithChannel:(WCWebChannelClientInternal *)channel { + self = [super init]; + if (self) { + _channel = channel; + } + return self; +} + +- (int)lastStatusCode { + return _channel.lastStatusCode; +} + +- (NSArray *)nonAckedMessages { + NSMutableArray *nonAckedMessages = [@[] mutableCopy]; + for (WCQueuedMap *nonAckedMap in _channel.nonAckedMaps) { + NSDictionary *messageMap = nonAckedMap.map; + [nonAckedMessages addObject:messageMap[kRawDataKey]]; + } + return nonAckedMessages; +} + +- (void)commit:(WCAckCommitCallbackBlock)ackCommitCallbackBlock { + _channel.forwardChannelFlushedCallback = ackCommitCallbackBlock; +} + + +@end diff --git a/webchannel/objc/imported_src/WCSupport.h b/webchannel/objc/imported_src/WCSupport.h new file mode 100644 index 0000000..20fbb72 --- /dev/null +++ b/webchannel/objc/imported_src/WCSupport.h @@ -0,0 +1,63 @@ +#import + +#import "WCHTTPRequest.h" +#import "WCTimer.h" + +@protocol WCURLEncoder; +@protocol WCLogger; +@protocol WCHTTPRequest; +@protocol WCJSONDecoder; +@protocol WCRequestStateChangedHandler; +@class GTMSessionFetcherService; +@class WCFailureRecoveryContext; + +typedef NS_ENUM(NSInteger, WCChannelType) { WCChannelTypeForwardChannel, WCChannelTypeBackChannel }; + +typedef NS_ENUM(NSInteger, WCServerReachability) { + WCServerReachabilityRequestMade, + WCServerReachabilitySucceed, + WCServerReachabilityFailed, + WCServerReachabilityBackChannelActivity +}; + +/** The protocol for HTTPRequest, Debugger, Encoder/decoder, etc. */ +@protocol WCSupport + +@property(nonatomic, readonly) id URLEncoder; +@property(nonatomic, readonly) id logger; +@property(nonatomic, readonly) id JSONDecoder; +@property(nonatomic, readonly) dispatch_queue_t dispatchQueue; + +- (id)HTTPRequest:(id)handler; + +- (void)notifyStatEvent:(WCRequestStat)event; + +- (void)notifyServerReachabilityEvent:(WCServerReachability)event; + +- (void)notifyTimingEventWithSize:(int)size withRTT:(NSTimeInterval)RTT withRetries:(int)retries; + +/** Reports the round trip time of the handshake request to establish the channel. */ +- (void)notifyHandshakeTimingEventWithRtt:(NSTimeInterval)rtt; + +- (id)setTimeout:(NSTimeInterval)timeout block:(void (^)())block; + +/** + * Schedules a block to be executed after a specified timeout, passing failure recovery context. + * + * @param timeout The timeout duration in seconds. + * @param block The block to execute when the timer fires. + * @param context Context information for failure recovery (aligned with Java). + * @return An opaque token representing the scheduled timer. + */ +- (id)setTimeout:(NSTimeInterval)timeout + block:(void (^)())block + context:(nonnull WCFailureRecoveryContext *)context; + +/** + * Clears/cancels a scheduled timer. + * + * @param timer The timer token returned by setTimeout:block: to clear. + */ +- (void)clearTimeout:(id)timer; + +@end diff --git a/webchannel/objc/imported_src/WCTimer.h b/webchannel/objc/imported_src/WCTimer.h new file mode 100644 index 0000000..8760afa --- /dev/null +++ b/webchannel/objc/imported_src/WCTimer.h @@ -0,0 +1,21 @@ +#import + +@class WCFailureRecoveryContext; + +NS_ASSUME_NONNULL_BEGIN + +/** + * Protocol representing an opaque token for a scheduled timer. + * Handled entirely by the WCSupport library implementation. + */ +@protocol WCTimer + +/** Cancels the scheduled timer. */ +- (void)cancel; + +/** The failure recovery context if this is a retry/recovery timer. */ +@property(nonatomic, readonly, nullable) WCFailureRecoveryContext *context; + +@end + +NS_ASSUME_NONNULL_END diff --git a/webchannel/objc/imported_src/WCWebChannelClient.h b/webchannel/objc/imported_src/WCWebChannelClient.h new file mode 100644 index 0000000..58db0b0 --- /dev/null +++ b/webchannel/objc/imported_src/WCWebChannelClient.h @@ -0,0 +1,120 @@ +#import + +#import "WCWebChannelClientProtocol.h" +#import "WCWebChannelClientReadWrite.h" + +@class WCOptions; +@protocol WCWebChannelClientHandlerDelegate; +@protocol WCSupport; + +/** Types of WebChannel client state enum */ +typedef NS_ENUM(NSInteger, WCWebChannelClientState) { + WCWebChannelClientStateClosed, + WCWebChannelClientStateInit, + WCWebChannelClientStateOpening, + WCWebChannelClientStateOpened +}; + +/** Types of error enum */ +typedef NS_ENUM(NSInteger, WCWebChannelClientError) { + WCWebChannelClientErrorNone, + WCWebChannelClientErrorRequestFailed, + WCWebChannelClientErrorLoggedOut, + WCWebChannelClientErrorNoData, + WCWebChannelClientErrorUnknownSessionID, + WCWebChannelClientErrorStop, + WCWebChannelClientErrorNetwork, + WCWebChannelClientErrorBadData, + WCWebChannelClientErrorBadResponse, +}; + +@interface WCWebChannelClient : NSObject + +/** The delegate for handling actions on the WebChannel. */ +@property(nonatomic, weak, nullable) id delegate; + +/** + * @param baseURL The base URL of the channel. + * @param options Configuration input of WebChannel runtime options. + * @param delegate The implementation of protocol for processing messages the + * WebChannel client receives from the server. + */ +- (instancetype)initWithURL:(NSURL *)baseURL + options:(WCOptions *)options + delegate:(id)delegate; + +/** + * @param baseURL The base URL of the channel. + * @param options Configuration input of WebChannel runtime options. + * @param delegate The implementation of protocol for processing messages the + * WebChannel client receives from the server. + * @param support The support object that encapsulates platform-specific utilities and networking. + */ +- (instancetype)initWithURL:(NSURL *)baseURL + options:(WCOptions *)options + delegate:(id)delegate + support:(id)support NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +/** + * Protocol for processing messages the WebChannelClient receives from the server on the back + * channel. These delegate methods mirror Java API's AsyncWebChannel.EventHandler. + */ +@protocol WCWebChannelClientHandlerDelegate + +/** + * Invoke when @c WCWebChannelClient is opened. + * + * @param client The opened WebChannelClient. + */ +- (void)webChannelOpened:(id)client; + +/** + * Invoke when @C WCWebChannelClient is closed, and send undeliverred data to server if any. + * + * @param client The closed WebChanelClient. + */ +- (void)webChannelClosed:(id)client; + +/** + * Invoke when @c WCWebChannelClient receives messages from back channel. + * + * @param client The WebChanelClient that receiving messages. + * @param message The JSON message received. + */ +- (void)webChannel:(id)client didReceiveMessage:(id)message; + +/** + * Invoke when error occurs on @c WCWebChannelClient. + * + * @param client The failed WebChanelClient. + * @param error The error occurs. + */ +- (void)webChannel:(id)client + encounteredError:(WCWebChannelClientError)error; + +/** + * Invoke when @c WCWebChannelClient receives metadata encoded as HTTP status code and headers. + * + * @param client The @c WCWebChannelClient with the metadata. + * @param statusCode Metadata as HTTP status code. + * @param headers Metadata as HTTP headers. + */ +- (void)webChannel:(id)client + didReceiveHeaders:(NSDictionary *)headers + statusCode:(int)statusCode; +/** + * Invoke when @c WCWebChannelClient receives metadata. + * + * @param metadata Metadata value, as an object parsed by NSJSONSerialization (NSArray or + * NSDictionary). Typically, ESF server would return metadata as NSArray, as per + * go/webchannel-oneplatform#message-formats. + * @param key Metadata key. + */ +- (void)webChannel:(id)client + didReceiveMetadata:(id)metadata + key:(NSString *)key; +@end diff --git a/webchannel/objc/imported_src/WCWebChannelClient.m b/webchannel/objc/imported_src/WCWebChannelClient.m new file mode 100644 index 0000000..0e4b75e --- /dev/null +++ b/webchannel/objc/imported_src/WCWebChannelClient.m @@ -0,0 +1,104 @@ +#import "WCWebChannelClient.h" + +#import "WCConnectionState.h" +#import "WCDefaultSupport.h" +#import "WCDispatcher.h" +#import "WCWebChannelClientInternal.h" +#import "WCWebChannelClientProtocol.h" + +@interface WCWebChannelClient () +@end + +@implementation WCWebChannelClient { + WCWebChannelClientInternal *_client; + id _dispatchableClient; +} + +- (instancetype)initWithURL:(NSURL *)baseURL + options:(WCOptions *)options + delegate:(id)delegate { + id support = [[WCDefaultSupport alloc] init]; + return [self initWithURL:baseURL options:options delegate:delegate support:support]; +} + +- (instancetype)initWithURL:(NSURL *)baseURL + options:(WCOptions *)options + delegate:(id)delegate + support:(id)support { + self = [super init]; + if (self) { + _delegate = delegate; + _client = [[WCWebChannelClientInternal alloc] initWithURL:baseURL.absoluteString + options:options + connectionState:[[WCConnectionState alloc] init] + clientVersion:0 + delegate:self + support:support]; + _dispatchableClient = + [WCDispatcher strongDispatcherWithTarget:_client + protocol:@protocol(WCWebChannelClientProtocol) + dispatchQueue:support.dispatchQueue]; + } + return self; +} + +- (void)open { + [_dispatchableClient open]; +} + +- (void)close { + [_dispatchableClient close]; +} + +- (void)send:(NSString *)message { + [_dispatchableClient send:message]; +} + +- (void)sendData:(NSData *)data { + [_dispatchableClient sendData:data]; +} + +- (NSDictionary *)messageUrlParams { + return _client.extraParams; +} + +- (void)setMessageUrlParams:(NSMutableDictionary *)messageUrlParams { + _client.extraParams = messageUrlParams; +} + +- (WCRuntimeProperties *)runtimeProperties { + return _client.runtimeProperties; +} + +#pragma WCWebChannelClientHandlerDelegate + +- (void)webChannelOpened:(__unused id)client { + [self.delegate webChannelOpened:self]; +} + +- (void)webChannelClosed:(__unused id)client { + [self.delegate webChannelClosed:self]; +} + +- (void)webChannel:(__unused id)client didReceiveMessage:(id)message { + [self.delegate webChannel:self didReceiveMessage:message]; +} + +- (void)webChannel:(__unused id)client + encounteredError:(WCWebChannelClientError)error { + [self.delegate webChannel:self encounteredError:error]; +} + +- (void)webChannel:(__unused id)client + didReceiveHeaders:(NSDictionary *)headers + statusCode:(int)statusCode { + [self.delegate webChannel:self didReceiveHeaders:headers statusCode:statusCode]; +} + +- (void)webChannel:(__unused id)client + didReceiveMetadata:(id)metadata + key:(NSString *)key { + [self.delegate webChannel:self didReceiveMetadata:metadata key:key]; +} + +@end diff --git a/webchannel/objc/imported_src/WCWebChannelClientInternal.h b/webchannel/objc/imported_src/WCWebChannelClientInternal.h new file mode 100644 index 0000000..6cea6ed --- /dev/null +++ b/webchannel/objc/imported_src/WCWebChannelClientInternal.h @@ -0,0 +1,123 @@ +#import + +#import "WCRuntimeProperties.h" +#import "WCWebChannelClient.h" +#import "WCWebChannelClientProtocol.h" +#import "WCWebChannelClientReadWrite.h" + +@class GTMSessionFetcherService; +@class WCForwardChannelRequestPool; +@class WCWireV8; +@class WCWireV8Binary; +@class WCChannelRequest; +@class WCQueuedMap; +@class WCInternalChannelParams; +@class WCOptions; +@class WCConnectionState; +@class WCRuntimeProperties; +@protocol WCWebChannelInternalHTTPHandler; +@protocol WCSupport; + +static const int kWCLastChannelVersion = 8; +static const int kWCClientVersion = 23; +static const NSTimeInterval kWCDefaultBaseRetryDelay = 5; +static const NSTimeInterval kWCDefaultRetryDelaySeed = 10; +static const int kWCDefaultForwardChannelMaxRetries = 2; +static const int kWCDefaultBackChannelMaxRetries = 3; +static const NSTimeInterval kWCDefaultForwardChannelRequestTimeout = 20; +static const int kWCMaxMapsPerRequest = 1000; +static const int kWCMaxCharPerGET = 4096; // 4 * 1024 +static const NSTimeInterval kWCRTTEstimate = 3; +static const int kWCOutstandingDataBackChannelRetryCutoff = 37500; + +static NSString *const kWCXClientProtocol = @"X-Client-Protocol"; +static NSString *const kWCXClientWireProtocol = @"X-Client-Wire-Protocol"; +static NSString *const kWCXClientProtocolWebChannel = @"webchannel"; +static NSString *const kWCXWebChannelContentType = @"X-WebChannel-Content-Type"; +static NSString *const kWCXWebChannelClientProfile = @"X-WebChannel-Client-Profile"; +static NSString *const kWCXHTTPSessionID = @"X-HTTP-Session-Id"; +static NSString *const kWCXHTTPInitialResponse = @"X-HTTP-Initial-Response"; +static NSString *const kRawDataKey = @"__data__"; +static NSString *const kMetadataHeadersKey = @"__headers__"; +static NSString *const kMetadataStatusKey = @"__status__"; +static NSString *const kMetadataKey = @"__sm__"; + +@protocol WCWebChannelInternalHTTPHandler +/** + * This function parses response text during initial handshake; otherwise when connection is OPENED, + * it handles messages by calling WCWebChannelClientDelegate:didReceiveMessage. + * + * @param input The response data passed from @c NSURLSessionDataTask. + * @param request The request triggering response data. + */ +- (void)didReceiveInput:(NSString *)input withRequest:(WCChannelRequest *)request; + +/** + * This function handles success connection by calling WCWebChannelClientDelegate:webChannelOpened. + * + * @param request The completing request. + */ +- (void)handleCompleteRequest:(WCChannelRequest *)request; + +/** + * This function handles when first byte of data is received. + * + * @param request The resquest that got this first byte of data. + * @param responseText The text string received in response. + */ +- (void)didReceivedFirstByteOfRequest:(WCChannelRequest *)request + responseText:(NSString *)responseText; + +@end + +/** The concrete WebChannel client. */ +@interface WCWebChannelClientInternal : NSObject + +/** The delegate for handling actions on the WebChannel. */ +@property(weak, nullable) id delegate; +@property(readonly) WCWebChannelClientState state; +@property NSDictionary *extraParams; +@property(readonly) WCRuntimeProperties *runtimeProperties; +@property(nonatomic) NSString *HTTPSessionIDParam; +@property(nonatomic) NSString *HTTPSessionID; +@property(nonatomic, readonly) NSURL *forwardChannelURL; +@property(nonatomic, readonly) NSURL *backChannelURL; +@property(nonatomic, readonly) WCForwardChannelRequestPool *forwardChannelRequestPool; +@property(nonatomic, readonly) WCWireV8 *wireCodec; +@property(nonatomic, readonly) WCWireV8Binary *wireCodecBinary; +@property(nonatomic, readonly) NSString *sessionID; +@property(nonatomic) NSMutableDictionary *extraHeaders; +@property(nonatomic) NSMutableDictionary *initialHeaders; +@property(nonatomic, readonly, getter=isStreamingEnabled) BOOL streamingEnabled; +@property(nonatomic) int forwardChannelMaxRetries; +@property(nonatomic) NSTimeInterval forwardChannelRequestTimeout; +@property(nonatomic, readonly) int backChannelMaxRetries; +@property(nonatomic, readonly) int lastStatusCode; +@property(nonatomic, readonly) int lastResponseCount; +@property(nonatomic, readonly) int lastPostResponseCount; +@property(nonatomic) WCWebChannelClientError error; +@property(nonatomic, readonly) int channelVersion; +@property(nonatomic, readonly) WCChannelRequest *backChannelRequest; +@property(nonatomic, readonly) NSMutableArray *outgoingMaps; +@property(nonatomic, readonly) NSMutableArray *nonAckedMaps; +@property(nonatomic, readonly) int backChannelRetryCount; +@property(nonatomic) WCAckCommitCallbackBlock forwardChannelFlushedCallback; + +/** + * @param URL The base URL of the channel. + * @param options Configuration input of WebChannel runtime options. + * @param connectionState The connection state of WebChannel client. + * @param clientVersion The clientVersion, default to be 0. + * @param delegate The implementation of protocol for processing messages the + * WebChannel client receives from the server. + * @param support The implementation of protocol for the support. + * @param fetcherService The fetcher service used for requests. + * @param dispatchQueue A dedicated dispatch queue where webchannel logic is run on. + */ +- (instancetype)initWithURL:(NSString *)baseURL + options:(WCOptions *)options + connectionState:(WCConnectionState *)connectionState + clientVersion:(int)clientVersion + delegate:(id)delegate + support:(id)support; +@end diff --git a/webchannel/objc/imported_src/WCWebChannelClientInternal.m b/webchannel/objc/imported_src/WCWebChannelClientInternal.m new file mode 100644 index 0000000..6c69485 --- /dev/null +++ b/webchannel/objc/imported_src/WCWebChannelClientInternal.m @@ -0,0 +1,1193 @@ +#import "WCWebChannelClientInternal.h" +#import "WCTimer.h" +#import "WCHTTPRequest.h" +#import "WCLogger.h" +#import "WCChannelRequest.h" +#import "WCFailureRecoveryContext.h" +#import "WCForwardChannelRequestPool.h" +#import "WCInternalChannelParams.h" +#import "WCOptions.h" +#import "WCQueuedMap.h" +#import "WCRuntimeProperties+Private.h" +#import "WCRuntimeProperties.h" +#import "WCSupport.h" +#import "WCWebChannelClient.h" +#import "WCWebChannelClientReadWrite.h" +#import "WCWireV8.h" +#import "WCWireV8Binary.h" +#import + +static const int kDecodeLevelOne = 1; +static const int kResponseArrayCountThree = 3; +static const int kDecodeLevelThree = 3; +static const int kPostResponseCountIndex = 1; +static const int kBackChannelIDIndex = 0; +static const double kBackChannelRequestTimeoutMultiplier = 2.1; +static const int kResponseOpen = 0; + +static NSString *const kQueryItemNameRID = @"RID"; +static NSString *const kQueryItemNameCVER = @"CVER"; +static NSString *const kQueryItemNameVER = @"VER"; +static NSString *const kQueryItemNameType = @"TYPE"; +static NSString *const kQueryItemValueTerminate = @"terminate"; +static NSString *const kQueryItemValueXMLHTTP = @"xmlhttp"; +static NSString *const kQueryItemValueInit = @"init"; +static NSString *const kQueryItemNameRequest = @"$req"; +static NSString *const kGETRequestID = @"rpc"; +static NSString *const kQueryItemNameFastHandshakeSID = @"sid"; +static NSString *const kQueryItemNameSID = @"SID"; +static NSString *const kQueryItemNameAID = @"AID"; +static NSString *const kQueryItemValueNull = @"null"; +static NSString *const kQueryItemNameOSID = @"OSID"; +static NSString *const kQueryItemNameOAID = @"OAID"; +static NSString *const kQueryItemCI = @"CI"; +static NSString *const kNumberZero = @"0"; +static NSString *const kNumberOne = @"1"; +static NSString *const kResponseStop = @"stop"; +static NSString *const kResponseClose = @"close"; +static NSString *const kResponseNoop = @"noop"; +static NSString *const kBackChannelMissing = @"0"; +static NSString *const kQueryParamCharacterComma = @","; +static NSString *const kQueryParamCharacterEncodedComma = @"%2C"; + +@interface WCWebChannelClientInternal () +@end + +@implementation WCWebChannelClientInternal { + WCConnectionState *_connectionState; + int _serverVersion; + int _clientVersion; + NSString *_path; + + id _forwardChannelDelayTimer; + id _backChannelDelayTimer; + id _deadBackChannelTimer; + id _bufferProxyDetectionTimer; + NSTimeInterval _handshakeRTT; + NSTimeInterval _baseRetryDelay; + NSTimeInterval _retryDelaySeed; + NSTimeInterval _backChannelRequestTimeout; + + int _nextRequestID; + int _nextMapID; + int _forwardChannelRetryCount; + int _backChannelAttemptID; + BOOL _failFast; + BOOL _detectBufferingProxy; + BOOL _fastHandshake; + BOOL _blockingHandshake; + BOOL _enableBinaryEncoding; + BOOL _bufferProxyDetectionDone; + BOOL _sendRawJSON; + BOOL _forwardRetryPendingMessagesScheduled; + BOOL _forwardChannelRequestInProgress; + BOOL _backChannelRequestInProgress; + + id _support; + + NSMutableArray *_nonAckedMapsWithClosedChannel; + + dispatch_queue_t _dispatchQueue; +} + +- (instancetype)initWithURL:(NSString *)baseURL + options:(WCOptions *)options + connectionState:(WCConnectionState *)connectionState + clientVersion:(int)clientVersion + delegate:(id)delegate + support:(id)support { + self = [super init]; + if (self) { + WCInternalChannelParams *internalChannelParams = + options.internalChannelParams ?: [[WCInternalChannelParams alloc] init]; + _path = [baseURL copy]; + _extraParams = @{}; + _delegate = delegate; + _connectionState = connectionState; + _clientVersion = clientVersion > 0 ? clientVersion : kWCClientVersion; + _support = support; + _runtimeProperties = [[WCRuntimeProperties alloc] initWithChannel:self]; + + _outgoingMaps = [@[] mutableCopy]; + _wireCodec = [[WCWireV8 alloc] initWithSupport:_support]; + _wireCodecBinary = [[WCWireV8Binary alloc] initWithSupport:_support]; + _failFast = internalChannelParams.failFast; + if (options.fastHandshake && options.enableBinaryEncoding) { + [_support.logger logWarning:@"Ignore fastHandshake because binary encoding is set."]; + // It's not safe to overwrite enable_binary_encoding to false + _fastHandshake = NO; + } else { + _fastHandshake = options.fastHandshake; + } + _enableBinaryEncoding = options.enableBinaryEncoding; + _streamingEnabled = YES; + _lastResponseCount = -1; + _lastPostResponseCount = -1; + _lastStatusCode = -1; + _baseRetryDelay = internalChannelParams.baseRetryDelay; + _retryDelaySeed = internalChannelParams.retryDelaySeed; + _forwardChannelMaxRetries = internalChannelParams.forwardChannelMaxRetries; + _backChannelMaxRetries = kWCDefaultBackChannelMaxRetries; + _forwardChannelRequestTimeout = internalChannelParams.forwardChannelRequestTimeout; + _sessionID = @""; + _forwardChannelRequestPool = [[WCForwardChannelRequestPool alloc] + initWithMaxPoolSize:options.concurrentRequestLimit ?: 0]; + + [self configureByOptions:options]; + _channelVersion = kWCLastChannelVersion; + _state = WCWebChannelClientStateInit; + _dispatchQueue = support.dispatchQueue; + } + return self; +} + +#pragma mark - public APIs + +- (void)open { + [self connectWithSessionID:nil responseID:nil]; +} + +- (void)close { + [self disconnect]; +} + +- (void)send:(NSString *)message { + if (_enableBinaryEncoding && _serverVersion > 0 && _serverVersion < 13) { + [_support.logger + logError:[NSString + stringWithFormat:@"Binary encoding is not supported by server version %d.", + _serverVersion]]; + return; + } + NSMutableDictionary *rawJSON = [NSMutableDictionary dictionary]; + rawJSON[kRawDataKey] = message; + [self sendMap:rawJSON context:nil]; +} + +- (void)sendData:(NSData *)data { + if (!_enableBinaryEncoding) { + [_support.logger logError:@"Binary encoding is not enabled."]; + return; + } + if (_serverVersion > 0 && _serverVersion < 13) { + [_support.logger + logError:[NSString + stringWithFormat:@"Binary encoding is not supported by server version %d.", + _serverVersion]]; + return; + } + NSMutableDictionary *rawJSON = [NSMutableDictionary dictionary]; + rawJSON[kRawDataKey] = data; + [self sendMap:rawJSON context:nil]; +} + + +#pragma mark - WCWebChannelInternalHTTPHandler + +- (void)didReceiveInput:(NSData *)response withRequest:(WCChannelRequest *)request { + WCWebChannelClientState currentState = self.state; + if (currentState == WCWebChannelClientStateClosed || + !([_backChannelRequest isEqual:request] || [_forwardChannelRequestPool hasRequest:request])) { + return; + } + + if (response == nil) { + return; + } + + if (!request.initialResponseDecoded && [_forwardChannelRequestPool hasRequest:request] && + currentState == WCWebChannelClientStateOpened) { + NSArray *responseArray = [_wireCodec decodeMessage:response level:kDecodeLevelOne]; + if (responseArray.count == kResponseArrayCountThree) { + [self handlePOSTResponse:responseArray request:request]; + [self checkForwardChannelFlush]; + } else { + [_support.logger logDebug:@"Bad POST response returned."]; + [self signalError:WCWebChannelClientErrorBadResponse]; + } + } else { + if (request.initialResponseDecoded || [_backChannelRequest isEqual:request]) { + [self clearDeadBackchannelTimer]; + } + NSArray *decodedResponse = [_wireCodec decodeMessage:response level:kDecodeLevelThree]; + [self processInput:decodedResponse request:request]; + } +} + +- (void)handleCompleteRequest:(WCChannelRequest *)request { + [_support.logger logDebug:@"Request Complete."]; + WCChannelType type; + NSMutableArray *pendingMessages = nil; + if ([_backChannelRequest isEqual:request]) { + [self clearDeadBackchannelTimer]; + [self clearBufferProxyDetectionTimer]; + _backChannelRequest = nil; + type = WCChannelTypeBackChannel; + } else if ([_forwardChannelRequestPool hasRequest:request]) { + pendingMessages = request.pendingMessages; + [_forwardChannelRequestPool removeRequest:request]; + type = WCChannelTypeForwardChannel; + } else { + return; + } + if (self.state == WCWebChannelClientStateClosed) { + return; + } + _lastStatusCode = request.lastStatusCode; + + if (request.successful) { + if (type == WCChannelTypeForwardChannel) { + int size = request.POSTData.length; + [_support + notifyTimingEventWithSize:size + withRTT:[[NSDate date] timeIntervalSinceDate:request.requestStartTime] + withRetries:_forwardChannelRetryCount]; + [self checkForwardChannelAvailabilityThenStart]; + [self triggerSuccessRequest:request]; + } else { + [self checkBackChannelAvailabilityThenStart]; + } + return; + } + [self attemptRetryForFailedRequest:request channelType:type pendingMessages:pendingMessages]; +} + +- (void)didReceivedFirstByteOfRequest:(WCChannelRequest *)request + responseText:(NSString *)responseText { + if ([_backChannelRequest isEqual:request] && !_bufferProxyDetectionDone && + _detectBufferingProxy) { + [self clearBufferProxyDetectionTimer]; + _bufferProxyDetectionDone = YES; + [_support notifyStatEvent:WCRequestStatNoProxy]; + } +} + +#pragma mark - WebChannel client states internal handlers + +- (void)triggerOpened { + [_support.logger logInfo:[NSString stringWithFormat:@"WebChannel opened on %@", _path]]; + [self.delegate webChannelOpened:nil]; +} + +- (void)triggerClosedWithPendingData:(NSArray *)pendingData + undeliveredData:(NSArray *)undeliveredData { + [_support.logger + logInfo:[NSString + stringWithFormat: + @"WebChannel closed on %@ with pending data: %@ and undelivered data: %@", + _path, pendingData.description, undeliveredData.description]]; + + [self.delegate webChannelClosed:nil]; +} + +- (void)triggerSuccessRequest:(WCChannelRequest *)request { + // NO-OP +} + +- (void)triggerDidReceiveMessage:(id)message { + id localDelegate = self.delegate; + if (localDelegate == nil) { + return; + } + if ([message respondsToSelector:@selector(allKeys)] && message[kMetadataHeadersKey] != nil) { + NSDictionary *headers = message[kMetadataHeadersKey]; + NSString *statusCodeValue = message[kMetadataStatusKey]; + int statusCode = statusCodeValue.intValue; + [localDelegate webChannel:nil didReceiveHeaders:headers statusCode:statusCode]; + return; + } + + if ([message respondsToSelector:@selector(allKeys)] && message[kMetadataKey] != nil) { + id metadataJSON = message[kMetadataKey]; + if ([metadataJSON respondsToSelector:@selector(allKeys)]) { + NSString *metadataKey = [[metadataJSON allKeys] firstObject]; + if (metadataKey != nil) { + [localDelegate webChannel:nil didReceiveMetadata:metadataJSON[metadataKey] key:metadataKey]; + } else { + [localDelegate webChannel:nil didReceiveMetadata:@"" key:@""]; + } + } + } else { + [localDelegate webChannel:nil didReceiveMessage:message]; + } +} + +- (void)triggerDidReceiveMultipleArrays:(NSArray *)messages { + // NO-OP +} + +- (void)triggerEncounteredError:(WCWebChannelClientError)error { + [_support.logger + logInfo:[NSString stringWithFormat:@"WebChannel aborted on %@ due to channel error %ld", + _path, (long)error]]; + [self.delegate webChannel:nil encounteredError:error]; +} + +#pragma mark - Private + +- (void)configureByOptions:(WCOptions *)options { + if (!options) { + return; + } + NSMutableDictionary *localMessageUrlParams = + [self.extraParams mutableCopy]; + [localMessageUrlParams addEntriesFromDictionary:options.messageURLParams]; + NSMutableDictionary *messageHeaders = [@{} mutableCopy]; + if (options.clientProtocolHeaderRequired) { + messageHeaders[kWCXClientProtocol] = kWCXClientProtocolWebChannel; + } + if (options.messageHeaders) { + [messageHeaders addEntriesFromDictionary:options.messageHeaders]; + } + [self setExtraHeaders:messageHeaders]; + NSMutableDictionary *initHeader = + [options.initialMessageHeaders mutableCopy] ?: [@{} mutableCopy]; + initHeader[kWCXWebChannelContentType] = options.messageContentType; + initHeader[kWCXWebChannelClientProfile] = options.clientProfile; + self.initialHeaders = initHeader; + _sendRawJSON = options.sendingRawJSON; + if (options.HTTPSessionIDParam != nil) { + NSString *HTTPSessionIDParam = [options.HTTPSessionIDParam + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (HTTPSessionIDParam.length != 0) { + self.HTTPSessionIDParam = HTTPSessionIDParam; + [localMessageUrlParams removeObjectForKey:HTTPSessionIDParam]; + } + } + if (options.redactDisabled) { + // debug info + } + _streamingEnabled = !options.longPollingForced; + _detectBufferingProxy = !_fastHandshake && _streamingEnabled && options.detectBufferingProxy; + _blockingHandshake = options.blockingHandshake; + self.extraParams = [localMessageUrlParams copy]; +} + +- (void)sendMap:(NSDictionary *)map context:(NSObject *)context { + WCWebChannelClientState currentState = self.state; + if (currentState == WCWebChannelClientStateClosed) { + [_support.logger logWarning:@"Invalid operation: sending map when state is closed."]; + } + if (_outgoingMaps.count == kWCMaxMapsPerRequest) { + [_support.logger + logError:[NSString stringWithFormat:@"Already have %d queued maps upon queueing %@.", + kWCMaxMapsPerRequest, map.description]]; + } + [_outgoingMaps addObject:[[WCQueuedMap alloc] initWithMapID:_nextMapID++ + map:map + context:context]]; + if (currentState == WCWebChannelClientStateOpened) { + [self checkForwardChannelAvailabilityThenStart]; + } +} + +- (int)forwardChannelMaxRetries { + return _failFast ? 0 : _forwardChannelMaxRetries; +} + +- (void)connectWithSessionID:(NSString *)sessionID responseID:(NSString *)responseID { + [_support notifyStatEvent:WCRequestStatConnectAttempt]; + WCWebChannelClientState currentState = self.state; + NSMutableDictionary *localMessageUrlParams = + [self.extraParams mutableCopy]; + if (sessionID != nil && responseID != nil) { + localMessageUrlParams[kQueryItemNameOSID] = sessionID; + localMessageUrlParams[kQueryItemNameOAID] = responseID; + } + self.extraParams = [localMessageUrlParams copy]; + if (currentState == WCWebChannelClientStateInit || + currentState == WCWebChannelClientStateClosed) { + _forwardChannelURL = [self createDataURL:_path]; + [self checkForwardChannelAvailabilityThenStart]; + } +} + +- (void)checkForwardChannelAvailabilityThenStart { + if (_forwardChannelRequestPool.full) { + return; + } + if (_forwardChannelRequestInProgress) { + return; + } + __weak typeof(self) weakSelf = self; + dispatch_async(_dispatchQueue, ^{ + __strong typeof(self) strongSelf = weakSelf; + if (!strongSelf) return; + strongSelf->_forwardChannelRequestInProgress = NO; + [strongSelf startForwardChannelWithRetryRequest:nil]; + }); + _forwardChannelRequestInProgress = YES; + _forwardChannelRetryCount = 0; +} + +- (void)checkBackChannelAvailabilityThenStart { + if (_backChannelRequest != nil) { + return; + } + if (_backChannelRequestInProgress) { + return; + } + __weak typeof(self) weakSelf = self; + dispatch_async(_dispatchQueue, ^{ + __strong typeof(self) strongSelf = weakSelf; + if (!strongSelf) return; + [strongSelf onStartBackChannelTimer]; + }); + _backChannelRequestInProgress = YES; + _backChannelRetryCount = 0; +} + +- (void)startForwardChannelWithRetryRequest:(WCChannelRequest *)retryRequest { + WCWebChannelClientState currentState = self.state; + _forwardChannelRequestInProgress = NO; + if (![self shouldMakeRequest]) { + return; + } + if (currentState == WCWebChannelClientStateInit) { + if (retryRequest != nil) { + return; + } + [self openForwardChannel]; + } + if (currentState == WCWebChannelClientStateOpened) { + if (retryRequest != nil) { + [self makeForwardChannelRequest:retryRequest]; + return; + } + if (_outgoingMaps.count == 0) { + [_support.logger logDebug:@"Nothing to send."]; + return; + } + if (_forwardChannelRequestPool.full) { + [_support.logger logDebug:@"Connection already in progress."]; + } + [self makeForwardChannelRequest:nil]; + } +} + +- (void)openForwardChannel { + [_support.logger logDebug:@"Opening Forward Channel."]; + _nextRequestID = arc4random_uniform(100000); + NSString *requestID = [NSString stringWithFormat:@"%d", _nextRequestID++]; + WCChannelRequest *request = [[WCChannelRequest alloc] initWithSessionID:@"" + requestID:requestID + support:_support + delegate:self]; + NSMutableDictionary *extraHeaders = [_extraHeaders mutableCopy]; + [extraHeaders addEntriesFromDictionary:_initialHeaders]; + request.extraHeaders = extraHeaders; + + int max = _fastHandshake ? [self maxNumMessageForFastHandshake] : kWCMaxMapsPerRequest; + NSURLComponents *components = [NSURLComponents componentsWithURL:_forwardChannelURL + resolvingAgainstBaseURL:NO]; + [self addQueryParameterToURLComponents:components name:kQueryItemNameRID value:requestID]; + if (_clientVersion > 0) { + [self addQueryParameterToURLComponents:components + name:kQueryItemNameCVER + value:[NSString stringWithFormat:@"%d", _clientVersion]]; + } + if (_HTTPSessionIDParam.length > 0) { + [self addQueryParameterToURLComponents:components + name:kWCXHTTPSessionID + value:_HTTPSessionIDParam]; + } + if (_blockingHandshake) { + [self addQueryParameterToURLComponents:components + name:kQueryItemNameType + value:kQueryItemValueInit]; + } + + if (_enableBinaryEncoding) { + NSMutableData *requestData = [NSMutableData data]; + request.pendingMessages = [self pendingMessagesWithMaxBinary:max requestData:requestData]; + request.isBinaryMessage = YES; + [_forwardChannelRequestPool addRequest:request]; + [request sendPOST:components withPostData:requestData chunkDecoded:YES]; + } else { + NSMutableString *requestText = [@"" mutableCopy]; + request.pendingMessages = [self pendingMessagesWithMax:max requestText:requestText]; + [_forwardChannelRequestPool addRequest:request]; + + if (_fastHandshake) { + [self addQueryParameterToURLComponents:components + name:kQueryItemNameRequest + value:requestText]; + [self addQueryParameterToURLComponents:components + name:kQueryItemNameFastHandshakeSID + value:kQueryItemValueNull]; + request.initialResponseDecoded = YES; + [request sendPOST:components withData:nil chunkDecoded:YES]; + } else { + [request sendPOST:components withData:requestText chunkDecoded:YES]; + } + } + // Make sure we send the POST request out, then flip the state. + _state = WCWebChannelClientStateOpening; +} + +- (NSURL *)createDataURL:(NSString *)path { + NSURLComponents *components = [NSURLComponents componentsWithString:path]; + if (_HTTPSessionIDParam && _HTTPSessionIDParam.length > 0 && _HTTPSessionID && + _HTTPSessionID.length > 0) { + [self addQueryParameterToURLComponents:components + name:_HTTPSessionIDParam + value:_HTTPSessionID]; + } + [self addQueryParameterToURLComponents:components + name:kQueryItemNameVER + value:[NSString stringWithFormat:@"%d", _channelVersion]]; + [self addAdditionalParams:components]; + return components.URL; +} + +- (void)addAdditionalParams:(NSURLComponents *)components { + if (self.extraParams != nil) { + for (NSString *key in self.extraParams.allKeys) { + [self addQueryParameterToURLComponents:components name:key value:self.extraParams[key]]; + } + } +} + +- (void)onStartBackChannelTimer { + [self clearBackChannelDelayTimer]; + _backChannelRequestInProgress = NO; + [self startBackChannel]; + if (!_detectBufferingProxy || _bufferProxyDetectionDone || _backChannelRequest == nil || + _handshakeRTT <= 0) { + return; + } + NSTimeInterval bufferProxyDetectionTimeout = 4 * _handshakeRTT; + __weak __typeof__(self) weakSelf = self; + _bufferProxyDetectionTimer = [_support setTimeout:bufferProxyDetectionTimeout + block:^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf) { + [strongSelf startBufferProxyDetection]; + } + }]; +} + +- (void)startBufferProxyDetection { + [self clearBufferProxyDetectionTimer]; + if (_backChannelRequest.request != nil) { + NSData *responseData = _backChannelRequest.responseData; + if (responseData.length > 0) { + [_support.logger + logWarning:[NSString + stringWithFormat:@"Timer should have been cancelled: %@", responseData]]; + } + } + _streamingEnabled = NO; + _bufferProxyDetectionDone = YES; + [_support notifyStatEvent:WCRequestStatProxy]; + [self cancelBackChannelRequest]; + [self startBackChannel]; +} + +- (void)startBackChannel { + if (![self shouldMakeRequest]) { + return; + } + _backChannelRequest = [[WCChannelRequest alloc] initWithSessionID:_sessionID + requestID:kGETRequestID + support:_support + delegate:self + retryID:_backChannelAttemptID]; + _backChannelRequest.extraHeaders = _extraHeaders; + + NSURLComponents *components = [[NSURLComponents alloc] initWithURL:_forwardChannelURL + resolvingAgainstBaseURL:NO]; + [self addQueryParameterToURLComponents:components name:kQueryItemNameRID value:kGETRequestID]; + [self addQueryParameterToURLComponents:components name:kQueryItemNameSID value:_sessionID]; + [self addQueryParameterToURLComponents:components + name:kQueryItemCI + value:_streamingEnabled ? kNumberZero : kNumberOne]; + [self addQueryParameterToURLComponents:components + name:kQueryItemNameAID + value:[NSString stringWithFormat:@"%d", _lastResponseCount]]; + [self addQueryParameterToURLComponents:components + name:kQueryItemNameType + value:kQueryItemValueXMLHTTP]; + + if (_backChannelRequestTimeout > 0) { + _backChannelRequest.timeout = _backChannelRequestTimeout; + } + [_backChannelRequest sendGET:components chunkDecoded:YES]; +} + +- (void)addQueryParameterToURLComponents:(NSURLComponents *)components + name:(NSString *)name + value:(NSString *)value { + NSURLQueryItem *item = [NSURLQueryItem queryItemWithName:name value:value]; + item = [self encodeCommaInQueryItem:item]; + NSMutableArray *queryItems = + [NSMutableArray arrayWithArray:components.queryItems]; + [queryItems addObject:item]; + [components setQueryItems:queryItems]; +} + +- (NSURLQueryItem *)encodeCommaInQueryItem:(NSURLQueryItem *)item { + // Manually encode comma. According to the docs + // (https://developer.apple.com/documentation/foundation/nsurlcomponents?language=objc) it + // utilizes RFC3986 (http://www.ietf.org/rfc/rfc3986.txt) and comma is legal in query parameters + // per that RFC. + NSString *commaEncodedName = + [item.name stringByReplacingOccurrencesOfString:kQueryParamCharacterComma + withString:kQueryParamCharacterEncodedComma]; + NSString *commaEncodedValue = + [item.value stringByReplacingOccurrencesOfString:kQueryParamCharacterComma + withString:kQueryParamCharacterEncodedComma]; + return [NSURLQueryItem queryItemWithName:commaEncodedName value:commaEncodedValue]; +} + +- (void)requeuePendingMaps:(WCChannelRequest *)retryRequest { + NSIndexSet *indexes = + [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, retryRequest.pendingMessages.count)]; + [_outgoingMaps insertObjects:retryRequest.pendingMessages atIndexes:indexes]; +} + +- (NSMutableArray *)pendingMessagesWithMax:(int)maxNum + requestText:(NSMutableString *)result { + int count = MIN(_outgoingMaps.count, maxNum); + [result appendString:[_wireCodec encodeMessageQueue:_outgoingMaps numOfMessages:count]]; + NSMutableArray *pendingMessages = + [[_outgoingMaps subarrayWithRange:NSMakeRange(0, count)] mutableCopy]; + [_outgoingMaps removeObjectsInRange:NSMakeRange(0, count)]; + return pendingMessages; +} + +- (NSMutableArray *)pendingMessagesWithMaxBinary:(int)maxNum + requestData:(NSMutableData *)result { + int count = MIN(_outgoingMaps.count, maxNum); + [result appendData:[_wireCodecBinary encodeMessageQueue:_outgoingMaps numOfMessages:count]]; + NSMutableArray *pendingMessages = + [[_outgoingMaps subarrayWithRange:NSMakeRange(0, count)] mutableCopy]; + [_outgoingMaps removeObjectsInRange:NSMakeRange(0, count)]; + return pendingMessages; +} + +- (int)maxNumMessageForFastHandshake { + int total = 0; + for (int i = 0; i < _outgoingMaps.count; i++) { + WCQueuedMap *map = _outgoingMaps[i]; + int size = map.rawDataSize; + if (size <= 0) { + break; + } + total += size; + if (total > kWCMaxCharPerGET) { + return i; + } + if (total == kWCMaxCharPerGET || i == _outgoingMaps.count - 1) { + return i + 1; + } + } + return kWCMaxMapsPerRequest; +} + +- (void)makeForwardChannelRequest:(WCChannelRequest *)retryRequest { + NSString *requestID = nil; + if (retryRequest != nil) { + requestID = retryRequest.requestID; + } else { + requestID = [NSString stringWithFormat:@"%d", _nextRequestID++]; + } + NSURLComponents *components = [[NSURLComponents alloc] initWithURL:_forwardChannelURL + resolvingAgainstBaseURL:NO]; + [self addQueryParameterToURLComponents:components name:kQueryItemNameSID value:_sessionID]; + [self addQueryParameterToURLComponents:components name:kQueryItemNameRID value:requestID]; + [self addQueryParameterToURLComponents:components + name:kQueryItemNameAID + value:[NSString stringWithFormat:@"%d", _lastResponseCount]]; + + WCChannelRequest *request = [[WCChannelRequest alloc] initWithSessionID:_sessionID + requestID:requestID + support:_support + delegate:self]; + request.extraHeaders = _extraHeaders; + + if (retryRequest != nil) { + [self requeuePendingMaps:retryRequest]; + } + request.timeout = round(_forwardChannelRequestTimeout * 0.5) + + round(_forwardChannelRequestTimeout * 0.5 * ((double)rand() / RAND_MAX)); + if (_enableBinaryEncoding) { + NSMutableData *requestData = [NSMutableData data]; + request.pendingMessages = [self pendingMessagesWithMaxBinary:kWCMaxMapsPerRequest + requestData:requestData]; + request.isBinaryMessage = YES; + [_forwardChannelRequestPool addRequest:request]; + [request sendPOST:components withPostData:requestData chunkDecoded:YES]; + } else { + NSMutableString *requestText = [@"" mutableCopy]; + request.pendingMessages = [self pendingMessagesWithMax:kWCMaxMapsPerRequest + requestText:requestText]; + [_forwardChannelRequestPool addRequest:request]; + [request sendPOST:components withData:requestText chunkDecoded:YES]; + } +} + +- (BOOL)shouldMakeRequest { + if (_error != WCWebChannelClientErrorNone) { + [self signalError:_error]; + return NO; + } + return YES; +} + +- (void)signalError:(WCWebChannelClientError)error { + [_support.logger logError:[NSString stringWithFormat:@"Error code: %ld", (long)error]]; + WCRequestStat requestStateError = error == WCWebChannelClientErrorRequestFailed + ? WCRequestStatErrorNetwork + : WCRequestStatErrorOther; + [_support notifyStatEvent:requestStateError]; + [self handleError:error]; +} + +- (void)handleError:(WCWebChannelClientError)error { + [_support.logger logDebug:[NSString stringWithFormat:@"HttpChannel: error - %ld", (long)error]]; + _state = WCWebChannelClientStateClosed; + [self triggerEncounteredError:error]; + + [self onClose]; + [self cancelRequests]; +} + +- (void)clearDeadBackchannelTimer { + [_support clearTimeout:_deadBackChannelTimer]; + _deadBackChannelTimer = nil; +} + +- (void)clearBufferProxyDetectionTimer { + [_support clearTimeout:_bufferProxyDetectionTimer]; + _bufferProxyDetectionTimer = nil; +} + +- (void)clearForwardChannelDelayTimer { + [_support clearTimeout:_forwardChannelDelayTimer]; + _forwardChannelDelayTimer = nil; +} + +- (void)clearBackChannelDelayTimer { + [_support clearTimeout:_backChannelDelayTimer]; + _backChannelDelayTimer = nil; +} + +- (void)attemptRetryForFailedRequest:(WCChannelRequest *)request + channelType:(WCChannelType)type + pendingMessages:(NSMutableArray *)pendingMessages { + WCChannelRequestError lastError = request.lastError; + _forwardRetryPendingMessagesScheduled = NO; + if (!request.lastErrorFatal) { + [_support.logger logDebug:[NSString stringWithFormat:@"Maybe retrying, last error: %@", + [request formatErrorToString:lastError]]]; + if (type == WCChannelTypeForwardChannel) { + if ([self shouldRetryForwardChannel:request]) { + if (!_forwardRetryPendingMessagesScheduled) { + [self retryForwardChannel:request]; + } + return; + } + } else { + if ([self shouldRetryBackChannel:request.lastError]) { + [self retryBackChannel:request.lastError]; + return; + } + } + } else { + [_support.logger logDebug:@"Not retrying due to error type."]; + } + + if (pendingMessages.count > 0) { + [_forwardChannelRequestPool addPendingMessages:pendingMessages]; + } + + [_support.logger logDebug:@"Error: HTTP request failed."]; + switch (lastError) { + case WCChannelRequestErrorNoData: + [self signalError:WCWebChannelClientErrorNoData]; + break; + case WCChannelRequestErrorBadData: + [self signalError:WCWebChannelClientErrorBadData]; + break; + case WCChannelRequestErrorUnknownSessionId: + [self signalError:WCWebChannelClientErrorUnknownSessionID]; + break; + case WCChannelRequestErrorUnknown: + break; + case WCChannelRequestErrorStatus: + case WCChannelRequestErrorTimeout: + case WCChannelRequestErrorHandlerException: + case WCChannelRequestErrorBrowserOffline: + [self signalError:WCWebChannelClientErrorRequestFailed]; + break; + } +} + +- (BOOL)shouldRetryForwardChannel:(WCChannelRequest *)request { + WCWebChannelClientState currentState = self.state; + if (_forwardChannelRequestPool.requestCount >= + _forwardChannelRequestPool.maxSize - (_forwardChannelRequestInProgress ? 1 : 0)) { + [_support.logger logError:@"Unexpected retry request is scheduled."]; + return NO; + } + if (_forwardChannelDelayTimer != nil) { + [_outgoingMaps + insertObjects:request.pendingMessages + atIndexes:[NSIndexSet + indexSetWithIndexesInRange:NSMakeRange(0, + request.pendingMessages.count)]]; + [_support.logger logDebug:@"Use the retry request that is already scheduled."]; + _forwardRetryPendingMessagesScheduled = YES; + return NO; + } + if (currentState == WCWebChannelClientStateInit || + currentState == WCWebChannelClientStateOpening || + _forwardChannelRetryCount >= _forwardChannelMaxRetries) { + return NO; + } + return YES; +} + +- (void)retryForwardChannel:(WCChannelRequest *)request { + if (_forwardChannelRequestInProgress) { + return; + } + [_support.logger logDebug:@"Going to retry POST."]; + WCFailureRecoveryContext *context = [[WCFailureRecoveryContext alloc] init]; + context.error = request.lastError; + context.attempt = _forwardChannelRetryCount + 1; + context.channelType = WCChannelTypeForwardChannel; + + NSTimeInterval delay = [self getRetryTime:_forwardChannelRetryCount]; + __weak __typeof__(self) weakSelf = self; + _forwardChannelDelayTimer = + [_support setTimeout:delay + block:^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf) { + [strongSelf clearForwardChannelDelayTimer]; + strongSelf->_forwardChannelRequestInProgress = NO; + [strongSelf startForwardChannelWithRetryRequest:request]; + } + } + context:context]; + _forwardChannelRequestInProgress = YES; + _forwardChannelRetryCount++; +} + +- (NSTimeInterval)getRetryTime:(int)retryCount { + NSTimeInterval retryTime = _baseRetryDelay + floor(((double)rand() / RAND_MAX) * _retryDelaySeed); + retryTime *= retryCount; + return retryTime; +} + +- (BOOL)shouldRetryBackChannel:(WCChannelRequestError)error { + if (_backChannelRequest != nil || _backChannelRequestInProgress) { + [_support.logger logError:@"Request already in progress."]; + return NO; + } + if (_backChannelRetryCount >= _backChannelMaxRetries) { + return NO; + } + return YES; +} + +- (void)retryBackChannel:(WCChannelRequestError)error { + [_support.logger logDebug:@"Going to retry GET."]; + _backChannelAttemptID++; + WCFailureRecoveryContext *context = nil; + if (error > 0) { + context = [[WCFailureRecoveryContext alloc] init]; + context.error = error; + context.attempt = _backChannelAttemptID; + context.channelType = WCChannelTypeBackChannel; + } + + NSTimeInterval delay = [self getRetryTime:_backChannelRetryCount]; + __weak __typeof__(self) weakSelf = self; + _backChannelDelayTimer = [_support setTimeout:delay + block:^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf) { + [strongSelf onStartBackChannelTimer]; + } + } + context:context]; + _backChannelRequestInProgress = YES; + _backChannelRetryCount++; +} + +- (void)onClose { + _state = WCWebChannelClientStateClosed; + _nonAckedMapsWithClosedChannel = [@[] mutableCopy]; + NSArray *copyOfpendingMessages = [_forwardChannelRequestPool.pendingMessages copy]; + NSArray *copyOfUndeliveredMaps = [_outgoingMaps copy]; + if (copyOfpendingMessages.count > 0 || _outgoingMaps.count > 0) { + [_support.logger + logDebug:[NSString + stringWithFormat:@"Number of undelivered maps pending: %lu, outgoing: %lu", + (unsigned long)copyOfpendingMessages.count, + (unsigned long)_outgoingMaps.count]]; + [_nonAckedMapsWithClosedChannel addObjectsFromArray:copyOfpendingMessages]; + [_nonAckedMapsWithClosedChannel addObjectsFromArray:copyOfUndeliveredMaps]; + [_forwardChannelRequestPool clearPendingMessages]; + [_outgoingMaps removeAllObjects]; + } + [self triggerClosedWithPendingData:copyOfpendingMessages undeliveredData:copyOfUndeliveredMaps]; +} + +- (void)processInput:(NSArray *> *)pendingBatchedResponses + request:(WCChannelRequest *)request { + WCWebChannelClientState currentState = self.state; + NSMutableArray *> *batch = nil; + for (NSArray *nextBatch in pendingBatchedResponses) { + _lastResponseCount = [(NSNumber *)nextBatch[0] integerValue]; + id nextResponseObject = nextBatch[1]; + if (currentState == WCWebChannelClientStateOpening) { + [self parseResponseObjectAtOpening:nextResponseObject request:request]; + } else if (currentState == WCWebChannelClientStateOpened) { + [self parseResponseObjectAtOpened:nextResponseObject nextBatch:nextBatch bufferBatch:batch]; + } + } + if (batch != nil && batch.count > 0) { + [self triggerDidReceiveMultipleArrays:batch]; + } +} + +- (void)parseResponseObjectAtOpening:(id)nextResponseObject request:(WCChannelRequest *)request { + NSArray *responsesAtOpening = nextResponseObject; + if ([responsesAtOpening[kResponseOpen] isEqual:@"c"]) { + _sessionID = responsesAtOpening[1]; + if (responsesAtOpening.count >= 4) { + _channelVersion = responsesAtOpening[3].integerValue; + } + if (responsesAtOpening.count >= 5) { + _serverVersion = responsesAtOpening[4].integerValue; + } + if (responsesAtOpening.count >= 6) { + NSInteger serverKeepAliveMS = responsesAtOpening[5].floatValue; + if (serverKeepAliveMS > 0) { + _backChannelRequestTimeout = + round(kBackChannelRequestTimeoutMultiplier * serverKeepAliveMS / 1000.0); + } + } + [self applyControlHeaders:request]; + + _state = WCWebChannelClientStateOpened; + [self triggerOpened]; + if (_detectBufferingProxy) { + _handshakeRTT = [[NSDate date] timeIntervalSinceDate:request.requestStartTime]; + } + [_support + notifyHandshakeTimingEventWithRtt:[[NSDate now] + timeIntervalSinceDate:request.requestStartTime]]; + [self startBackChannelAfterHandshake:request]; + if (_outgoingMaps.count != 0) { + [self checkForwardChannelAvailabilityThenStart]; + } + } else if ([nextResponseObject[0] isEqual:kResponseStop] || + [nextResponseObject[0] isEqual:kResponseClose]) { + [self signalError:WCWebChannelClientErrorStop]; + } +} +- (void)parseResponseObjectAtOpened:(id)nextResponseObject + nextBatch:(NSArray *)nextBatch + bufferBatch:(NSMutableArray *> *)batch { + NSArray *responsesAtOpened = nextBatch; + if ([nextResponseObject isKindOfClass:[NSArray class]]) { + responsesAtOpened = (NSArray *)nextResponseObject; + } + if ([nextResponseObject isKindOfClass:[NSArray class]] && + ([nextResponseObject[0] isEqual:kResponseStop] || + [nextResponseObject[0] isEqual:kResponseClose])) { + if (batch.count > 0) { + [self triggerDidReceiveMultipleArrays:batch]; + batch = [NSMutableArray array]; + } + if ([responsesAtOpened[0] isEqual:kResponseStop]) { + [self signalError:WCWebChannelClientErrorStop]; + } else { + [self disconnect]; + } + } else if ([nextResponseObject isKindOfClass:[NSArray class]] && + [nextResponseObject[0] isEqual:kResponseNoop]) { + // ignore - noop to keep connection happy + } else { + if (batch != nil) { + [batch addObjectsFromArray:responsesAtOpened]; + } else { + [self triggerDidReceiveMessage:nextResponseObject]; + } + } + _backChannelRetryCount = 0; +} +- (void)startBackChannelAfterHandshake:(WCChannelRequest *)request { + _backChannelURL = [self createDataURL:_path]; + if (request.initialResponseDecoded) { + [_support.logger logDebug:@"Upgrade the handshake request to a backchannel."]; + [_forwardChannelRequestPool removeRequest:request]; + request.timeout = _backChannelRequestTimeout; + _backChannelRequest = request; + } else { + [self checkBackChannelAvailabilityThenStart]; + } +} + +- (void)applyControlHeaders:(WCChannelRequest *)request { + id req = request.request; + if (req == nil) { + return; + } + NSString *clientProtocol = [req responseHeaderForName:kWCXClientWireProtocol]; + if (clientProtocol != nil) { + [_forwardChannelRequestPool applyClientProtocol:clientProtocol]; + } + if (_HTTPSessionIDParam.length > 0) { + NSString *HTTPSessionIDHeader = [req responseHeaderForName:kWCXHTTPSessionID]; + if (HTTPSessionIDHeader.length > 0) { + _HTTPSessionID = HTTPSessionIDHeader; + NSURLComponents *component = [NSURLComponents componentsWithURL:_forwardChannelURL + resolvingAgainstBaseURL:YES]; + [self addQueryParameterToURLComponents:component + name:_HTTPSessionIDParam + value:HTTPSessionIDHeader]; + _forwardChannelURL = component.URL; + } else { + [_support.logger logDebug:@"Missing iOS_HTTP_SESSION_ID in the handshake response."]; + } + } +} + +- (void)cancelRequests { + [self cancelBackChannelRequest]; + [self clearBufferProxyDetectionTimer]; + if (_backChannelRequestInProgress) { + [self clearBackChannelDelayTimer]; + _backChannelRequestInProgress = NO; + } + [self clearDeadBackchannelTimer]; + [_forwardChannelRequestPool cancel]; + if (_forwardChannelRequestInProgress) { + [self clearForwardChannelDelayTimer]; + _forwardChannelRequestInProgress = NO; + } +} + +- (void)disconnect { + [self cancelRequests]; + if (self.state == WCWebChannelClientStateOpened) { + long rid = _nextRequestID++; + NSURLComponents *components = [NSURLComponents componentsWithURL:_forwardChannelURL + resolvingAgainstBaseURL:NO]; + [self addQueryParameterToURLComponents:components name:kQueryItemNameSID value:_sessionID]; + [self addQueryParameterToURLComponents:components + name:kQueryItemNameRID + value:[NSString stringWithFormat:@"%ld", rid]]; + [self addQueryParameterToURLComponents:components + name:kQueryItemNameType + value:kQueryItemValueTerminate]; + [self addAdditionalParams:components]; + + WCChannelRequest *request = + [[WCChannelRequest alloc] initWithSessionID:_sessionID + requestID:[NSString stringWithFormat:@"%ld", rid] + support:_support + delegate:self]; + [request closeWithURLComponents:components]; + } + [self onClose]; +} + +- (void)cancelBackChannelRequest { + if (_backChannelRequest != nil) { + [self clearBufferProxyDetectionTimer]; + [_backChannelRequest cancel]; + _backChannelRequest = nil; + } +} + +- (void)handlePOSTResponse:(NSArray *)responseArray + request:(WCChannelRequest *)forwardRequest { + if (responseArray[kBackChannelIDIndex] == kBackChannelMissing) { + [self handleBackChannelMissingWithLastRequestStartTime:forwardRequest.requestStartTime]; + return; + } + _lastPostResponseCount = responseArray[kPostResponseCountIndex].integerValue; + int numOutstandingArrays = _lastPostResponseCount - _lastResponseCount; + if (numOutstandingArrays > 0) { + int numOutstandingBackChannelBytes = responseArray[2].integerValue; + if (![self shouldRetryBackChannelWithinLimit:numOutstandingBackChannelBytes]) { + return; + } + if (_deadBackChannelTimer == nil) { + __weak __typeof__(self) weakSelf = self; + _deadBackChannelTimer = [_support setTimeout:2 * kWCRTTEstimate + block:^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf) { + [strongSelf onBackChannelDead]; + } + }]; + } + } +} + +- (BOOL)shouldRetryBackChannelWithinLimit:(int)outstandingBytes { + return outstandingBytes < kWCOutstandingDataBackChannelRetryCutoff && _streamingEnabled && + _backChannelRetryCount == 0; +} + +- (void)onBackChannelDead { + if (_deadBackChannelTimer != nil) { + [self clearDeadBackchannelTimer]; + [self cancelBackChannelRequest]; + if ([self shouldRetryBackChannel:WCChannelRequestErrorNoData]) { + [self retryBackChannel:WCChannelRequestErrorNoData]; + } + [_support notifyStatEvent:WCRequestStatBackChannelDead]; + } +} + +- (void)handleBackChannelMissingWithLastRequestStartTime:(NSDate *)forwardRequestStartTime { + [_support.logger logDebug:@"Server claims our backchannel is missing."]; + if (_backChannelRequestInProgress) { + [_support.logger logDebug:@"But we are currently starting the request."]; + return; + } + if (_backChannelRequest == nil) { + [_support.logger logWarning:@"We don not have a backchannel established."]; + } else if ([_backChannelRequest.requestStartTime addTimeInterval:kWCRTTEstimate] < + forwardRequestStartTime) { + [self clearDeadBackchannelTimer]; + [self cancelBackChannelRequest]; + } else { + return; + } + if ([self shouldRetryBackChannel:WCChannelRequestErrorUnknown]) { + [self retryBackChannel:WCChannelRequestErrorUnknown]; + } + [_support notifyStatEvent:WCRequestStatBackChannelMissing]; +} + +- (void)checkForwardChannelFlush { + if (_forwardChannelRequestPool.requestCount <= 1) { + if (_forwardChannelFlushedCallback != nil) { + _forwardChannelFlushedCallback(); + } + _forwardChannelFlushedCallback = nil; + } +} + +- (NSMutableArray *)nonAckedMaps { + if (self.state == WCWebChannelClientStateClosed) { + return _nonAckedMapsWithClosedChannel; + } + + NSMutableArray *nonAckedMaps = [@[] mutableCopy]; + [nonAckedMaps addObjectsFromArray:_forwardChannelRequestPool.pendingMessages]; + [nonAckedMaps addObjectsFromArray:_outgoingMaps]; + return nonAckedMaps; +} + +@end diff --git a/webchannel/objc/imported_src/WCWebChannelClientProtocol.h b/webchannel/objc/imported_src/WCWebChannelClientProtocol.h new file mode 100644 index 0000000..6bc3940 --- /dev/null +++ b/webchannel/objc/imported_src/WCWebChannelClientProtocol.h @@ -0,0 +1,28 @@ +#import + +@protocol GTMFetcherAuthorizationProtocol; + +/** APIs and factory methods of the dispatchable WebChannel client. */ +@protocol WCWebChannelClientProtocol + +/** Opens a WebChannel connection. */ +- (void)open; + +/** Disconnects WebChannel. */ +- (void)close; + +/** + * Sends message using forward channel. + * + * @param message The string to be sent. + */ +- (void)send:(NSString *)message; + +/** + * Sends data using forward channel. + * + * @param data The data to be sent. + */ +- (void)sendData:(NSData *)data; + +@end diff --git a/webchannel/objc/imported_src/WCWebChannelClientReadWrite.h b/webchannel/objc/imported_src/WCWebChannelClientReadWrite.h new file mode 100644 index 0000000..c0d11b7 --- /dev/null +++ b/webchannel/objc/imported_src/WCWebChannelClientReadWrite.h @@ -0,0 +1,17 @@ +#import + +@class WCRuntimeProperties; + +typedef NS_ENUM(NSInteger, WCWebChannelClientState); + +/** Public accessible properties of the dispatchable WebChannel client. */ +@protocol WCWebChannelClientReadWrite + +/** + * This value will also be modified internally, so when users use it they have to read the params, + * aka making a copy before writing it. + */ +@property(copy) NSDictionary *messageUrlParams; +@property(readonly) WCRuntimeProperties *runtimeProperties; + +@end diff --git a/webchannel/objc/imported_src/WCWireV8.h b/webchannel/objc/imported_src/WCWireV8.h new file mode 100644 index 0000000..bc6d15a --- /dev/null +++ b/webchannel/objc/imported_src/WCWireV8.h @@ -0,0 +1,25 @@ +#import + +@class WCQueuedMap; +@protocol WCSupport; + +/** + * A stream of JSON chunks, each chunk representing a message (envelope) from the + * server. Messages to the client are sent down in a client-chunked style. This class will handle + * chunk encoding and decoding. + */ +@interface WCWireV8 : NSObject + +- (instancetype)initWithSupport:(id)support; + +- (NSString *)encodeMessageQueue:(NSMutableArray *)outgoingMaps numOfMessages:(int)count; + +/** + * Decode message from NSData to NSArray. + * + * @param message Data to be decoded. + * @param level The level of decoding of the nested array. + */ +- (NSArray *)decodeMessage:(NSData *)message level:(int)level; + +@end diff --git a/webchannel/objc/imported_src/WCWireV8.m b/webchannel/objc/imported_src/WCWireV8.m new file mode 100644 index 0000000..6af174f --- /dev/null +++ b/webchannel/objc/imported_src/WCWireV8.m @@ -0,0 +1,74 @@ +#import "WCWireV8.h" + +#import "WCJSONDecoder.h" +#import "WCURLEncoder.h" +#import "WCQueuedMap.h" +#import "WCSupport.h" + +@implementation WCWireV8 { + id _support; +} + +- (instancetype)initWithSupport:(id)support { + self = [super init]; + if (self) { + _support = support; + } + return self; +} + +- (void)encodeMessage:(NSDictionary *)message + buffer:(NSMutableArray *)buffer + prefix:(NSString *)prefix { + NSString *prefixField = prefix ?: @""; + for (NSString *key in message.allKeys) { + [buffer addObject:[NSString stringWithFormat:@"%@%@=%@", prefixField, key, + [_support.URLEncoder encode:message[key]]]]; + } +} + +- (NSString *)encodeMessageQueue:(NSMutableArray *)messageQueue numOfMessages:(int)count { + long offset = -1; + while (YES) { + NSMutableArray *buffer = [NSMutableArray array]; + [buffer addObject:[NSString stringWithFormat:@"count=%d", count]]; + if (offset == -1) { + if (count > 0) { + offset = messageQueue[0].mapID; + [self addOffsetToBuffer:buffer offset:offset]; + } else { + offset = 0; + } + } else { + [self addOffsetToBuffer:buffer offset:offset]; + } + + BOOL done = YES; + for (int i = 0; i < count; i++) { + long mapID = messageQueue[i].mapID - offset; + NSDictionary *map = messageQueue[i].map; + if (mapID < 0) { + // redo the encoding in case of retry/reordering and add extra space. + // This MAX is critical to make sure exit while(YES) loop. + offset = MAX(0, messageQueue[i].mapID - 100); + done = NO; + continue; + } + [self encodeMessage:map buffer:buffer prefix:[NSString stringWithFormat:@"req%ld_", mapID]]; + } + + if (done) { + return [buffer componentsJoinedByString:@"&"]; + } + } +} + +- (NSArray *)decodeMessage:(NSData *)message level:(int)level { + return [_support.JSONDecoder decodeData:message maxDepth:level]; +} + +- (void)addOffsetToBuffer:(NSMutableArray *)buffer offset:(long)offset { + [buffer addObject:[NSString stringWithFormat:@"ofs=%ld", offset]]; +} + +@end diff --git a/webchannel/objc/imported_src/WCWireV8Binary.h b/webchannel/objc/imported_src/WCWireV8Binary.h new file mode 100644 index 0000000..4ae8425 --- /dev/null +++ b/webchannel/objc/imported_src/WCWireV8Binary.h @@ -0,0 +1,23 @@ +#import + +@class WCQueuedMap; +@protocol WCSupport; + +/** + * A stream of binary chunks, each chunk representing a message (envelope) from the + * server. Messages to the client are sent down in a client-chunked style. This class will handle + * chunk encoding and decoding. + */ +@interface WCWireV8Binary : NSObject + +- (instancetype)initWithSupport:(id)support; + +/** + * Encode a queue of messages into a binary blob. + * + * @param messageQueue The queue of messages to be encoded. + * @param count The number of messages to encode. + */ +- (NSData *)encodeMessageQueue:(NSArray *)messageQueue numOfMessages:(int)count; + +@end diff --git a/webchannel/objc/imported_src/WCWireV8Binary.m b/webchannel/objc/imported_src/WCWireV8Binary.m new file mode 100644 index 0000000..14fa779 --- /dev/null +++ b/webchannel/objc/imported_src/WCWireV8Binary.m @@ -0,0 +1,71 @@ +#import "WCWireV8Binary.h" + +#import "WCQueuedMap.h" +#import "WCSupport.h" + +@implementation WCWireV8Binary { + id _support; +} + +- (instancetype)initWithSupport:(id)support { + self = [super init]; + if (self) { + _support = support; + } + return self; +} + +- (NSData *)encodeMessageQueue:(NSArray *)messageQueue numOfMessages:(int)count { + int64_t offset = -1; + while (YES) { + NSMutableData *buffer = [NSMutableData data]; + [self appendString:[NSString stringWithFormat:@"count=%d&", count] toData:buffer]; + if (offset == -1) { + if (count > 0) { + offset = messageQueue[0].mapID; + [self appendString:[NSString stringWithFormat:@"ofs=%lld", offset] toData:buffer]; + } else { + offset = 0; + } + } else { + [self appendString:[NSString stringWithFormat:@"ofs=%lld", offset] toData:buffer]; + } + [self appendString:@"\r\n" toData:buffer]; + + BOOL done = YES; + for (int i = 0; i < count; i++) { + int64_t mapID = messageQueue[i].mapID; + mapID -= offset; + if (mapID < 0) { + offset = MAX(0, messageQueue[i].mapID - 100); + done = NO; + continue; + } + [self encodeMessage:messageQueue[i] relativeMapID:mapID toData:buffer]; + } + + if (done) { + return buffer; + } + } +} + +- (void)encodeMessage:(WCQueuedMap *)message + relativeMapID:(int64_t)relativeMapID + toData:(NSMutableData *)buffer { + [self appendString:[NSString stringWithFormat:@"id=%lld&size=%d\r\n", relativeMapID, + message.rawDataSize] + toData:buffer]; + id data = message.map[@"__data__"]; + if ([data isKindOfClass:[NSString class]]) { + [self appendString:(NSString *)data toData:buffer]; + } else if ([data isKindOfClass:[NSData class]]) { + [buffer appendData:(NSData *)data]; + } +} + +- (void)appendString:(NSString *)string toData:(NSMutableData *)data { + [data appendData:[string dataUsingEncoding:NSUTF8StringEncoding]]; +} + +@end