blob: 2492c934cf0f0f63e1dc1aeab7b9559961cd3ec5 [file] [edit]
/*
* 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