| /* | |
| * Copyright 2026 Rive | |
| */ | |
| #ifdef WITH_RIVE_SCRIPTNET | |
| #include "rive/scriptnet/net.hpp" | |
| #import <Foundation/Foundation.h> | |
| #include <mutex> | |
| #include <string> | |
| #include <unordered_map> | |
| #include <vector> | |
| // Script requests on Apple platforms. The session is ephemeral and stripped of | |
| // every source of ambient authority: no cookie jar, no credential store, no | |
| // cache, and authentication challenges other than server trust are refused so | |
| // neither keychain identities nor Kerberos/NTLM single sign-on answer for a | |
| // script. Redirect hops are re-checked against NetPolicy, which a browser | |
| // cannot offer. | |
| using namespace rive; | |
| using namespace rive::scriptnet; | |
| namespace | |
| { | |
| std::string toString(NSString* value) | |
| { | |
| const char* utf8 = value.UTF8String; | |
| return utf8 != nullptr ? std::string(utf8) : std::string(); | |
| } | |
| // HEAD answers and null body statuses carry no body, whatever their | |
| // Content-Length says about the resource. | |
| bool expectsBody(NSURLSessionTask* task, NSURLResponse* response) | |
| { | |
| if ([task.currentRequest.HTTPMethod isEqualToString:@"HEAD"]) | |
| { | |
| return false; | |
| } | |
| if (![response isKindOfClass:[NSHTTPURLResponse class]]) | |
| { | |
| return true; | |
| } | |
| switch (((NSHTTPURLResponse*)response).statusCode) | |
| { | |
| case 101: | |
| case 103: | |
| case 204: | |
| case 205: | |
| case 304: | |
| return false; | |
| } | |
| return true; | |
| } | |
| struct TaskState | |
| { | |
| RequestId id = 0; | |
| NSURLSessionTask* task = nil; | |
| uint32_t maxBodyBytes = 0; | |
| std::vector<uint8_t> body; | |
| // Set when this file stops the task itself (policy, size, timeout); it is | |
| // reported instead of the cancellation it causes. | |
| bool failed = false; | |
| NetError error; | |
| }; | |
| } // namespace | |
| @interface RiveScriptNetDelegate : NSObject <NSURLSessionDataDelegate> | |
| - (void)track:(NSURLSessionTask*)task | |
| id:(RequestId)requestId | |
| maxBodyBytes:(uint32_t)maxBodyBytes; | |
| - (void)cancelRequest:(RequestId)requestId; | |
| - (void)stopTask:(NSUInteger)taskIdentifier error:(NetError)error; | |
| @end | |
| @implementation RiveScriptNetDelegate | |
| { | |
| std::mutex _mutex; | |
| std::unordered_map<NSUInteger, TaskState> _tasks; | |
| std::unordered_map<RequestId, NSUInteger> _taskIds; | |
| } | |
| - (void)track:(NSURLSessionTask*)task | |
| id:(RequestId)requestId | |
| maxBodyBytes:(uint32_t)maxBodyBytes | |
| { | |
| std::lock_guard<std::mutex> lock(_mutex); | |
| TaskState& state = _tasks[task.taskIdentifier]; | |
| state.id = requestId; | |
| state.task = task; | |
| state.maxBodyBytes = maxBodyBytes; | |
| _taskIds[requestId] = task.taskIdentifier; | |
| } | |
| - (void)cancelRequest:(RequestId)requestId | |
| { | |
| NSURLSessionTask* task = nil; | |
| { | |
| std::lock_guard<std::mutex> lock(_mutex); | |
| auto it = _taskIds.find(requestId); | |
| if (it == _taskIds.end()) | |
| { | |
| return; | |
| } | |
| auto state = _tasks.find(it->second); | |
| if (state != _tasks.end()) | |
| { | |
| task = state->second.task; | |
| } | |
| } | |
| [task cancel]; | |
| } | |
| // Records why the task is being stopped, then cancels it; didComplete | |
| // reports the recorded error. | |
| - (void)stopTask:(NSUInteger)taskIdentifier error:(NetError)error | |
| { | |
| NSURLSessionTask* task = nil; | |
| { | |
| std::lock_guard<std::mutex> lock(_mutex); | |
| auto it = _tasks.find(taskIdentifier); | |
| if (it == _tasks.end() || it->second.failed) | |
| { | |
| return; | |
| } | |
| it->second.failed = true; | |
| it->second.error = std::move(error); | |
| task = it->second.task; | |
| } | |
| [task cancel]; | |
| } | |
| - (void)answerChallenge:(NSURLAuthenticationChallenge*)challenge | |
| completionHandler: | |
| (void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential*)) | |
| completionHandler | |
| { | |
| if ([challenge.protectionSpace.authenticationMethod | |
| isEqualToString:NSURLAuthenticationMethodServerTrust]) | |
| { | |
| completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil); | |
| return; | |
| } | |
| // Basic/Digest/NTLM/Negotiate/client certificates: answer with nothing, | |
| // so the script sees the server's 401 instead of the user's identity. | |
| completionHandler(NSURLSessionAuthChallengeRejectProtectionSpace, nil); | |
| } | |
| - (void)URLSession:(NSURLSession*)session | |
| didReceiveChallenge:(NSURLAuthenticationChallenge*)challenge | |
| completionHandler: | |
| (void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential*)) | |
| completionHandler | |
| { | |
| [self answerChallenge:challenge completionHandler:completionHandler]; | |
| } | |
| - (void)URLSession:(NSURLSession*)session | |
| task:(NSURLSessionTask*)task | |
| didReceiveChallenge:(NSURLAuthenticationChallenge*)challenge | |
| completionHandler: | |
| (void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential*)) | |
| completionHandler | |
| { | |
| [self answerChallenge:challenge completionHandler:completionHandler]; | |
| } | |
| - (void)URLSession:(NSURLSession*)session | |
| task:(NSURLSessionTask*)task | |
| willPerformHTTPRedirection:(NSHTTPURLResponse*)response | |
| newRequest:(NSURLRequest*)request | |
| completionHandler:(void (^)(NSURLRequest*))completionHandler | |
| { | |
| HttpRequest hop; | |
| hop.url = toString(request.URL.absoluteString); | |
| hop.method = toString(request.HTTPMethod); | |
| NetError error; | |
| if (NetPolicy::check(hop, scriptnet::limits(), &error)) | |
| { | |
| completionHandler(request); | |
| return; | |
| } | |
| completionHandler(nil); | |
| [self stopTask:task.taskIdentifier | |
| error:NetError{ | |
| NetErrorCode::Policy, | |
| "a redirect was refused: " + error.message}]; | |
| } | |
| - (void)URLSession:(NSURLSession*)session | |
| dataTask:(NSURLSessionDataTask*)dataTask | |
| didReceiveResponse:(NSURLResponse*)response | |
| completionHandler: | |
| (void (^)(NSURLSessionResponseDisposition))completionHandler | |
| { | |
| // Without a body to expect, the length is no reason to stop or reserve; | |
| // didReceiveData still counts what does arrive. | |
| long long expected = expectsBody(dataTask, response) | |
| ? response.expectedContentLength | |
| : NSURLResponseUnknownLength; | |
| uint32_t maxBodyBytes = 0; | |
| { | |
| std::lock_guard<std::mutex> lock(_mutex); | |
| auto it = _tasks.find(dataTask.taskIdentifier); | |
| if (it != _tasks.end()) | |
| { | |
| maxBodyBytes = it->second.maxBodyBytes; | |
| if (expected > 0 && expected <= maxBodyBytes) | |
| { | |
| it->second.body.reserve((size_t)expected); | |
| } | |
| } | |
| } | |
| if (expected != NSURLResponseUnknownLength && expected > maxBodyBytes) | |
| { | |
| completionHandler(NSURLSessionResponseCancel); | |
| [self stopTask:dataTask.taskIdentifier | |
| error:NetError{ | |
| NetErrorCode::TooLarge, | |
| "the response body is larger than " + | |
| std::to_string(maxBodyBytes) + " bytes"}]; | |
| return; | |
| } | |
| completionHandler(NSURLSessionResponseAllow); | |
| } | |
| - (void)URLSession:(NSURLSession*)session | |
| dataTask:(NSURLSessionDataTask*)dataTask | |
| didReceiveData:(NSData*)data | |
| { | |
| bool tooLarge = false; | |
| uint32_t maxBodyBytes = 0; | |
| { | |
| std::lock_guard<std::mutex> lock(_mutex); | |
| auto it = _tasks.find(dataTask.taskIdentifier); | |
| if (it == _tasks.end() || it->second.failed) | |
| { | |
| return; | |
| } | |
| TaskState& state = it->second; | |
| maxBodyBytes = state.maxBodyBytes; | |
| if (state.body.size() + data.length > maxBodyBytes) | |
| { | |
| tooLarge = true; | |
| } | |
| else | |
| { | |
| std::vector<uint8_t>* body = &state.body; | |
| [data enumerateByteRangesUsingBlock:^( | |
| const void* bytes, NSRange range, BOOL* stop) { | |
| const uint8_t* begin = static_cast<const uint8_t*>(bytes); | |
| body->insert(body->end(), begin, begin + range.length); | |
| }]; | |
| } | |
| } | |
| if (tooLarge) | |
| { | |
| [self stopTask:dataTask.taskIdentifier | |
| error:NetError{ | |
| NetErrorCode::TooLarge, | |
| "the response body is larger than " + | |
| std::to_string(maxBodyBytes) + " bytes"}]; | |
| } | |
| } | |
| - (void)URLSession:(NSURLSession*)session | |
| task:(NSURLSessionTask*)task | |
| didCompleteWithError:(NSError*)error | |
| { | |
| TaskState state; | |
| { | |
| std::lock_guard<std::mutex> lock(_mutex); | |
| auto it = _tasks.find(task.taskIdentifier); | |
| if (it == _tasks.end()) | |
| { | |
| return; | |
| } | |
| state = std::move(it->second); | |
| _tasks.erase(it); | |
| _taskIds.erase(state.id); | |
| } | |
| if (state.failed) | |
| { | |
| scriptnet::fail(state.id, std::move(state.error)); | |
| return; | |
| } | |
| if (error != nil) | |
| { | |
| NetErrorCode code = NetErrorCode::Network; | |
| if ([error.domain isEqualToString:NSURLErrorDomain]) | |
| { | |
| if (error.code == NSURLErrorTimedOut) | |
| { | |
| code = NetErrorCode::Timeout; | |
| } | |
| else if (error.code == NSURLErrorCancelled) | |
| { | |
| code = NetErrorCode::Aborted; | |
| } | |
| } | |
| std::string message = toString(error.localizedDescription); | |
| scriptnet::fail( | |
| state.id, {code, message.empty() ? "request failed" : message}); | |
| return; | |
| } | |
| NSHTTPURLResponse* http = [task.response | |
| isKindOfClass:[NSHTTPURLResponse class]] | |
| ? (NSHTTPURLResponse*)task.response | |
| : nil; | |
| if (http == nil) | |
| { | |
| scriptnet::fail( | |
| state.id, {NetErrorCode::Network, "no HTTP response arrived"}); | |
| return; | |
| } | |
| HttpResponse response; | |
| response.status = (uint16_t)http.statusCode; | |
| // NSURLSession hides the server's reason phrase. | |
| response.statusText = standardReasonPhrase(response.status); | |
| response.url = toString(http.URL.absoluteString); | |
| HttpHeaders* headers = &response.headers; | |
| [http.allHeaderFields | |
| enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL* stop) { | |
| if ([key isKindOfClass:[NSString class]] && | |
| [value isKindOfClass:[NSString class]]) | |
| { | |
| headers->push_back({toString(key), toString(value)}); | |
| } | |
| }]; | |
| response.body = std::move(state.body); | |
| scriptnet::complete(state.id, std::move(response)); | |
| } | |
| @end | |
| namespace | |
| { | |
| class AppleProvider : public Provider | |
| { | |
| public: | |
| AppleProvider() | |
| { | |
| NSURLSessionConfiguration* configuration = [NSURLSessionConfiguration | |
| ephemeralSessionConfiguration]; | |
| configuration.HTTPCookieAcceptPolicy = NSHTTPCookieAcceptPolicyNever; | |
| configuration.HTTPShouldSetCookies = NO; | |
| configuration.HTTPCookieStorage = nil; | |
| configuration.URLCredentialStorage = nil; | |
| configuration.URLCache = nil; | |
| configuration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; | |
| NSOperationQueue* queue = [[NSOperationQueue alloc] init]; | |
| queue.maxConcurrentOperationCount = 1; | |
| queue.name = @"app.rive.scriptnet"; | |
| m_delegate = [[RiveScriptNetDelegate alloc] init]; | |
| m_session = [NSURLSession sessionWithConfiguration:configuration | |
| delegate:m_delegate | |
| delegateQueue:queue]; | |
| } | |
| // The session retains its delegate until invalidated. | |
| ~AppleProvider() override { [m_session invalidateAndCancel]; } | |
| void start(RequestId id, const HttpRequest& request) override | |
| { | |
| @autoreleasepool | |
| { | |
| NSString* urlString = [NSString stringWithUTF8String:request.url.c_str()]; | |
| NSURL* url = urlString != nil ? [NSURL URLWithString:urlString] : nil; | |
| if (url == nil) | |
| { | |
| fail(id, {NetErrorCode::Policy, "the URL could not be parsed"}); | |
| return; | |
| } | |
| NSMutableURLRequest* urlRequest = [NSMutableURLRequest | |
| requestWithURL:url]; | |
| urlRequest.HTTPMethod = [NSString | |
| stringWithUTF8String:request.method.c_str()]; | |
| urlRequest.HTTPShouldHandleCookies = NO; | |
| urlRequest.timeoutInterval = request.timeoutMs / 1000.0; | |
| for (const HttpHeader& header : request.headers) | |
| { | |
| NSString* name = [NSString stringWithUTF8String:header.name.c_str()]; | |
| NSString* value = [NSString stringWithUTF8String:header.value.c_str()]; | |
| if (name != nil && value != nil) | |
| { | |
| [urlRequest addValue:value forHTTPHeaderField:name]; | |
| } | |
| } | |
| if (!request.body.empty()) | |
| { | |
| urlRequest.HTTPBody = [NSData dataWithBytes:request.body.data() | |
| length:request.body.size()]; | |
| } | |
| NSURLSessionDataTask* task = [m_session dataTaskWithRequest:urlRequest]; | |
| [m_delegate track:task | |
| id:id | |
| maxBodyBytes:scriptnet::limits().maxResponseBodyBytes]; | |
| // Not yet done (see NetPolicy): resolving the host on a background | |
| // queue before this resume, and before each redirect, to refuse | |
| // local and private addresses through NetPolicy::checkAddress, as | |
| // the Windows transport does. | |
| // The request's timeoutInterval is an idle timeout; the script's | |
| // timeout covers the whole exchange. | |
| RiveScriptNetDelegate* delegate = m_delegate; | |
| NSUInteger taskIdentifier = task.taskIdentifier; | |
| dispatch_after( | |
| dispatch_time( | |
| DISPATCH_TIME_NOW, (int64_t)(request.timeoutMs * NSEC_PER_MSEC)), | |
| dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), | |
| ^{ | |
| [delegate stopTask:taskIdentifier | |
| error:NetError{ | |
| NetErrorCode::Timeout, | |
| "no response before the timeout"}]; | |
| }); | |
| [task resume]; | |
| } | |
| } | |
| void abort(RequestId id) override { [m_delegate cancelRequest:id]; } | |
| private: | |
| NSURLSession* m_session = nil; | |
| RiveScriptNetDelegate* m_delegate = nil; | |
| }; | |
| } // namespace | |
| rcp<Provider> rive::scriptnet::makePlatformProvider() | |
| { | |
| return make_rcp<AppleProvider>(); | |
| } | |
| #endif // WITH_RIVE_SCRIPTNET |