Files
Attractor/App/Web Process Bundle Bridge/SBRProcessBundleBridge.m

429 lines
15 KiB
Objective-C

//
// SBRProcessBundleBridge.m
// SBrowser
//
// Created by James Magahern on 7/22/20.
//
#import "SBRProcessBundleBridge.h"
#import "SBRScriptPolicy.h"
#import "Hacks.h"
#import <OSLog/OSLog.h>
#import <stdatomic.h>
#import <WebKit/_WKRemoteObjectInterface.h>
#import <WebKit/_WKRemoteObjectRegistry.h>
#import <WebKit/_WKProcessPoolConfiguration.h>
#import <WebKit/WKProcessPoolPrivate.h>
#import <WebKit/WKWebViewPrivate.h>
#import <WebKit/WKWebViewConfigurationPrivate.h>
#import <WebKit/WKContentRuleListStore.h>
#define WKUserStyleSheet id
#define WKUserStyleSheetEncodedClassName "X1dLVXNlclN0eWxlU2hlZXQ="
NSArray<NSString *> *CommonCDNList(void) {
static dispatch_once_t onceToken;
static NSArray<NSString *> *commonCDNList = nil;
dispatch_once(&onceToken, ^{
commonCDNList = @[
@"cdn.jsdelivr.net",
@"fsdn.net",
@"cdnjs.com",
@"osscdn.com",
@"code.jquery.com",
@"bootstrapcdn.com"
];
});
return commonCDNList;
}
@interface StyleSheet : NSObject <NSCopying>
@property (nonatomic, readonly, copy) NSString *source;
@property (nonatomic, readonly, copy) NSURL *baseURL;
@property (nonatomic, readonly, getter=isForMainFrameOnly) BOOL forMainFrameOnly;
- (instancetype)initWithSource:(NSString *)source forMainFrameOnly:(BOOL)forMainFrameOnly;
@end
// Note: We no longer proxy http/https via WKURLSchemeHandler; we use
// WebKit content blocking rules instead.
@interface WKUserContentController (Private)
- (void)__addUserStyleSheet:(WKUserStyleSheet)userStyleSheet;
- (void)__removeUserStyleSheet:(WKUserStyleSheet)userStyleSheet;
- (void)__addUserScriptImmediately:(WKUserScript *)userScript;
@end
@implementation WKUserContentController (Private)
- (void)__addUserStyleSheet:(id)userStyleSheet
{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[self performSelector:DecodedSelector("X2FkZFVzZXJTdHlsZVNoZWV0Og==") withObject:userStyleSheet];
#pragma clang diagnostic pop
}
- (void)__removeUserStyleSheet:(id)userStyleSheet
{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[self performSelector:DecodedSelector("X3JlbW92ZVVzZXJTdHlsZVNoZWV0Og==") withObject:userStyleSheet];
#pragma clang diagnostic pop
}
- (void)__addUserScriptImmediately:(WKUserScript *)userScript
{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[self performSelector:DecodedSelector("X2FkZFVzZXJTY3JpcHRJbW1lZGlhdGVseTo=") withObject:userScript];
#pragma clang diagnostic pop
}
@end
#define LOG_DEBUG(format, ...) os_log_debug(_log, format, ##__VA_ARGS__)
#define LOG_ERROR(format, ...) os_log_error(_log, format, ##__VA_ARGS__)
static os_log_t _log;
__attribute__((constructor))
static void initialize_log(void) {
_log = os_log_create("net.buzzert.attractor.webview", "bridge");
}
@interface NSURLResponse (BridgeAdditions)
@property (nonatomic, readonly) BOOL isJavascriptResponse;
@end
@implementation NSURLResponse (BridgeAdditions)
- (BOOL)isJavascriptResponse
{
NSString *extension = [[self URL] pathExtension];
if ([[extension lowercaseString] isEqualToString:@"js"]) {
return YES;
}
NSString *MIMEType = [self MIMEType];
if ([[MIMEType lowercaseString] containsString:@"javascript"]) {
return YES;
}
return NO;
}
@end
@interface SBRProcessBundleBridge ()
@end
@implementation SBRProcessBundleBridge {
WKWebView *_webView;
WKWebViewConfiguration *_webViewConfiguration;
WKProcessPool *_processPool;
WKUserStyleSheet _darkModeStyleSheet;
WKUserScript *_readabilityScript;
NSArray<WKUserScript *> *_userScripts;
// These come from settings.
WKUserStyleSheet _customizedUserStylesheet;
WKUserScript *_customizedUserScript;
// Content blocking
WKContentRuleList *_activeScriptRuleList;
void *_urlKVOContext;
}
- (void)tearDown
{
if (_webView) {
@try { [_webView removeObserver:self forKeyPath:@"URL" context:_urlKVOContext]; }
@catch (__unused NSException *ex) {}
}
}
- (instancetype)initWithWebViewConfiguration:(WKWebViewConfiguration *)webViewConfiguration
{
self = [super init];
if (self) {
if (!webViewConfiguration) {
webViewConfiguration = [[WKWebViewConfiguration alloc] init];
// Set up process pool
_processPool = [[WKProcessPool alloc] init];
webViewConfiguration.processPool = _processPool;
webViewConfiguration._waitsForPaintAfterViewDidMoveToWindow = NO;
webViewConfiguration._applePayEnabled = YES;
// No http/https interception — rely on content blocking rules instead.
}
_webViewConfiguration = webViewConfiguration;
// User scripts
WKUserContentController *userContentController = [_webViewConfiguration userContentController];
for (WKUserScript *script in [self _userScripts]) {
[userContentController addUserScript:script];
}
// Reload customized user scripts/stylesheets from settings
[self reloadCustomizedUserScriptsAndStylesheets];
// Instantiate web view
WKWebView *webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:webViewConfiguration];
if (@available(iOS 16.0, *)) {
webView.findInteractionEnabled = YES;
}
_webView = webView;
_urlKVOContext = &_urlKVOContext; // unique context pointer
[_webView addObserver:self forKeyPath:@"URL" options:(NSKeyValueObservingOptionNew) context:_urlKVOContext];
// Initialize content blocking rules for current host
[self rebuildContentBlockingRulesForCurrentHost];
}
return self;
}
- (WKUserScript *)_loadScriptForResource:(NSString *)resourceName withExtension:(NSString *)extension
{
NSURL *url = [[NSBundle mainBundle] URLForResource:resourceName withExtension:extension];
NSString *source = [NSString stringWithContentsOfURL:url encoding:NSUTF8StringEncoding error:nil];
return [[WKUserScript alloc] initWithSource:source injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
}
- (NSArray<WKUserScript *> *)_userScripts
{
if (!_userScripts) {
_userScripts = @[
[self _loadScriptForResource:@"Tagger" withExtension:@"js"],
];
}
return _userScripts;
}
- (void)reloadCustomizedUserScriptsAndStylesheets
{
WKUserContentController *userContentController = [_webViewConfiguration userContentController];
[userContentController removeAllUserScripts];
NSString *scriptSource = [[NSUserDefaults standardUserDefaults] stringForKey:@"userScript"];
if ([scriptSource length]) {
_customizedUserScript = [[WKUserScript alloc] initWithSource:scriptSource injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
[userContentController addUserScript:_customizedUserScript];
}
if (_customizedUserStylesheet) {
[userContentController __removeUserStyleSheet:_customizedUserStylesheet];
}
NSString *stylesheetSource = [[NSUserDefaults standardUserDefaults] stringForKey:@"userStylesheet"];
if ([stylesheetSource length]) {
_customizedUserStylesheet = [[DecodedClass(WKUserStyleSheetEncodedClassName) alloc] initWithSource:stylesheetSource forMainFrameOnly:YES];
[userContentController __addUserStyleSheet:_customizedUserStylesheet];
}
}
#pragma mark Former <SBRWebProcessDelegate> methods
- (void)webProcessDidAllowScriptWithOrigin:(NSString *)origin
{
dispatch_async(dispatch_get_main_queue(), ^{
[[self delegate] webProcess:self didAllowScriptResourceFromOrigin:origin];
});
}
- (void)webProcessDidBlockScriptWithOrigin:(NSString *)origin
{
dispatch_async(dispatch_get_main_queue(), ^{
[[self delegate] webProcess:self didBlockScriptResourceFromOrigin:origin];
});
}
#pragma mark Actions
- (void)policyDataSourceDidChange
{
// Rebuild content blocking rules when policy changes.
[self rebuildContentBlockingRulesForCurrentHost];
}
- (void)setAllowAllScripts:(BOOL)allowAllScripts
{
_allowAllScripts = allowAllScripts;
[self rebuildContentBlockingRulesForCurrentHost];
}
- (void)setDarkModeEnabled:(BOOL)darkModeEnabled
{
_darkModeEnabled = darkModeEnabled;
WKUserContentController *userContentController = [_webViewConfiguration userContentController];
if (darkModeEnabled) {
if (!_darkModeStyleSheet) {
NSURL *styleSheetURL = [[NSBundle mainBundle] URLForResource:@"darkmode" withExtension:@"css"];
NSString *styleSheetSource = [NSString stringWithContentsOfURL:styleSheetURL encoding:NSUTF8StringEncoding error:nil];
_darkModeStyleSheet = [[DecodedClass(WKUserStyleSheetEncodedClassName) alloc] initWithSource:styleSheetSource forMainFrameOnly:NO];
}
[userContentController __addUserStyleSheet:_darkModeStyleSheet];
} else if (_darkModeStyleSheet) {
[userContentController __removeUserStyleSheet:_darkModeStyleSheet];
}
}
- (void)parseDocumentForReaderMode:(void (^)(NSString * _Nonnull))completionBlock
{
WKUserContentController *userContentController = [_webViewConfiguration userContentController];
if (!_readabilityScript) {
_readabilityScript = [self _loadScriptForResource:@"Readability" withExtension:@"js"];
}
[userContentController __addUserScriptImmediately:_readabilityScript];
NSString *script = @""
"var documentClone = document.cloneNode(true);"
"var article = new Readability(documentClone).parse();"
"article.content";
os_log_t log = _log;
[_webView evaluateJavaScript:script completionHandler:^(NSString *result, NSError * _Nullable error) {
if (error != nil) {
os_log_error(log, "Bridge: Readability error: %@", error.localizedDescription);
} else {
completionBlock(result);
}
}];
}
// MARK: - Content Blocking Rules
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
if (context == _urlKVOContext && [keyPath isEqualToString:@"URL"]) {
[self rebuildContentBlockingRulesForCurrentHost];
return;
}
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
- (void)rebuildContentBlockingRulesForCurrentHost
{
NSString *hostOrigin = _webView.URL.host;
if (!hostOrigin) {
// No page loaded yet; clear any existing rules
[self applyContentRuleListJSON:nil withName:nil];
return;
}
// Determine policy for this host
NSNumber *policyType = nil;
@synchronized (_policyDataSource) {
NSDictionary<NSString *, NSNumber *> *policyTypes = [_policyDataSource scriptPolicyTypeByOrigin];
policyType = [policyTypes objectForKey:hostOrigin] ?: @(0);
}
// Alpha=0 Bravo=1 Charlie=2 Delta=3 Echo=4
SBRScriptOriginPolicyType type = [policyType integerValue];
if (_allowAllScripts || type >= SBRScriptOriginPolicyTypeEcho) {
// Echo or shields down: no blocking
[self applyContentRuleListJSON:nil withName:nil];
return;
}
// Base trigger applies only to this page's domain
NSMutableArray *rules = [NSMutableArray array];
NSDictionary *baseScriptTrigger = @{
@"resource-type" : @[ @"script" ],
@"if-domain" : @[ hostOrigin ]
};
if (type <= SBRScriptOriginPolicyTypeBravo) {
// Alpha or Bravo: block all external script subresources (inline JS is controlled via preferences elsewhere)
[rules addObject:@{ @"trigger": baseScriptTrigger, @"action": @{ @"type": @"block" } }];
} else {
// Charlie/Delta: block third-party scripts
NSMutableDictionary *trigger = [baseScriptTrigger mutableCopy];
trigger[@"load-type"] = @[ @"third-party" ];
[rules addObject:@{ @"trigger": trigger, @"action": @{ @"type": @"block" } }];
if (type >= SBRScriptOriginPolicyTypeDelta) {
// Delta: add allowlist for common CDNs
for (NSString *cdn in CommonCDNList()) {
NSString *escaped = [NSRegularExpression escapedPatternForString:cdn];
NSString *pattern = [NSString stringWithFormat:@".*://([^.]*\\.)?%@/", escaped];
[rules addObject:@{
@"trigger": @{
@"url-filter": pattern,
@"if-domain": @[ hostOrigin ],
@"resource-type": @[ @"script" ]
},
@"action": @{ @"type": @"ignore-previous-rules" }
}];
}
// Heuristic: allow cdn.<anything-with-family-name>/* where family name is the second-level label
NSArray<NSString *> *components = [hostOrigin componentsSeparatedByString:@"."];
if (components.count > 1) {
NSString *family = components[components.count - 2];
NSString *escapedFamily = [NSRegularExpression escapedPatternForString:family];
NSString *familyPattern = [NSString stringWithFormat:@".*://cdn\\.[^/]*%@[^/]*/", escapedFamily];
[rules addObject:@{
@"trigger": @{
@"url-filter": familyPattern,
@"if-domain": @[ hostOrigin ],
@"resource-type": @[ @"script" ]
},
@"action": @{ @"type": @"ignore-previous-rules" }
}];
}
}
}
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:rules options:0 error:nil];
NSString *json = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
NSString *name = [NSString stringWithFormat:@"net.buzzert.attractor.rules.%@", hostOrigin];
[self applyContentRuleListJSON:json withName:name];
}
- (void)applyContentRuleListJSON:(NSString *)json withName:(NSString *)name
{
WKUserContentController *controller = [_webViewConfiguration userContentController];
if (_activeScriptRuleList) {
[controller removeContentRuleList:_activeScriptRuleList];
_activeScriptRuleList = nil;
}
if (!json || !name) { return; }
WKContentRuleListStore *store = [WKContentRuleListStore defaultStore];
[store compileContentRuleListForIdentifier:name encodedContentRuleList:json completionHandler:^(WKContentRuleList * _Nullable ruleList, NSError * _Nullable error) {
if (error) {
LOG_ERROR("Failed to compile content rule list: %@", error.localizedDescription);
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
self->_activeScriptRuleList = ruleList;
[controller addContentRuleList:ruleList];
LOG_DEBUG("Applied content rule list: %@", name);
});
}];
}
@end