diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..945ae75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# OS X +.DS_Store + +## Xcode user settings +xcuserdata/ +*.xcuserstate +*.xcscmblueprint +*.xccheckout +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Build output +_Project/build/ +build/ +_Project/DerivedData/ +DerivedData/ +.clang-module-cache/ +.derived/ +.derived-device/ + +## Apple toolchain artefacts +*.hmap +*.ipa +*.dSYM.zip +*.dSYM +*.xcresult +*.xctestrun +.swiftpm/ +.build/ + +## Local editor settings +.vscode/ +.idea/ + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +_Project/Availability.h diff --git a/Browser.xcodeproj/project.pbxproj b/Browser.xcodeproj/project.pbxproj deleted file mode 100644 index beece6a..0000000 --- a/Browser.xcodeproj/project.pbxproj +++ /dev/null @@ -1,306 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 46; - objects = { - -/* Begin PBXBuildFile section */ - B002B8671BAE420500C744AF /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = B002B8661BAE420500C744AF /* main.m */; }; - B002B86A1BAE420500C744AF /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = B002B8691BAE420500C744AF /* AppDelegate.m */; }; - B002B86D1BAE420500C744AF /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = B002B86C1BAE420500C744AF /* ViewController.m */; }; - B002B8701BAE420500C744AF /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B002B86E1BAE420500C744AF /* Main.storyboard */; }; - B002B8721BAE420500C744AF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B002B8711BAE420500C744AF /* Assets.xcassets */; }; - B0F6B4621BAEBF9900E2F26B /* README.mdown in Sources */ = {isa = PBXBuildFile; fileRef = B0F6B4611BAEBF9900E2F26B /* README.mdown */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - B002B8621BAE420500C744AF /* Browser.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Browser.app; sourceTree = BUILT_PRODUCTS_DIR; }; - B002B8661BAE420500C744AF /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; - B002B8681BAE420500C744AF /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; - B002B8691BAE420500C744AF /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; - B002B86B1BAE420500C744AF /* ViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ViewController.h; sourceTree = ""; }; - B002B86C1BAE420500C744AF /* ViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ViewController.m; sourceTree = ""; }; - B002B86F1BAE420500C744AF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - B002B8711BAE420500C744AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - B002B8731BAE420500C744AF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B0F6B4611BAEBF9900E2F26B /* README.mdown */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.mdown; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - B002B85F1BAE420500C744AF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - B002B8591BAE420500C744AF = { - isa = PBXGroup; - children = ( - B0F6B4611BAEBF9900E2F26B /* README.mdown */, - B002B8641BAE420500C744AF /* Browser */, - B002B8631BAE420500C744AF /* Products */, - ); - sourceTree = ""; - }; - B002B8631BAE420500C744AF /* Products */ = { - isa = PBXGroup; - children = ( - B002B8621BAE420500C744AF /* Browser.app */, - ); - name = Products; - sourceTree = ""; - }; - B002B8641BAE420500C744AF /* Browser */ = { - isa = PBXGroup; - children = ( - B002B8681BAE420500C744AF /* AppDelegate.h */, - B002B8691BAE420500C744AF /* AppDelegate.m */, - B002B86B1BAE420500C744AF /* ViewController.h */, - B002B86C1BAE420500C744AF /* ViewController.m */, - B002B8651BAE420500C744AF /* Supporting Files */, - ); - path = Browser; - sourceTree = ""; - }; - B002B8651BAE420500C744AF /* Supporting Files */ = { - isa = PBXGroup; - children = ( - B002B8661BAE420500C744AF /* main.m */, - B002B86E1BAE420500C744AF /* Main.storyboard */, - B002B8711BAE420500C744AF /* Assets.xcassets */, - B002B8731BAE420500C744AF /* Info.plist */, - ); - name = "Supporting Files"; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - B002B8611BAE420500C744AF /* Browser */ = { - isa = PBXNativeTarget; - buildConfigurationList = B002B8761BAE420500C744AF /* Build configuration list for PBXNativeTarget "Browser" */; - buildPhases = ( - B002B85E1BAE420500C744AF /* Sources */, - B002B85F1BAE420500C744AF /* Frameworks */, - B002B8601BAE420500C744AF /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Browser; - productName = Browser; - productReference = B002B8621BAE420500C744AF /* Browser.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - B002B85A1BAE420500C744AF /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 0710; - ORGANIZATIONNAME = "High Caffeine Content"; - TargetAttributes = { - B002B8611BAE420500C744AF = { - CreatedOnToolsVersion = 7.1; - }; - }; - }; - buildConfigurationList = B002B85D1BAE420500C744AF /* Build configuration list for PBXProject "Browser" */; - compatibilityVersion = "Xcode 3.2"; - developmentRegion = English; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = B002B8591BAE420500C744AF; - productRefGroup = B002B8631BAE420500C744AF /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - B002B8611BAE420500C744AF /* Browser */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - B002B8601BAE420500C744AF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B002B8721BAE420500C744AF /* Assets.xcassets in Resources */, - B002B8701BAE420500C744AF /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - B002B85E1BAE420500C744AF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B002B86D1BAE420500C744AF /* ViewController.m in Sources */, - B002B86A1BAE420500C744AF /* AppDelegate.m in Sources */, - B0F6B4621BAEBF9900E2F26B /* README.mdown in Sources */, - B002B8671BAE420500C744AF /* main.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - B002B86E1BAE420500C744AF /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - B002B86F1BAE420500C744AF /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - B002B8741BAE420500C744AF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = appletvos; - TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 9.0; - }; - name = Debug; - }; - B002B8751BAE420500C744AF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = appletvos; - TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 9.0; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - B002B8771BAE420500C744AF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; - ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; - CODE_SIGN_IDENTITY = "iPhone Developer"; - FRAMEWORK_SEARCH_PATHS = "/System/Library/PrivateFrameworks/**"; - INFOPLIST_FILE = Browser/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.highcaffeinecontent.Browser; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - B002B8781BAE420500C744AF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; - ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; - CODE_SIGN_IDENTITY = "iPhone Developer"; - FRAMEWORK_SEARCH_PATHS = "/System/Library/PrivateFrameworks/**"; - INFOPLIST_FILE = Browser/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.highcaffeinecontent.Browser; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - B002B85D1BAE420500C744AF /* Build configuration list for PBXProject "Browser" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B002B8741BAE420500C744AF /* Debug */, - B002B8751BAE420500C744AF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - B002B8761BAE420500C744AF /* Build configuration list for PBXNativeTarget "Browser" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B002B8771BAE420500C744AF /* Debug */, - B002B8781BAE420500C744AF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = B002B85A1BAE420500C744AF /* Project object */; -} diff --git a/Browser.xcodeproj/project.xcworkspace/xcuserdata/steven.xcuserdatad/UserInterfaceState.xcuserstate b/Browser.xcodeproj/project.xcworkspace/xcuserdata/steven.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 08e5935..0000000 Binary files a/Browser.xcodeproj/project.xcworkspace/xcuserdata/steven.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json deleted file mode 100644 index 0564959..0000000 --- a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "idiom" : "tv", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Browser/Assets.xcassets/Cursor.imageset/Cursor.png b/Browser/Assets.xcassets/Cursor.imageset/Cursor.png deleted file mode 100644 index 1f7c8b1..0000000 Binary files a/Browser/Assets.xcassets/Cursor.imageset/Cursor.png and /dev/null differ diff --git a/Browser/Base.lproj/Main.storyboard b/Browser/Base.lproj/Main.storyboard deleted file mode 100644 index 3e6780b..0000000 --- a/Browser/Base.lproj/Main.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Browser/ViewController.h b/Browser/ViewController.h deleted file mode 100644 index f29354a..0000000 --- a/Browser/ViewController.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// ViewController.h -// Browser -// -// Created by Steven Troughton-Smith on 20/09/2015. -// Copyright © 2015 High Caffeine Content. All rights reserved. -// - -#import -#import - -@interface ViewController : GCEventViewController - - -@end - diff --git a/Browser/ViewController.m b/Browser/ViewController.m deleted file mode 100644 index 8bb6764..0000000 --- a/Browser/ViewController.m +++ /dev/null @@ -1,168 +0,0 @@ -// -// ViewController.m -// Browser -// -// Created by Steven Troughton-Smith on 20/09/2015. -// Copyright © 2015 High Caffeine Content. All rights reserved. -// - -#import "ViewController.h" -#import - -typedef struct _Input -{ - CGFloat x; - CGFloat y; -} Input; - - -@interface ViewController () -{ - UIView *cursorView; - Input input; - NSString *temporaryURL; -} - -@property UIWebView *webview; -@property (strong) CADisplayLink *link; -@property (strong, nonatomic) GCController *controller; -@property BOOL cursorMode; -@end - -@implementation ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - - cursorView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 64, 64)]; - cursorView.center = CGPointMake(CGRectGetMidX([UIScreen mainScreen].bounds), CGRectGetMidY([UIScreen mainScreen].bounds)); - cursorView.backgroundColor = [UIColor colorWithPatternImage:[UIImage imageNamed:@"Cursor"]]; - cursorView.hidden = YES; - - self.webview = [[UIWebView alloc] initWithFrame:[UIScreen mainScreen].bounds]; - [self.webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.apple.com"]]]; - - [self.view addSubview:self.webview]; - [self.view addSubview:cursorView]; - - self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(updateCursor)]; - [self.link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; - - self.webview.scrollView.bounces = YES; - self.webview.scrollView.panGestureRecognizer.allowedTouchTypes = @[ @(UITouchTypeIndirect) ]; - - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(setupController) name:GCControllerDidConnectNotification object:nil]; -} - --(void)toggleMode -{ - self.cursorMode = !self.cursorMode; - - if (self.cursorMode) - { - self.webview.scrollView.scrollEnabled = NO; - self.webview.userInteractionEnabled = NO; - cursorView.hidden = NO; - } - else - { - self.webview.scrollView.scrollEnabled = YES; - self.webview.userInteractionEnabled = YES; - cursorView.hidden = YES; - } -} - -- (void)alertTextFieldDidChange:(UITextField *)sender -{ - UIAlertController *alertController = (UIAlertController *)self.presentedViewController; - if (alertController) - { - UITextField *urlField = alertController.textFields.firstObject; - temporaryURL = urlField.text; - } -} - --(void)pressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event -{ - - if (presses.anyObject.type == UIPressTypeMenu) - { - if (self.presentedViewController) - { - [self dismissViewControllerAnimated:YES completion:nil]; - } - else - [self.webview goBack]; - } - else if (presses.anyObject.type == UIPressTypeSelect) - { - /* Gross. */ - CGPoint point = [self.webview convertPoint:cursorView.frame.origin toView:nil]; - [self.webview stringByEvaluatingJavaScriptFromString:[NSString stringWithFormat:@"document.elementFromPoint(%i, %i).click()", (int)point.x, (int)point.y]]; - } - - else if (presses.anyObject.type == UIPressTypePlayPause) - { - UIAlertController *alertController = [UIAlertController - alertControllerWithTitle:@"Enter Address" - message:@"" - preferredStyle:UIAlertControllerStyleAlert]; - - [alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) - { - textField.keyboardType = UIKeyboardTypeURL; - textField.placeholder = @"www.apple.com"; - [textField addTarget:self - action:@selector(alertTextFieldDidChange:) - forControlEvents:UIControlEventEditingChanged]; - - }]; - - UIAlertAction *okAction = [UIAlertAction - actionWithTitle:@"OK" - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *action) - { - [self.webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"http://%@", temporaryURL]]]]; - temporaryURL = nil; - }]; - - [alertController addAction:okAction]; - - [self presentViewController:alertController animated:YES completion:nil]; - - } - else if (presses.anyObject.type == UIPressTypeUpArrow) - { - [self toggleMode]; - } -} - - -#pragma mark - Cursor Input - --(void)setupController -{ - self.controller = [GCController controllers].firstObject; - self.controller.microGamepad.dpad.valueChangedHandler = ^(GCControllerDirectionPad *pad, float x, float y) { - input.x = x; - input.y = -y; - }; -} - --(void)updateCursor -{ - CGFloat delta = 5.0; - - if (!self.cursorMode) - return; - - if (input.x != 0) - cursorView.transform = CGAffineTransformTranslate(cursorView.transform, pow(2,delta*fabs(input.x))*(input.x>0?1:-1), 0); - - if (input.y != 0) - cursorView.transform = CGAffineTransformTranslate(cursorView.transform, 0, pow(2,delta*fabs(input.y))*(input.y>0?1:-1)); - -} - -@end diff --git a/Icons/MenuIcons/go-back-left-arrow.png b/Icons/MenuIcons/go-back-left-arrow.png new file mode 100644 index 0000000..1f2e9dc Binary files /dev/null and b/Icons/MenuIcons/go-back-left-arrow.png differ diff --git a/Icons/MenuIcons/house-outline.png b/Icons/MenuIcons/house-outline.png new file mode 100644 index 0000000..83b2e27 Binary files /dev/null and b/Icons/MenuIcons/house-outline.png differ diff --git a/Icons/MenuIcons/maximize-2.png b/Icons/MenuIcons/maximize-2.png new file mode 100644 index 0000000..dacfa5d Binary files /dev/null and b/Icons/MenuIcons/maximize-2.png differ diff --git a/Icons/MenuIcons/menu-2.png b/Icons/MenuIcons/menu-2.png new file mode 100644 index 0000000..05a702f Binary files /dev/null and b/Icons/MenuIcons/menu-2.png differ diff --git a/Icons/MenuIcons/menu-button.png b/Icons/MenuIcons/menu-button.png new file mode 100644 index 0000000..28b1845 Binary files /dev/null and b/Icons/MenuIcons/menu-button.png differ diff --git a/Icons/MenuIcons/multi-tab.png b/Icons/MenuIcons/multi-tab.png new file mode 100644 index 0000000..ba14288 Binary files /dev/null and b/Icons/MenuIcons/multi-tab.png differ diff --git a/Icons/MenuIcons/plus.png b/Icons/MenuIcons/plus.png new file mode 100644 index 0000000..15b1816 Binary files /dev/null and b/Icons/MenuIcons/plus.png differ diff --git a/Icons/MenuIcons/refresh-button.png b/Icons/MenuIcons/refresh-button.png new file mode 100644 index 0000000..066579e Binary files /dev/null and b/Icons/MenuIcons/refresh-button.png differ diff --git a/Icons/MenuIcons/resize-arrows.png b/Icons/MenuIcons/resize-arrows.png new file mode 100644 index 0000000..4ed8414 Binary files /dev/null and b/Icons/MenuIcons/resize-arrows.png differ diff --git a/Icons/MenuIcons/right-arrow-forward.png b/Icons/MenuIcons/right-arrow-forward.png new file mode 100644 index 0000000..e1a1be7 Binary files /dev/null and b/Icons/MenuIcons/right-arrow-forward.png differ diff --git a/Icons/iconFlattened.pdf b/Icons/iconFlattened.pdf new file mode 100644 index 0000000..c7a5b66 Binary files /dev/null and b/Icons/iconFlattened.pdf differ diff --git a/Icons/iconFlattened.psd b/Icons/iconFlattened.psd new file mode 100644 index 0000000..42b63a1 Binary files /dev/null and b/Icons/iconFlattened.psd differ diff --git a/README.mdown b/README.mdown index 7617c7e..a8a0470 100644 --- a/README.mdown +++ b/README.mdown @@ -1,20 +1,52 @@ tvOS Browser ============= -Very simplistic browser for tvOS using private API (aka UIWebView). This is about as complete as this project is ever going to get, so just treat it as sample code. +![Alt text](/screen01.png?raw=true "tvOS Browser on AppleTV") -You'll need to redefine the following in Availability.h to build successfully. +![Alt text](/screen02.png?raw=true "tvOS Browser Tab Switcher") -``` -__TVOS_UNAVAILABLE -__TVOS_PROHIBITED -``` -How to Use +![Alt text](/screen03.png?raw=true "tvOS Browser Menu") + + +***tvOS Browser*** is a webbrowser for Apple TV devices running tvOS. It's using private API's, Apple does normally not allow to use this in App Store distributed apps. The latest version has some significant changes: +- It's now using WKWebView instead of UIWebView, which results in much improved performance and better website rendering. +- Added support for browser Tabs +- New modern Liquid Glass UI +- Added (experimental) full screen video player, which you can enable in the Menu + +This code is provided as is with no warrenty or liability. Use at your own risk and do ***not*** distribute builds of this project on the App Store. + + +How to Install tvOS Browser ============= -Tap the top of the touch area on the Apple TV Remote to switch between cursor & scroll mode. -Click the touch area to click. +There is ***no*** ready to use binary available on this repository. You need to install Xcode and build/run the project yourself. +To install this app, connect your Apple TV to your macOS computer via WiFi (or USB on older devices). Open this project in Xcode and install to your Apple TV. +Connecting to your Apple TV wirelessly: http://www.redmondpie.com/how-to-wirelessly-connect-apple-tv-4k-to-xcode-on-mac/. + +How to Use tvOSBrowser +============= -Menu will navigate back. +Quick start: +- Double press on the center of the touch area of the Apple TV Remote to switch between cursor & scroll mode. +- Press the touch area while in cursor mode to click. +- Single tap to Menu button to go back, or exit if it's root page. +- Single tap the Play/Pause button to show the Quick Menu, with: input URLs, search Google, reload the page, or go forward. +- Double tap the Play/Pause button to: display the full Side Menu, with: Favorites, History, set/open homepage, change user agent, clear cache, and clear cookies and more. -The Play/Pause button will let you input URLs (no fuzzy matching or auto-search). \ No newline at end of file +The Side menu lets you: +- Go to Home page +- Set Home page +- Manage Favorites +- Manage History +- Show Tabs +- Open New Tab +- Hide/Show top Navigation bar +- Set page Scaling options +- Increase/decrease Font size +- Enable/disable Full Screen player +- Switch Desktop/Mobile User Agent +- Show some video diagnostics +- Clear cache +- Cleare cookies +- Show the usage guide diff --git a/_Project/Browser.xcodeproj/project.pbxproj b/_Project/Browser.xcodeproj/project.pbxproj new file mode 100644 index 0000000..eee8793 --- /dev/null +++ b/_Project/Browser.xcodeproj/project.pbxproj @@ -0,0 +1,639 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 3A2A7C1621E7805D0083CB13 /* go-back-left-arrow.png in Resources */ = {isa = PBXBuildFile; fileRef = 3A2A7C1121E7805D0083CB13 /* go-back-left-arrow.png */; }; + 3A2A7C1721E7805D0083CB13 /* house-outline.png in Resources */ = {isa = PBXBuildFile; fileRef = 3A2A7C1221E7805D0083CB13 /* house-outline.png */; }; + 3A2A7C1821E7805D0083CB13 /* right-arrow-forward.png in Resources */ = {isa = PBXBuildFile; fileRef = 3A2A7C1321E7805D0083CB13 /* right-arrow-forward.png */; }; + 3A2A7C1921E7805D0083CB13 /* refresh-button.png in Resources */ = {isa = PBXBuildFile; fileRef = 3A2A7C1421E7805D0083CB13 /* refresh-button.png */; }; + 3A2A7C1A21E7805E0083CB13 /* maximize-2.png in Resources */ = {isa = PBXBuildFile; fileRef = 3A2A7C1521E7805D0083CB13 /* maximize-2.png */; }; + 3A2A7C1C21E783D00083CB13 /* menu-button.png in Resources */ = {isa = PBXBuildFile; fileRef = 3A2A7C1B21E783D00083CB13 /* menu-button.png */; }; + 3A2A7C1E21E7842F0083CB13 /* menu-2.png in Resources */ = {isa = PBXBuildFile; fileRef = 3A2A7C1D21E7842E0083CB13 /* menu-2.png */; }; + 3A2A7C2021E784760083CB13 /* resize-arrows.png in Resources */ = {isa = PBXBuildFile; fileRef = 3A2A7C1F21E784760083CB13 /* resize-arrows.png */; }; + 3A2A7C2221E790000083CB13 /* multi-tab.png in Resources */ = {isa = PBXBuildFile; fileRef = 3A2A7C2121E790000083CB13 /* multi-tab.png */; }; + 3A2A7C2421E790000083CB13 /* plus.png in Resources */ = {isa = PBXBuildFile; fileRef = 3A2A7C2321E790000083CB13 /* plus.png */; }; + 9675E1FC20855F6500A4A84A /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9675E1FB20855F6500A4A84A /* Foundation.framework */; }; + 9675E1FF20857AEF00A4A84A /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9675E1FE20857AEF00A4A84A /* UIKit.framework */; }; + A1B49A482D67F2B3001D58A1 /* BrowserTabViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A472D67F2B3001D58A1 /* BrowserTabViewModel.m */; }; + A1B49A4B2D67F2C3001D58A1 /* BrowserViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A4A2D67F2C3001D58A1 /* BrowserViewModel.m */; }; + A1B49A4E2D680900001D58A1 /* BrowserNavigationService.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A4D2D680900001D58A1 /* BrowserNavigationService.m */; }; + A1B49A552D68143D001D58A1 /* BrowserMenuCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A542D68143D001D58A1 /* BrowserMenuCoordinator.m */; }; + A1B49A582D6828D4001D58A1 /* BrowserSessionStore.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A572D6828D4001D58A1 /* BrowserSessionStore.m */; }; + A1B49A5B2D684000001D58A1 /* BrowserTopBarView.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A5A2D684000001D58A1 /* BrowserTopBarView.m */; }; + A1B49A5D2D69F100001D58A1 /* WebCoreNSURLSessionTaskTransactionMetrics+AddPrivacyStance.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A5C2D69F100001D58A1 /* WebCoreNSURLSessionTaskTransactionMetrics+AddPrivacyStance.m */; }; + A1B49A5F2D69F900001D58A1 /* WebAVPlayerViewController+FullscreenSubviewHack.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A5E2D69F900001D58A1 /* WebAVPlayerViewController+FullscreenSubviewHack.m */; }; + A1B49A612D6A1000001D58A1 /* BrowserWKWebViewProofOfConceptViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A602D6A1000001D58A1 /* BrowserWKWebViewProofOfConceptViewController.m */; }; + A1B49A632D6A3800001D58A1 /* BrowserWebView.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A622D6A3800001D58A1 /* BrowserWebView.m */; }; + A1B49A662D6A7500001D58A1 /* AVPlayerViewController+BrowserFullscreenBlock.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A652D6A7500001D58A1 /* AVPlayerViewController+BrowserFullscreenBlock.m */; }; + A1B49A682D6AD500001D58A1 /* UIApplication+BrowserSelectPressForwarding.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A672D6AD500001D58A1 /* UIApplication+BrowserSelectPressForwarding.m */; }; + A1B49A6C2D6B1000001D58A1 /* BrowserNativeVideoPlayerViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A6B2D6B1000001D58A1 /* BrowserNativeVideoPlayerViewController.m */; }; + A1B49A722D6B7000001D58A1 /* BrowserYouTubeExtractor.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A712D6B7000001D58A1 /* BrowserYouTubeExtractor.m */; }; + A1B49A742D6C1000001D58A1 /* BrowserNativeVideoAssetLoader.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A732D6C1000001D58A1 /* BrowserNativeVideoAssetLoader.m */; }; + A1B49A842D6D2000001D58A1 /* BrowserDOMInteractionService.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A832D6D2000001D58A1 /* BrowserDOMInteractionService.m */; }; + A1B49A872D6D2000001D58A1 /* BrowserVideoPlaybackCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A862D6D2000001D58A1 /* BrowserVideoPlaybackCoordinator.m */; }; + A1B49A892D700000001D58A1 /* BrowserPreferencesStore.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A882D700000001D58A1 /* BrowserPreferencesStore.m */; }; + A1B49A8C2D700010001D58A1 /* BrowserTabCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A8B2D700010001D58A1 /* BrowserTabCoordinator.m */; }; + A1B49A8F2D700020001D58A1 /* BrowserTabOverviewController.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A8E2D700020001D58A1 /* BrowserTabOverviewController.m */; }; + A1B49A922D700030001D58A1 /* BrowserPageActionCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A912D700030001D58A1 /* BrowserPageActionCoordinator.m */; }; + A1B49A952D700040001D58A1 /* BrowserRemoteInputController.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B49A942D700040001D58A1 /* BrowserRemoteInputController.m */; }; + B002B8671BAE420500C744AF /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = B002B8661BAE420500C744AF /* main.m */; }; + B002B86A1BAE420500C744AF /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = B002B8691BAE420500C744AF /* AppDelegate.m */; }; + B002B86D1BAE420500C744AF /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = B002B86C1BAE420500C744AF /* ViewController.m */; }; + B002B8701BAE420500C744AF /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B002B86E1BAE420500C744AF /* Main.storyboard */; }; + B002B8721BAE420500C744AF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B002B8711BAE420500C744AF /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 3A2A7C1121E7805D0083CB13 /* go-back-left-arrow.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "go-back-left-arrow.png"; sourceTree = ""; }; + 3A2A7C1221E7805D0083CB13 /* house-outline.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "house-outline.png"; sourceTree = ""; }; + 3A2A7C1321E7805D0083CB13 /* right-arrow-forward.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "right-arrow-forward.png"; sourceTree = ""; }; + 3A2A7C1421E7805D0083CB13 /* refresh-button.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "refresh-button.png"; sourceTree = ""; }; + 3A2A7C1521E7805D0083CB13 /* maximize-2.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "maximize-2.png"; sourceTree = ""; }; + 3A2A7C1B21E783D00083CB13 /* menu-button.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menu-button.png"; sourceTree = ""; }; + 3A2A7C1D21E7842E0083CB13 /* menu-2.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menu-2.png"; sourceTree = ""; }; + 3A2A7C1F21E784760083CB13 /* resize-arrows.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "resize-arrows.png"; sourceTree = ""; }; + 3A2A7C2121E790000083CB13 /* multi-tab.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "multi-tab.png"; sourceTree = ""; }; + 3A2A7C2321E790000083CB13 /* plus.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = plus.png; sourceTree = ""; }; + 9675E1FB20855F6500A4A84A /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; + 9675E1FE20857AEF00A4A84A /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + A1B49A462D67F2B3001D58A1 /* BrowserTabViewModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserTabViewModel.h; sourceTree = ""; }; + A1B49A472D67F2B3001D58A1 /* BrowserTabViewModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserTabViewModel.m; sourceTree = ""; }; + A1B49A492D67F2C3001D58A1 /* BrowserViewModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserViewModel.h; sourceTree = ""; }; + A1B49A4A2D67F2C3001D58A1 /* BrowserViewModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserViewModel.m; sourceTree = ""; }; + A1B49A4C2D680900001D58A1 /* BrowserNavigationService.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserNavigationService.h; sourceTree = ""; }; + A1B49A4D2D680900001D58A1 /* BrowserNavigationService.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserNavigationService.m; sourceTree = ""; }; + A1B49A532D68143D001D58A1 /* BrowserMenuCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserMenuCoordinator.h; sourceTree = ""; }; + A1B49A542D68143D001D58A1 /* BrowserMenuCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserMenuCoordinator.m; sourceTree = ""; }; + A1B49A562D6828D4001D58A1 /* BrowserSessionStore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserSessionStore.h; sourceTree = ""; }; + A1B49A572D6828D4001D58A1 /* BrowserSessionStore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserSessionStore.m; sourceTree = ""; }; + A1B49A592D684000001D58A1 /* BrowserTopBarView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserTopBarView.h; sourceTree = ""; }; + A1B49A5A2D684000001D58A1 /* BrowserTopBarView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserTopBarView.m; sourceTree = ""; }; + A1B49A5C2D69F100001D58A1 /* WebCoreNSURLSessionTaskTransactionMetrics+AddPrivacyStance.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "WebCoreNSURLSessionTaskTransactionMetrics+AddPrivacyStance.m"; sourceTree = ""; }; + A1B49A5E2D69F900001D58A1 /* WebAVPlayerViewController+FullscreenSubviewHack.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "WebAVPlayerViewController+FullscreenSubviewHack.m"; sourceTree = ""; }; + A1B49A602D6A1000001D58A1 /* BrowserWKWebViewProofOfConceptViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserWKWebViewProofOfConceptViewController.m; sourceTree = ""; }; + A1B49A622D6A3800001D58A1 /* BrowserWebView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserWebView.m; sourceTree = ""; }; + A1B49A642D6A3900001D58A1 /* BrowserWebView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserWebView.h; sourceTree = ""; }; + A1B49A652D6A7500001D58A1 /* AVPlayerViewController+BrowserFullscreenBlock.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "AVPlayerViewController+BrowserFullscreenBlock.m"; sourceTree = ""; }; + A1B49A672D6AD500001D58A1 /* UIApplication+BrowserSelectPressForwarding.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIApplication+BrowserSelectPressForwarding.m"; sourceTree = ""; }; + A1B49A692D6B1000001D58A1 /* BrowserNativeVideoPlayerViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserNativeVideoPlayerViewController.h; sourceTree = ""; }; + A1B49A6B2D6B1000001D58A1 /* BrowserNativeVideoPlayerViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserNativeVideoPlayerViewController.m; sourceTree = ""; }; + A1B49A702D6B7000001D58A1 /* BrowserYouTubeExtractor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserYouTubeExtractor.h; sourceTree = ""; }; + A1B49A712D6B7000001D58A1 /* BrowserYouTubeExtractor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserYouTubeExtractor.m; sourceTree = ""; }; + A1B49A732D6C1000001D58A1 /* BrowserNativeVideoAssetLoader.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserNativeVideoAssetLoader.m; sourceTree = ""; }; + A1B49A752D6C1000001D58A1 /* BrowserNativeVideoAssetLoader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserNativeVideoAssetLoader.h; sourceTree = ""; }; + A1B49A822D6D2000001D58A1 /* BrowserDOMInteractionService.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserDOMInteractionService.h; sourceTree = ""; }; + A1B49A832D6D2000001D58A1 /* BrowserDOMInteractionService.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserDOMInteractionService.m; sourceTree = ""; }; + A1B49A852D6D2000001D58A1 /* BrowserVideoPlaybackCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserVideoPlaybackCoordinator.h; sourceTree = ""; }; + A1B49A862D6D2000001D58A1 /* BrowserVideoPlaybackCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserVideoPlaybackCoordinator.m; sourceTree = ""; }; + A1B49A872D700000001D58A1 /* BrowserPreferencesStore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserPreferencesStore.h; sourceTree = ""; }; + A1B49A882D700000001D58A1 /* BrowserPreferencesStore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserPreferencesStore.m; sourceTree = ""; }; + A1B49A8A2D700010001D58A1 /* BrowserTabCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserTabCoordinator.h; sourceTree = ""; }; + A1B49A8B2D700010001D58A1 /* BrowserTabCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserTabCoordinator.m; sourceTree = ""; }; + A1B49A8D2D700020001D58A1 /* BrowserTabOverviewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserTabOverviewController.h; sourceTree = ""; }; + A1B49A8E2D700020001D58A1 /* BrowserTabOverviewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserTabOverviewController.m; sourceTree = ""; }; + A1B49A902D700030001D58A1 /* BrowserPageActionCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserPageActionCoordinator.h; sourceTree = ""; }; + A1B49A912D700030001D58A1 /* BrowserPageActionCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserPageActionCoordinator.m; sourceTree = ""; }; + A1B49A932D700040001D58A1 /* BrowserRemoteInputController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrowserRemoteInputController.h; sourceTree = ""; }; + A1B49A942D700040001D58A1 /* BrowserRemoteInputController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BrowserRemoteInputController.m; sourceTree = ""; }; + B002B8621BAE420500C744AF /* Browser.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Browser.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B002B8661BAE420500C744AF /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + B002B8681BAE420500C744AF /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + B002B8691BAE420500C744AF /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + B002B86B1BAE420500C744AF /* ViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ViewController.h; sourceTree = ""; }; + B002B86C1BAE420500C744AF /* ViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ViewController.m; sourceTree = ""; }; + B002B86F1BAE420500C744AF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + B002B8711BAE420500C744AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B002B8731BAE420500C744AF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B0F6B4611BAEBF9900E2F26B /* ../README.mdown */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = ../README.mdown; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + B002B85F1BAE420500C744AF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9675E1FF20857AEF00A4A84A /* UIKit.framework in Frameworks */, + 9675E1FC20855F6500A4A84A /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 3A2A7C0621E77E2C0083CB13 /* MenuIcons */ = { + isa = PBXGroup; + children = ( + 3A2A7C1D21E7842E0083CB13 /* menu-2.png */, + 3A2A7C1F21E784760083CB13 /* resize-arrows.png */, + 3A2A7C2121E790000083CB13 /* multi-tab.png */, + 3A2A7C2321E790000083CB13 /* plus.png */, + 3A2A7C1B21E783D00083CB13 /* menu-button.png */, + 3A2A7C1121E7805D0083CB13 /* go-back-left-arrow.png */, + 3A2A7C1221E7805D0083CB13 /* house-outline.png */, + 3A2A7C1521E7805D0083CB13 /* maximize-2.png */, + 3A2A7C1421E7805D0083CB13 /* refresh-button.png */, + 3A2A7C1321E7805D0083CB13 /* right-arrow-forward.png */, + ); + name = MenuIcons; + path = ../../Icons/MenuIcons; + sourceTree = ""; + }; + 9675E1FA20855F6500A4A84A /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9675E1FE20857AEF00A4A84A /* UIKit.framework */, + 9675E1FB20855F6500A4A84A /* Foundation.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + A1B49A772D6D0000001D58A1 /* App */ = { + isa = PBXGroup; + children = ( + B002B8681BAE420500C744AF /* AppDelegate.h */, + B002B8691BAE420500C744AF /* AppDelegate.m */, + ); + name = App; + sourceTree = ""; + }; + A1B49A792D6D0000001D58A1 /* Models */ = { + isa = PBXGroup; + children = ( + A1B49A462D67F2B3001D58A1 /* BrowserTabViewModel.h */, + A1B49A472D67F2B3001D58A1 /* BrowserTabViewModel.m */, + ); + name = Models; + sourceTree = ""; + }; + A1B49A7A2D6D0000001D58A1 /* ViewModels */ = { + isa = PBXGroup; + children = ( + A1B49A492D67F2C3001D58A1 /* BrowserViewModel.h */, + A1B49A4A2D67F2C3001D58A1 /* BrowserViewModel.m */, + ); + name = ViewModels; + sourceTree = ""; + }; + A1B49A7B2D6D0000001D58A1 /* Coordinators */ = { + isa = PBXGroup; + children = ( + A1B49A532D68143D001D58A1 /* BrowserMenuCoordinator.h */, + A1B49A542D68143D001D58A1 /* BrowserMenuCoordinator.m */, + A1B49A902D700030001D58A1 /* BrowserPageActionCoordinator.h */, + A1B49A912D700030001D58A1 /* BrowserPageActionCoordinator.m */, + A1B49A932D700040001D58A1 /* BrowserRemoteInputController.h */, + A1B49A942D700040001D58A1 /* BrowserRemoteInputController.m */, + A1B49A8A2D700010001D58A1 /* BrowserTabCoordinator.h */, + A1B49A8B2D700010001D58A1 /* BrowserTabCoordinator.m */, + A1B49A8D2D700020001D58A1 /* BrowserTabOverviewController.h */, + A1B49A8E2D700020001D58A1 /* BrowserTabOverviewController.m */, + A1B49A852D6D2000001D58A1 /* BrowserVideoPlaybackCoordinator.h */, + A1B49A862D6D2000001D58A1 /* BrowserVideoPlaybackCoordinator.m */, + ); + name = Coordinators; + sourceTree = ""; + }; + A1B49A7C2D6D0000001D58A1 /* Services */ = { + isa = PBXGroup; + children = ( + A1B49A822D6D2000001D58A1 /* BrowserDOMInteractionService.h */, + A1B49A832D6D2000001D58A1 /* BrowserDOMInteractionService.m */, + A1B49A4C2D680900001D58A1 /* BrowserNavigationService.h */, + A1B49A4D2D680900001D58A1 /* BrowserNavigationService.m */, + A1B49A872D700000001D58A1 /* BrowserPreferencesStore.h */, + A1B49A882D700000001D58A1 /* BrowserPreferencesStore.m */, + A1B49A562D6828D4001D58A1 /* BrowserSessionStore.h */, + A1B49A572D6828D4001D58A1 /* BrowserSessionStore.m */, + ); + name = Services; + sourceTree = ""; + }; + A1B49A7D2D6D0000001D58A1 /* UI */ = { + isa = PBXGroup; + children = ( + A1B49A7E2D6D0000001D58A1 /* Controllers */, + A1B49A7F2D6D0000001D58A1 /* Views */, + ); + name = UI; + sourceTree = ""; + }; + A1B49A7E2D6D0000001D58A1 /* Controllers */ = { + isa = PBXGroup; + children = ( + B002B86B1BAE420500C744AF /* ViewController.h */, + B002B86C1BAE420500C744AF /* ViewController.m */, + A1B49A692D6B1000001D58A1 /* BrowserNativeVideoPlayerViewController.h */, + A1B49A6B2D6B1000001D58A1 /* BrowserNativeVideoPlayerViewController.m */, + A1B49A602D6A1000001D58A1 /* BrowserWKWebViewProofOfConceptViewController.m */, + ); + name = Controllers; + sourceTree = ""; + }; + A1B49A7F2D6D0000001D58A1 /* Views */ = { + isa = PBXGroup; + children = ( + A1B49A592D684000001D58A1 /* BrowserTopBarView.h */, + A1B49A5A2D684000001D58A1 /* BrowserTopBarView.m */, + A1B49A642D6A3900001D58A1 /* BrowserWebView.h */, + A1B49A622D6A3800001D58A1 /* BrowserWebView.m */, + ); + name = Views; + sourceTree = ""; + }; + A1B49A802D6D0000001D58A1 /* Video */ = { + isa = PBXGroup; + children = ( + A1B49A752D6C1000001D58A1 /* BrowserNativeVideoAssetLoader.h */, + A1B49A732D6C1000001D58A1 /* BrowserNativeVideoAssetLoader.m */, + A1B49A702D6B7000001D58A1 /* BrowserYouTubeExtractor.h */, + A1B49A712D6B7000001D58A1 /* BrowserYouTubeExtractor.m */, + ); + name = Video; + sourceTree = ""; + }; + A1B49A812D6D0000001D58A1 /* Runtime */ = { + isa = PBXGroup; + children = ( + A1B49A652D6A7500001D58A1 /* AVPlayerViewController+BrowserFullscreenBlock.m */, + A1B49A672D6AD500001D58A1 /* UIApplication+BrowserSelectPressForwarding.m */, + A1B49A5E2D69F900001D58A1 /* WebAVPlayerViewController+FullscreenSubviewHack.m */, + A1B49A5C2D69F100001D58A1 /* WebCoreNSURLSessionTaskTransactionMetrics+AddPrivacyStance.m */, + ); + name = Runtime; + sourceTree = ""; + }; + B002B8591BAE420500C744AF = { + isa = PBXGroup; + children = ( + B0F6B4611BAEBF9900E2F26B /* ../README.mdown */, + B002B8641BAE420500C744AF /* Browser */, + B002B8631BAE420500C744AF /* Products */, + 9675E1FA20855F6500A4A84A /* Frameworks */, + ); + sourceTree = ""; + }; + B002B8631BAE420500C744AF /* Products */ = { + isa = PBXGroup; + children = ( + B002B8621BAE420500C744AF /* Browser.app */, + ); + name = Products; + sourceTree = ""; + }; + B002B8641BAE420500C744AF /* Browser */ = { + isa = PBXGroup; + children = ( + A1B49A772D6D0000001D58A1 /* App */, + A1B49A792D6D0000001D58A1 /* Models */, + A1B49A7A2D6D0000001D58A1 /* ViewModels */, + A1B49A7B2D6D0000001D58A1 /* Coordinators */, + A1B49A7C2D6D0000001D58A1 /* Services */, + A1B49A7D2D6D0000001D58A1 /* UI */, + A1B49A802D6D0000001D58A1 /* Video */, + A1B49A812D6D0000001D58A1 /* Runtime */, + 3A2A7C0621E77E2C0083CB13 /* MenuIcons */, + B002B8651BAE420500C744AF /* Supporting Files */, + ); + path = Browser; + sourceTree = ""; + }; + B002B8651BAE420500C744AF /* Supporting Files */ = { + isa = PBXGroup; + children = ( + B002B8661BAE420500C744AF /* main.m */, + B002B86E1BAE420500C744AF /* Main.storyboard */, + B002B8711BAE420500C744AF /* Assets.xcassets */, + B002B8731BAE420500C744AF /* Info.plist */, + ); + name = "Supporting Files"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + B002B8611BAE420500C744AF /* Browser */ = { + isa = PBXNativeTarget; + buildConfigurationList = B002B8761BAE420500C744AF /* Build configuration list for PBXNativeTarget "Browser" */; + buildPhases = ( + B002B85E1BAE420500C744AF /* Sources */, + B002B85F1BAE420500C744AF /* Frameworks */, + B002B8601BAE420500C744AF /* Resources */, + 9693F8451BF5A99E00077BAB /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Browser; + productName = Browser; + productReference = B002B8621BAE420500C744AF /* Browser.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B002B85A1BAE420500C744AF /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 2620; + ORGANIZATIONNAME = "High Caffeine Content"; + TargetAttributes = { + B002B8611BAE420500C744AF = { + CreatedOnToolsVersion = 7.1; + }; + }; + }; + buildConfigurationList = B002B85D1BAE420500C744AF /* Build configuration list for PBXProject "Browser" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = B002B8591BAE420500C744AF; + productRefGroup = B002B8631BAE420500C744AF /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B002B8611BAE420500C744AF /* Browser */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B002B8601BAE420500C744AF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3A2A7C1C21E783D00083CB13 /* menu-button.png in Resources */, + 3A2A7C2021E784760083CB13 /* resize-arrows.png in Resources */, + 3A2A7C2221E790000083CB13 /* multi-tab.png in Resources */, + 3A2A7C2421E790000083CB13 /* plus.png in Resources */, + B002B8721BAE420500C744AF /* Assets.xcassets in Resources */, + B002B8701BAE420500C744AF /* Main.storyboard in Resources */, + 3A2A7C1821E7805D0083CB13 /* right-arrow-forward.png in Resources */, + 3A2A7C1E21E7842F0083CB13 /* menu-2.png in Resources */, + 3A2A7C1621E7805D0083CB13 /* go-back-left-arrow.png in Resources */, + 3A2A7C1921E7805D0083CB13 /* refresh-button.png in Resources */, + 3A2A7C1A21E7805E0083CB13 /* maximize-2.png in Resources */, + 3A2A7C1721E7805D0083CB13 /* house-outline.png in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 9693F8451BF5A99E00077BAB /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 8; + files = ( + ); + inputPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 1; + shellPath = /bin/sh; + shellScript = "echo \"Versioning is controlled by MARKETING_VERSION/CURRENT_PROJECT_VERSION.\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + B002B85E1BAE420500C744AF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A1B49A482D67F2B3001D58A1 /* BrowserTabViewModel.m in Sources */, + A1B49A552D68143D001D58A1 /* BrowserMenuCoordinator.m in Sources */, + A1B49A922D700030001D58A1 /* BrowserPageActionCoordinator.m in Sources */, + A1B49A952D700040001D58A1 /* BrowserRemoteInputController.m in Sources */, + A1B49A8C2D700010001D58A1 /* BrowserTabCoordinator.m in Sources */, + A1B49A8F2D700020001D58A1 /* BrowserTabOverviewController.m in Sources */, + A1B49A872D6D2000001D58A1 /* BrowserVideoPlaybackCoordinator.m in Sources */, + A1B49A842D6D2000001D58A1 /* BrowserDOMInteractionService.m in Sources */, + A1B49A4E2D680900001D58A1 /* BrowserNavigationService.m in Sources */, + A1B49A892D700000001D58A1 /* BrowserPreferencesStore.m in Sources */, + A1B49A582D6828D4001D58A1 /* BrowserSessionStore.m in Sources */, + A1B49A5B2D684000001D58A1 /* BrowserTopBarView.m in Sources */, + A1B49A6C2D6B1000001D58A1 /* BrowserNativeVideoPlayerViewController.m in Sources */, + A1B49A722D6B7000001D58A1 /* BrowserYouTubeExtractor.m in Sources */, + A1B49A742D6C1000001D58A1 /* BrowserNativeVideoAssetLoader.m in Sources */, + A1B49A662D6A7500001D58A1 /* AVPlayerViewController+BrowserFullscreenBlock.m in Sources */, + A1B49A682D6AD500001D58A1 /* UIApplication+BrowserSelectPressForwarding.m in Sources */, + A1B49A632D6A3800001D58A1 /* BrowserWebView.m in Sources */, + A1B49A612D6A1000001D58A1 /* BrowserWKWebViewProofOfConceptViewController.m in Sources */, + A1B49A5F2D69F900001D58A1 /* WebAVPlayerViewController+FullscreenSubviewHack.m in Sources */, + A1B49A5D2D69F100001D58A1 /* WebCoreNSURLSessionTaskTransactionMetrics+AddPrivacyStance.m in Sources */, + A1B49A4B2D67F2C3001D58A1 /* BrowserViewModel.m in Sources */, + B002B86D1BAE420500C744AF /* ViewController.m in Sources */, + B002B86A1BAE420500C744AF /* AppDelegate.m in Sources */, + B002B8671BAE420500C744AF /* main.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + B002B86E1BAE420500C744AF /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + B002B86F1BAE420500C744AF /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + B002B8741BAE420500C744AF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = H7W77QXGX2; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = appletvos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 12.0; + }; + name = Debug; + }; + B002B8751BAE420500C744AF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = H7W77QXGX2; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = appletvos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 12.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + B002B8771BAE420500C744AF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 3000; + DEVELOPMENT_TEAM = PMC4RZG4LF; + EMBED_ASSET_PACKS_IN_PRODUCT_BUNDLE = YES; + ENABLE_ON_DEMAND_RESOURCES = NO; + FRAMEWORK_SEARCH_PATHS = ""; + INFOPLIST_FILE = Browser/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 2.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jva.tvbrowser; + PRODUCT_NAME = "$(TARGET_NAME)"; + SCAN_ALL_SOURCE_FILES_FOR_INCLUDES = NO; + TVOS_DEPLOYMENT_TARGET = 15.6; + }; + name = Debug; + }; + B002B8781BAE420500C744AF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 3000; + DEVELOPMENT_TEAM = PMC4RZG4LF; + EMBED_ASSET_PACKS_IN_PRODUCT_BUNDLE = YES; + ENABLE_ON_DEMAND_RESOURCES = NO; + FRAMEWORK_SEARCH_PATHS = ""; + INFOPLIST_FILE = Browser/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 2.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jva.tvbrowser; + PRODUCT_NAME = "$(TARGET_NAME)"; + SCAN_ALL_SOURCE_FILES_FOR_INCLUDES = NO; + TVOS_DEPLOYMENT_TARGET = 15.6; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B002B85D1BAE420500C744AF /* Build configuration list for PBXProject "Browser" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B002B8741BAE420500C744AF /* Debug */, + B002B8751BAE420500C744AF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B002B8761BAE420500C744AF /* Build configuration list for PBXNativeTarget "Browser" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B002B8771BAE420500C744AF /* Debug */, + B002B8781BAE420500C744AF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = B002B85A1BAE420500C744AF /* Project object */; +} diff --git a/Browser.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/_Project/Browser.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from Browser.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to _Project/Browser.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/_Project/Browser.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/_Project/Browser.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/_Project/Browser.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/_Project/Browser.xcodeproj/xcuserdata/Jason.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist similarity index 100% rename from Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist rename to _Project/Browser.xcodeproj/xcuserdata/Jason.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist diff --git a/_Project/Browser.xcodeproj/xcuserdata/Jason.xcuserdatad/xcschemes/Browser.xcscheme b/_Project/Browser.xcodeproj/xcuserdata/Jason.xcuserdatad/xcschemes/Browser.xcscheme new file mode 100644 index 0000000..2b22fc7 --- /dev/null +++ b/_Project/Browser.xcodeproj/xcuserdata/Jason.xcuserdatad/xcschemes/Browser.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcschemes/xcschememanagement.plist b/_Project/Browser.xcodeproj/xcuserdata/Jason.xcuserdatad/xcschemes/xcschememanagement.plist similarity index 100% rename from Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcschemes/xcschememanagement.plist rename to _Project/Browser.xcodeproj/xcuserdata/Jason.xcuserdatad/xcschemes/xcschememanagement.plist diff --git a/_Project/Browser.xcodeproj/xcuserdata/jipvanakker.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/_Project/Browser.xcodeproj/xcuserdata/jipvanakker.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..e93e32d --- /dev/null +++ b/_Project/Browser.xcodeproj/xcuserdata/jipvanakker.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/_Project/Browser.xcodeproj/xcuserdata/jipvanakker.xcuserdatad/xcschemes/Browser.xcscheme b/_Project/Browser.xcodeproj/xcuserdata/jipvanakker.xcuserdatad/xcschemes/Browser.xcscheme new file mode 100644 index 0000000..8728be0 --- /dev/null +++ b/_Project/Browser.xcodeproj/xcuserdata/jipvanakker.xcuserdatad/xcschemes/Browser.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_Project/Browser.xcodeproj/xcuserdata/jipvanakker.xcuserdatad/xcschemes/xcschememanagement.plist b/_Project/Browser.xcodeproj/xcuserdata/jipvanakker.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..0be6ad5 --- /dev/null +++ b/_Project/Browser.xcodeproj/xcuserdata/jipvanakker.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,22 @@ + + + + + SchemeUserState + + Browser.xcscheme + + orderHint + 0 + + + SuppressBuildableAutocreation + + B002B8611BAE420500C744AF + + primary + + + + + diff --git a/_Project/Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/_Project/Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..fe2b454 --- /dev/null +++ b/_Project/Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,5 @@ + + + diff --git a/Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcschemes/Browser.xcscheme b/_Project/Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcschemes/Browser.xcscheme similarity index 100% rename from Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcschemes/Browser.xcscheme rename to _Project/Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcschemes/Browser.xcscheme diff --git a/_Project/Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcschemes/xcschememanagement.plist b/_Project/Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..0be6ad5 --- /dev/null +++ b/_Project/Browser.xcodeproj/xcuserdata/steven.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,22 @@ + + + + + SchemeUserState + + Browser.xcscheme + + orderHint + 0 + + + SuppressBuildableAutocreation + + B002B8611BAE420500C744AF + + primary + + + + + diff --git a/_Project/Browser/AVPlayerViewController+BrowserFullscreenBlock.m b/_Project/Browser/AVPlayerViewController+BrowserFullscreenBlock.m new file mode 100644 index 0000000..0848fcb --- /dev/null +++ b/_Project/Browser/AVPlayerViewController+BrowserFullscreenBlock.m @@ -0,0 +1,50 @@ +#import +#import + +static BOOL const kBrowserAVKitFullscreenBlockEnabled = NO; + +@interface AVPlayerViewController (BrowserFullscreenBlock) + +- (void)browser_blockedEnterFullScreenAnimated:(BOOL)animated completionHandler:(void (^ __nullable)(void))completionHandler; +- (void)browser_blockedExitFullScreenAnimated:(BOOL)animated completionHandler:(void (^ __nullable)(void))completionHandler; + +@end + +@implementation AVPlayerViewController (BrowserFullscreenBlock) + ++ (void)load { + if (!kBrowserAVKitFullscreenBlockEnabled) { + return; + } + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Class playerViewControllerClass = [AVPlayerViewController class]; + + Method enterOriginal = class_getInstanceMethod(playerViewControllerClass, NSSelectorFromString(@"enterFullScreenAnimated:completionHandler:")); + Method enterReplacement = class_getInstanceMethod(playerViewControllerClass, @selector(browser_blockedEnterFullScreenAnimated:completionHandler:)); + if (enterOriginal != NULL && enterReplacement != NULL) { + method_exchangeImplementations(enterOriginal, enterReplacement); + } + + Method exitOriginal = class_getInstanceMethod(playerViewControllerClass, NSSelectorFromString(@"exitFullScreenAnimated:completionHandler:")); + Method exitReplacement = class_getInstanceMethod(playerViewControllerClass, @selector(browser_blockedExitFullScreenAnimated:completionHandler:)); + if (exitOriginal != NULL && exitReplacement != NULL) { + method_exchangeImplementations(exitOriginal, exitReplacement); + } + }); +} + +- (void)browser_blockedEnterFullScreenAnimated:(BOOL)animated completionHandler:(void (^ __nullable)(void))completionHandler { + if (completionHandler != nil) { + completionHandler(); + } +} + +- (void)browser_blockedExitFullScreenAnimated:(BOOL)animated completionHandler:(void (^ __nullable)(void))completionHandler { + if (completionHandler != nil) { + completionHandler(); + } +} + +@end diff --git a/Browser/AppDelegate.h b/_Project/Browser/AppDelegate.h similarity index 78% rename from Browser/AppDelegate.h rename to _Project/Browser/AppDelegate.h index 9caed27..b07de4f 100644 --- a/Browser/AppDelegate.h +++ b/_Project/Browser/AppDelegate.h @@ -3,7 +3,7 @@ // Browser // // Created by Steven Troughton-Smith on 20/09/2015. -// Copyright © 2015 High Caffeine Content. All rights reserved. +// Improved by Jip van Akker on 14/10/2015 through 10/01/2019 // #import diff --git a/Browser/AppDelegate.m b/_Project/Browser/AppDelegate.m similarity index 56% rename from Browser/AppDelegate.m rename to _Project/Browser/AppDelegate.m index 9bb76c3..88c9216 100644 --- a/Browser/AppDelegate.m +++ b/_Project/Browser/AppDelegate.m @@ -3,10 +3,12 @@ // Browser // // Created by Steven Troughton-Smith on 20/09/2015. -// Copyright © 2015 High Caffeine Content. All rights reserved. +// Improved by Jip van Akker on 14/10/2015 through 10/01/2019 // #import "AppDelegate.h" +#import "BrowserPreferencesStore.h" +#import "BrowserWebView.h" @interface AppDelegate () @@ -14,32 +16,67 @@ @interface AppDelegate () @implementation AppDelegate +- (void)restoreCookiesFromDefaults { + NSData *cookieData = [[NSUserDefaults standardUserDefaults] objectForKey:@"ApplicationCookie"]; + if (cookieData.length == 0) { + return; + } + + [BrowserWebView restoreCookiesFromData:cookieData]; +} + +- (void)saveCookiesToDefaults { + NSData *cookieData = [BrowserWebView cookieDataRepresentation]; + if (cookieData == nil) { + return; + } + + [[NSUserDefaults standardUserDefaults] setObject:cookieData forKey:@"ApplicationCookie"]; + [[NSUserDefaults standardUserDefaults] synchronize]; +} + - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // Override point for customization after application launch. + if ([[NSUserDefaults standardUserDefaults] boolForKey:@"MobileMode"]) { + [[NSUserDefaults standardUserDefaults] setObject:BrowserPreferencesStore.mobileUserAgent forKey:@"UserAgent"]; + [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"MobileMode"]; + [[NSUserDefaults standardUserDefaults] synchronize]; + } + else { + [[NSUserDefaults standardUserDefaults] setObject:BrowserPreferencesStore.desktopUserAgent forKey:@"UserAgent"]; + [[NSUserDefaults standardUserDefaults] setBool:NO forKey:@"MobileMode"]; + [[NSUserDefaults standardUserDefaults] synchronize]; + } + [self restoreCookiesFromDefaults]; return YES; } - (void)applicationWillResignActive:(UIApplication *)application { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. + [self saveCookiesToDefaults]; } - (void)applicationDidEnterBackground:(UIApplication *)application { // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + [self saveCookiesToDefaults]; } - (void)applicationWillEnterForeground:(UIApplication *)application { // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. + [self restoreCookiesFromDefaults]; } - (void)applicationDidBecomeActive:(UIApplication *)application { // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + [self restoreCookiesFromDefaults]; } - (void)applicationWillTerminate:(UIApplication *)application { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + [self saveCookiesToDefaults]; } @end diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Content.imageset/Contents.json similarity index 70% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Content.imageset/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Content.imageset/Contents.json index 0564959..16a370d 100644 --- a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Content.imageset/Contents.json +++ b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -3,6 +3,10 @@ { "idiom" : "tv", "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" } ], "info" : { diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Contents.json similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Contents.json diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Contents.json similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Contents.json diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Content.imageset/Contents.json similarity index 70% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Content.imageset/Contents.json index 0564959..16a370d 100644 --- a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json +++ b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -3,6 +3,10 @@ { "idiom" : "tv", "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" } ], "info" : { diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Contents.json similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Contents.json diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json similarity index 70% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Content.imageset/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json index 0564959..16a370d 100644 --- a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Content.imageset/Contents.json +++ b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -3,6 +3,10 @@ { "idiom" : "tv", "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" } ], "info" : { diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Contents.json similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Contents.json diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/Contents.json similarity index 74% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/Contents.json index 19cd5e4..edcaac6 100644 --- a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/Contents.json +++ b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -4,6 +4,10 @@ "idiom" : "tv", "filename" : "b0.png", "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" } ], "info" : { diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/b0.png b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/b0.png similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/b0.png rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/b0.png diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Contents.json similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Contents.json diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Contents.json similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Contents.json diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/Contents.json similarity index 74% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/Contents.json index 7ffa32e..017cebb 100644 --- a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/Contents.json +++ b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -4,6 +4,10 @@ "idiom" : "tv", "filename" : "b2.png", "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" } ], "info" : { diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/b2.png b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/b2.png similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/b2.png rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/b2.png diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Contents.json similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Contents.json diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Content.imageset/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Content.imageset/Contents.json similarity index 74% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Content.imageset/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Content.imageset/Contents.json index b2595fa..7d1be78 100644 --- a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Content.imageset/Contents.json +++ b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Content.imageset/Contents.json @@ -4,6 +4,10 @@ "idiom" : "tv", "filename" : "b3.png", "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" } ], "info" : { diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Content.imageset/b3.png b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Content.imageset/b3.png similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Content.imageset/b3.png rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Content.imageset/b3.png diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Contents.json similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Glyph.imagestacklayer/Contents.json diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json similarity index 74% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json index 54363d7..d762b12 100644 --- a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json +++ b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -4,6 +4,10 @@ "idiom" : "tv", "filename" : "b1.png", "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" } ], "info" : { diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/b1.png b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/b1.png similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/b1.png rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/b1.png diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Contents.json similarity index 100% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Contents.json diff --git a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json similarity index 76% rename from Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json rename to _Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json index 21cebab..06167b6 100644 --- a/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json +++ b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json @@ -12,6 +12,12 @@ "filename" : "App Icon - Small.imagestack", "role" : "primary-app-icon" }, + { + "size" : "2320x720", + "idiom" : "tv", + "filename" : "Top Shelf Image Wide-1.imageset", + "role" : "top-shelf-image-wide" + }, { "size" : "1920x720", "idiom" : "tv", diff --git a/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide-1.imageset/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide-1.imageset/Contents.json new file mode 100644 index 0000000..16a370d --- /dev/null +++ b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide-1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "tv", + "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json new file mode 100644 index 0000000..16a370d --- /dev/null +++ b/_Project/Browser/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "tv", + "scale" : "1x" + }, + { + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Browser/Assets.xcassets/Contents.json b/_Project/Browser/Assets.xcassets/Contents.json similarity index 100% rename from Browser/Assets.xcassets/Contents.json rename to _Project/Browser/Assets.xcassets/Contents.json diff --git a/Browser/Assets.xcassets/Cursor.imageset/Contents.json b/_Project/Browser/Assets.xcassets/Cursor.imageset/Contents.json similarity index 85% rename from Browser/Assets.xcassets/Cursor.imageset/Contents.json rename to _Project/Browser/Assets.xcassets/Cursor.imageset/Contents.json index 810e7ff..10ad438 100644 --- a/Browser/Assets.xcassets/Cursor.imageset/Contents.json +++ b/_Project/Browser/Assets.xcassets/Cursor.imageset/Contents.json @@ -2,7 +2,7 @@ "images" : [ { "idiom" : "universal", - "filename" : "Cursor.png", + "filename" : "mac-osx-arrow-cursor.png", "scale" : "1x" }, { diff --git a/_Project/Browser/Assets.xcassets/Cursor.imageset/mac-osx-arrow-cursor.png b/_Project/Browser/Assets.xcassets/Cursor.imageset/mac-osx-arrow-cursor.png new file mode 100644 index 0000000..77f2755 Binary files /dev/null and b/_Project/Browser/Assets.xcassets/Cursor.imageset/mac-osx-arrow-cursor.png differ diff --git a/Browser/Assets.xcassets/LaunchImage.launchimage/Contents.json b/_Project/Browser/Assets.xcassets/LaunchImage.launchimage/Contents.json similarity index 59% rename from Browser/Assets.xcassets/LaunchImage.launchimage/Contents.json rename to _Project/Browser/Assets.xcassets/LaunchImage.launchimage/Contents.json index 29d94c7..d746a60 100644 --- a/Browser/Assets.xcassets/LaunchImage.launchimage/Contents.json +++ b/_Project/Browser/Assets.xcassets/LaunchImage.launchimage/Contents.json @@ -1,5 +1,12 @@ { "images" : [ + { + "orientation" : "landscape", + "idiom" : "tv", + "extent" : "full-screen", + "minimum-system-version" : "11.0", + "scale" : "2x" + }, { "orientation" : "landscape", "idiom" : "tv", diff --git a/_Project/Browser/Assets.xcassets/Pointer.imageset/Contents.json b/_Project/Browser/Assets.xcassets/Pointer.imageset/Contents.json new file mode 100644 index 0000000..a08e6bd --- /dev/null +++ b/_Project/Browser/Assets.xcassets/Pointer.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "mac-osx-pointer-cursor.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/_Project/Browser/Assets.xcassets/Pointer.imageset/mac-osx-pointer-cursor.png b/_Project/Browser/Assets.xcassets/Pointer.imageset/mac-osx-pointer-cursor.png new file mode 100644 index 0000000..ca44a87 Binary files /dev/null and b/_Project/Browser/Assets.xcassets/Pointer.imageset/mac-osx-pointer-cursor.png differ diff --git a/_Project/Browser/Base.lproj/Main.storyboard b/_Project/Browser/Base.lproj/Main.storyboard new file mode 100644 index 0000000..dc1797b --- /dev/null +++ b/_Project/Browser/Base.lproj/Main.storyboard @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_Project/Browser/BrowserDOMInteractionService.h b/_Project/Browser/BrowserDOMInteractionService.h new file mode 100644 index 0000000..dfc1a2a --- /dev/null +++ b/_Project/Browser/BrowserDOMInteractionService.h @@ -0,0 +1,40 @@ +#import +#import + +@class BrowserWebView; + +NS_ASSUME_NONNULL_BEGIN + +@interface BrowserDOMInteractionService : NSObject + +- (CGPoint)DOMPointForCursorOrigin:(CGPoint)cursorOrigin + inView:(UIView *)containerView + webView:(BrowserWebView *)webView; +- (NSString *)evaluateResolvedElementJavaScriptAtPoint:(CGPoint)point + webView:(BrowserWebView *)webView + body:(NSString *)body; +- (NSString *)evaluateEditableElementJavaScriptAtPoint:(CGPoint)point + webView:(BrowserWebView *)webView + body:(NSString *)body; +- (NSString *)evaluateHoverStateJavaScriptAtPoint:(CGPoint)point + webView:(BrowserWebView *)webView; +- (NSString *)javaScriptEscapedString:(NSString *)string; +- (NSDictionary *)videoInfoAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView; +- (NSDictionary *)linkInfoAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView; +- (NSDictionary *)directVideoInfoAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView; +- (BOOL)isVideoActivationTargetAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView; +- (BOOL)isVideoDismissTargetAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView; +- (NSDictionary *)primedVideoInfoAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView; +- (NSDictionary *)activateVideoTargetAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView + timeout:(NSTimeInterval)timeout; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserDOMInteractionService.m b/_Project/Browser/BrowserDOMInteractionService.m new file mode 100644 index 0000000..d10aedd --- /dev/null +++ b/_Project/Browser/BrowserDOMInteractionService.m @@ -0,0 +1,743 @@ +#import "BrowserDOMInteractionService.h" + +#import "BrowserWebView.h" + +static NSString * const kInteractiveElementSelector = @"a, button, input, textarea, select, option, label, summary, [role='button'], [onclick], [tabindex]"; +static NSString * const kEditableElementSelector = @"input, textarea, select, [contenteditable='true'], [contenteditable=''], [contenteditable]"; + +@implementation BrowserDOMInteractionService + +- (CGPoint)DOMPointForCursorOrigin:(CGPoint)cursorOrigin + inView:(UIView *)containerView + webView:(BrowserWebView *)webView { + CGPoint point = [containerView convertPoint:cursorOrigin toView:webView]; + if (point.y < 0.0) { + return point; + } + + NSInteger displayWidth = [[webView stringByEvaluatingJavaScriptFromString:@"window.innerWidth"] integerValue]; + if (displayWidth <= 0) { + return point; + } + + CGFloat scale = CGRectGetWidth([webView frame]) / (CGFloat)displayWidth; + if (scale <= 0.0) { + return point; + } + + point.x /= scale; + point.y /= scale; + return point; +} + +- (NSString *)evaluateResolvedElementJavaScriptAtPoint:(CGPoint)point + webView:(BrowserWebView *)webView + body:(NSString *)body { + if (webView == nil) { + return @""; + } + + NSInteger pointX = (NSInteger)llround(point.x); + NSInteger pointY = (NSInteger)llround(point.y); + NSString *script = [NSString stringWithFormat: + @"(function(){" + "var x=%ld;" + "var y=%ld;" + "var interactiveSelector=\"%@\";" + "var editableSelector=\"%@\";" + "function resolveElement(root, px, py) {" + "if (!root || typeof root.elementFromPoint !== 'function') { return null; }" + "var element = root.elementFromPoint(px, py);" + "while (element) {" + "if (element.shadowRoot && typeof element.shadowRoot.elementFromPoint === 'function') {" + "var shadowRect = element.getBoundingClientRect();" + "var shadowElement = resolveElement(element.shadowRoot, px - shadowRect.left, py - shadowRect.top);" + "if (shadowElement && shadowElement !== element) {" + "element = shadowElement;" + "continue;" + "}" + "}" + "if (element.tagName === 'IFRAME') {" + "try {" + "var frameRect = element.getBoundingClientRect();" + "var frameDocument = element.contentDocument;" + "var frameElement = resolveElement(frameDocument, px - frameRect.left, py - frameRect.top);" + "if (frameElement) {" + "element = frameElement;" + "continue;" + "}" + "} catch (error) {}" + "}" + "return element;" + "}" + "return null;" + "}" + "function closestMatch(element, selector) {" + "while (element) {" + "if (element.matches && element.matches(selector)) { return element; }" + "element = element.parentElement;" + "}" + "return null;" + "}" + "var resolvedElement = resolveElement(document, x, y);" + "var interactiveElement = closestMatch(resolvedElement, interactiveSelector);" + "var editableElement = closestMatch(resolvedElement, editableSelector);" + "%@" + "})()", + (long)pointX, + (long)pointY, + kInteractiveElementSelector, + kEditableElementSelector, + body]; + return [webView stringByEvaluatingJavaScriptFromString:script] ?: @""; +} + +- (NSString *)evaluateEditableElementJavaScriptAtPoint:(CGPoint)point + webView:(BrowserWebView *)webView + body:(NSString *)body { + NSString *wrappedBody = [NSString stringWithFormat: + @"function browserIsEditableCandidate(element) {" + "if (!element) { return false; }" + "var tagName = element.tagName ? element.tagName.toLowerCase() : '';" + "if (element.matches && element.matches(editableSelector)) { return true; }" + "if (tagName === 'textarea' || tagName === 'select') { return true; }" + "if (element.isContentEditable) { return true; }" + "return false;" + "}" + "function browserEditableTarget() {" + "var stored = window.__browserLastEditableElement;" + "if (stored && stored.isConnected && browserIsEditableCandidate(stored)) { return stored; }" + "var active = document.activeElement;" + "if (active && browserIsEditableCandidate(active)) {" + "window.__browserLastEditableElement = active;" + "return active;" + "}" + "var candidate = editableElement || interactiveElement || resolvedElement;" + "if (candidate && browserIsEditableCandidate(candidate)) {" + "window.__browserLastEditableElement = candidate;" + "return candidate;" + "}" + "if (candidate && candidate.closest) {" + "var fallback = candidate.closest(editableSelector) || candidate.closest('textarea, select');" + "if (fallback && browserIsEditableCandidate(fallback)) {" + "window.__browserLastEditableElement = fallback;" + "return fallback;" + "}" + "}" + "return null;" + "}" + "%@", + body]; + return [self evaluateResolvedElementJavaScriptAtPoint:point webView:webView body:wrappedBody]; +} + +- (NSString *)evaluateHoverStateJavaScriptAtPoint:(CGPoint)point + webView:(BrowserWebView *)webView { + if (webView == nil) { + return @"false"; + } + + return [self evaluateResolvedElementJavaScriptAtPoint:point + webView:webView + body:@"var candidate = interactiveElement || resolvedElement;" + "while (candidate) {" + "if (candidate.matches && candidate.matches(interactiveSelector)) { return 'true'; }" + "candidate = candidate.parentElement;" + "}" + "return 'false';"]; +} + +- (NSString *)javaScriptEscapedString:(NSString *)string { + NSString *escapedString = string ?: @""; + escapedString = [escapedString stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"]; + escapedString = [escapedString stringByReplacingOccurrencesOfString:@"'" withString:@"\\'"]; + escapedString = [escapedString stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"]; + escapedString = [escapedString stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"]; + escapedString = [escapedString stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"]; + escapedString = [escapedString stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"]; + return escapedString; +} + +- (NSDictionary *)videoInfoAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView { + NSString *result = [self evaluateResolvedElementJavaScriptAtPoint:point + webView:webView + body:@"function browserAbsoluteURL(url) {" + "if (!url) { return ''; }" + "try { return String(new URL(url, document.baseURI).toString()); } catch (error) { return String(url); }" + "}" + "function browserVideoContainsPoint(video) {" + "if (!video || typeof video.getBoundingClientRect !== 'function') { return false; }" + "var rect = video.getBoundingClientRect();" + "return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;" + "}" + "function browserResolveVideoElement() {" + "var candidate = resolvedElement;" + "while (candidate) {" + "if (candidate.tagName && candidate.tagName.toLowerCase() === 'video') { return candidate; }" + "candidate = candidate.parentElement;" + "}" + "var videos = document.querySelectorAll('video');" + "var bestVisibleVideo = null;" + "var bestVisibleArea = 0;" + "for (var i = 0; i < videos.length; i++) {" + "var video = videos[i];" + "if (browserVideoContainsPoint(video)) { return video; }" + "if (!video || typeof video.getBoundingClientRect !== 'function') { continue; }" + "var rect = video.getBoundingClientRect();" + "var visibleWidth = Math.max(0, Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0));" + "var visibleHeight = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0));" + "var visibleArea = visibleWidth * visibleHeight;" + "if (visibleArea <= 0) { continue; }" + "if (!video.paused && !video.ended && video.readyState >= 2) { return video; }" + "if (visibleArea > bestVisibleArea) {" + "bestVisibleArea = visibleArea;" + "bestVisibleVideo = video;" + "}" + "}" + "return bestVisibleVideo;" + "}" + "function browserResolvePrimarySource(video) {" + "if (!video) { return ''; }" + "if (video.currentSrc) { return browserAbsoluteURL(video.currentSrc); }" + "if (video.src) { return browserAbsoluteURL(video.src); }" + "var sources = video.querySelectorAll('source');" + "for (var i = 0; i < sources.length; i++) {" + "var sourceSrc = sources[i].src || sources[i].getAttribute('src') || '';" + "if (sourceSrc) { return browserAbsoluteURL(sourceSrc); }" + "}" + "return '';" + "}" + "function browserResolveSourceList(video) {" + "var values = [];" + "if (!video) { return values; }" + "if (video.currentSrc) { values.push(browserAbsoluteURL(video.currentSrc)); }" + "if (video.src && values.indexOf(browserAbsoluteURL(video.src)) === -1) { values.push(browserAbsoluteURL(video.src)); }" + "var sources = video.querySelectorAll('source');" + "for (var i = 0; i < sources.length; i++) {" + "var sourceSrc = sources[i].src || sources[i].getAttribute('src') || '';" + "sourceSrc = browserAbsoluteURL(sourceSrc);" + "if (sourceSrc && values.indexOf(sourceSrc) === -1) { values.push(sourceSrc); }" + "}" + "return values;" + "}" + "var video = browserResolveVideoElement();" + "if (!video) { return ''; }" + "return JSON.stringify({" + "src: browserResolvePrimarySource(video)," + "sources: browserResolveSourceList(video)," + "poster: browserAbsoluteURL(video.poster || '')," + "title: video.getAttribute('title') || video.getAttribute('aria-label') || document.title || ''," + "tagName: video.tagName ? video.tagName.toLowerCase() : ''," + "paused: !!video.paused" + "});"]; + return [self JSONObjectFromJavaScriptString:result]; +} + +- (NSDictionary *)linkInfoAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView { + NSString *result = [self evaluateResolvedElementJavaScriptAtPoint:point + webView:webView + body:@"function browserAbsoluteURL(url) {" + "if (!url) { return ''; }" + "try { return String(new URL(url, document.baseURI).toString()); } catch (error) { return String(url); }" + "}" + "var element = interactiveElement || resolvedElement;" + "if (!element) { return ''; }" + "var link = null;" + "if (element.closest) {" + "link = element.closest('a[href]');" + "}" + "if (!link && element.tagName && element.tagName.toLowerCase() === 'a') {" + "link = element;" + "}" + "if (!link) { return ''; }" + "var href = link.href || (link.getAttribute ? (link.getAttribute('href') || '') : '');" + "if (!href) { return ''; }" + "var target = link.getAttribute ? (link.getAttribute('target') || '') : '';" + "return JSON.stringify({" + "href: browserAbsoluteURL(href)," + "target: String(target || '').toLowerCase()" + "});"]; + return [self JSONObjectFromJavaScriptString:result]; +} + +- (NSDictionary *)directVideoInfoAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView { + NSString *result = [self evaluateResolvedElementJavaScriptAtPoint:point + webView:webView + body:@"function browserAbsoluteURL(url) {" + "if (!url) { return ''; }" + "try { return String(new URL(url, document.baseURI).toString()); } catch (error) { return String(url); }" + "}" + "function browserVideoContainsPoint(video) {" + "if (!video || typeof video.getBoundingClientRect !== 'function') { return false; }" + "var rect = video.getBoundingClientRect();" + "return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;" + "}" + "function browserResolveDirectVideoElement() {" + "var candidate = resolvedElement;" + "while (candidate) {" + "if (candidate.tagName && candidate.tagName.toLowerCase() === 'video') { return candidate; }" + "candidate = candidate.parentElement;" + "}" + "var videos = document.querySelectorAll('video');" + "for (var i = 0; i < videos.length; i++) {" + "if (browserVideoContainsPoint(videos[i])) { return videos[i]; }" + "}" + "return null;" + "}" + "function browserResolvePrimarySource(video) {" + "if (!video) { return ''; }" + "if (video.currentSrc) { return browserAbsoluteURL(video.currentSrc); }" + "if (video.src) { return browserAbsoluteURL(video.src); }" + "var sources = video.querySelectorAll('source');" + "for (var i = 0; i < sources.length; i++) {" + "var sourceSrc = sources[i].src || sources[i].getAttribute('src') || '';" + "if (sourceSrc) { return browserAbsoluteURL(sourceSrc); }" + "}" + "return '';" + "}" + "function browserResolveSourceList(video) {" + "var values = [];" + "if (!video) { return values; }" + "if (video.currentSrc) { values.push(browserAbsoluteURL(video.currentSrc)); }" + "if (video.src && values.indexOf(browserAbsoluteURL(video.src)) === -1) { values.push(browserAbsoluteURL(video.src)); }" + "var sources = video.querySelectorAll('source');" + "for (var i = 0; i < sources.length; i++) {" + "var sourceSrc = sources[i].src || sources[i].getAttribute('src') || '';" + "sourceSrc = browserAbsoluteURL(sourceSrc);" + "if (sourceSrc && values.indexOf(sourceSrc) === -1) { values.push(sourceSrc); }" + "}" + "return values;" + "}" + "var video = browserResolveDirectVideoElement();" + "if (!video) { return ''; }" + "return JSON.stringify({" + "src: browserResolvePrimarySource(video)," + "sources: browserResolveSourceList(video)," + "poster: browserAbsoluteURL(video.poster || '')," + "title: video.getAttribute('title') || video.getAttribute('aria-label') || document.title || ''," + "tagName: video.tagName ? video.tagName.toLowerCase() : ''," + "paused: !!video.paused" + "});"]; + return [self JSONObjectFromJavaScriptString:result]; +} + +- (BOOL)isVideoActivationTargetAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView { + NSString *result = [self evaluateResolvedElementJavaScriptAtPoint:point + webView:webView + body:@"function browserVideoContainsPoint(video) {" + "if (!video || typeof video.getBoundingClientRect !== 'function') { return false; }" + "var rect = video.getBoundingClientRect();" + "return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;" + "}" + "function browserLooksLikeDismissControl(element) {" + "if (!element) { return false; }" + "var tagName = element.tagName ? element.tagName.toLowerCase() : '';" + "var role = element.getAttribute ? (element.getAttribute('role') || '').toLowerCase() : '';" + "var isButtonLike = tagName === 'button' || tagName === 'a' || role === 'button' ||" + "(typeof element.onclick === 'function') ||" + "(typeof element.tabIndex === 'number' && element.tabIndex >= 0);" + "if (!isButtonLike) { return false; }" + "var text = (element.textContent || '').replace(/\\s+/g, ' ').trim();" + "var shortText = text.length <= 3 ? text : '';" + "var label = [" + "element.id || ''," + "element.className || ''," + "element.getAttribute ? (element.getAttribute('aria-label') || '') : ''," + "element.getAttribute ? (element.getAttribute('title') || '') : ''," + "element.getAttribute ? (element.getAttribute('name') || '') : ''," + "shortText" + "].join(' ').toLowerCase();" + "if (!label) { return false; }" + "if ((/(^|[^a-z])(close|dismiss|cancel|collapse|minimi[sz]e|exit)([^a-z]|$)/).test(label) ||" + "label.indexOf('modal-close') !== -1 ||" + "label.indexOf('icon-close') !== -1) {" + "return true;" + "}" + "if (shortText === '×' || shortText === '✕' || shortText === '✖' || shortText === 'x' || shortText === 'X') {" + "return true;" + "}" + "return false;" + "}" + "function browserMatchesVideoIntent(element) {" + "if (!element) { return false; }" + "if (browserLooksLikeDismissControl(element)) { return false; }" + "var value = [" + "element.id || ''," + "element.className || ''," + "element.getAttribute ? (element.getAttribute('role') || '') : ''," + "element.getAttribute ? (element.getAttribute('aria-label') || '') : ''," + "element.getAttribute ? (element.getAttribute('aria-description') || '') : ''," + "element.getAttribute ? (element.getAttribute('title') || '') : ''" + "].join(' ').toLowerCase();" + "if (!value) { return false; }" + "var hasPlayWord = (/(^|[^a-z])(play|watch|resume|trailer)([^a-z]|$)/).test(value);" + "var hasControlWord = value.indexOf('ytp-') !== -1 || value.indexOf('video-play') !== -1 || value.indexOf('play-button') !== -1;" + "return hasPlayWord || hasControlWord;" + "}" + "function browserContainsVideoAncestor(element) {" + "var candidate = element;" + "var depth = 0;" + "while (candidate && depth < 10) {" + "if (candidate.tagName && candidate.tagName.toLowerCase() === 'video') { return true; }" + "candidate = candidate.parentElement;" + "depth += 1;" + "}" + "return false;" + "}" + "function browserLooksLikeNavigationTarget(element) {" + "if (!element) { return false; }" + "var tagName = element.tagName ? element.tagName.toLowerCase() : '';" + "if (tagName !== 'a' && tagName !== 'button') { return false; }" + "if (element.closest && element.closest('nav, header, [role=\"navigation\"], .ac-gn, .ac-gn-content, .ac-gn-list, .globalnav')) { return true; }" + "if (tagName === 'a') {" + "var href = (element.getAttribute ? (element.getAttribute('href') || '') : '').trim().toLowerCase();" + "if (href && href !== '#' && href.indexOf('javascript:') !== 0 && href.indexOf('mailto:') !== 0 && href.indexOf('tel:') !== 0) {" + "return true;" + "}" + "}" + "return false;" + "}" + "if (browserLooksLikeNavigationTarget(interactiveElement) &&" + "!browserContainsVideoAncestor(interactiveElement) &&" + "!browserMatchesVideoIntent(interactiveElement)) {" + "return 'false';" + "}" + "var candidate = resolvedElement;" + "var candidateDepth = 0;" + "while (candidate && candidateDepth < 10) {" + "if (browserLooksLikeDismissControl(candidate)) { return 'false'; }" + "if (candidate.tagName && candidate.tagName.toLowerCase() === 'video') { return 'true'; }" + "if (browserMatchesVideoIntent(candidate)) { return 'true'; }" + "candidate = candidate.parentElement;" + "candidateDepth += 1;" + "}" + "var videos = document.querySelectorAll('video');" + "for (var i = 0; i < videos.length; i++) {" + "if (browserVideoContainsPoint(videos[i])) {" + "if (browserLooksLikeNavigationTarget(interactiveElement) && !browserContainsVideoAncestor(interactiveElement)) { return 'false'; }" + "if (browserLooksLikeDismissControl(interactiveElement) || browserLooksLikeDismissControl(resolvedElement)) { return 'false'; }" + "return 'true';" + "}" + "}" + "return 'false';"]; + return [result isEqualToString:@"true"]; +} + +- (BOOL)isVideoDismissTargetAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView { + NSString *result = [self evaluateResolvedElementJavaScriptAtPoint:point + webView:webView + body:@"function browserLooksLikeDismissControl(element) {" + "if (!element) { return false; }" + "var tagName = element.tagName ? element.tagName.toLowerCase() : '';" + "var role = element.getAttribute ? (element.getAttribute('role') || '').toLowerCase() : '';" + "var isButtonLike = tagName === 'button' || tagName === 'a' || role === 'button' ||" + "(typeof element.onclick === 'function') ||" + "(typeof element.tabIndex === 'number' && element.tabIndex >= 0);" + "if (!isButtonLike) { return false; }" + "var text = (element.textContent || '').replace(/\\s+/g, ' ').trim();" + "var shortText = text.length <= 3 ? text : '';" + "var label = [" + "element.id || ''," + "element.className || ''," + "element.getAttribute ? (element.getAttribute('aria-label') || '') : ''," + "element.getAttribute ? (element.getAttribute('title') || '') : ''," + "element.getAttribute ? (element.getAttribute('name') || '') : ''," + "shortText" + "].join(' ').toLowerCase();" + "if (!label) { return false; }" + "if ((/(^|[^a-z])(close|dismiss|cancel|collapse|minimi[sz]e|exit)([^a-z]|$)/).test(label) ||" + "label.indexOf('modal-close') !== -1 ||" + "label.indexOf('icon-close') !== -1) {" + "return true;" + "}" + "if (shortText === '×' || shortText === '✕' || shortText === '✖' || shortText === 'x' || shortText === 'X') {" + "return true;" + "}" + "return false;" + "}" + "var candidate = interactiveElement || resolvedElement;" + "while (candidate) {" + "if (browserLooksLikeDismissControl(candidate)) { return 'true'; }" + "candidate = candidate.parentElement;" + "}" + "return 'false';"]; + return [result isEqualToString:@"true"]; +} + +- (NSDictionary *)primedVideoInfoAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView { + [self evaluateResolvedElementJavaScriptAtPoint:point + webView:webView + body:@"window.__browserPrimedVideoInfo = '';" + "function browserAbsoluteURL(url) {" + "if (!url) { return ''; }" + "try { return String(new URL(url, document.baseURI).toString()); } catch (error) { return String(url); }" + "}" + "function browserVideoContainsPoint(video) {" + "if (!video || typeof video.getBoundingClientRect !== 'function') { return false; }" + "var rect = video.getBoundingClientRect();" + "return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;" + "}" + "function browserResolveVideoElement() {" + "var candidate = resolvedElement;" + "while (candidate) {" + "if (candidate.tagName && candidate.tagName.toLowerCase() === 'video') { return candidate; }" + "candidate = candidate.parentElement;" + "}" + "var videos = document.querySelectorAll('video');" + "var bestVisibleVideo = null;" + "var bestVisibleArea = 0;" + "for (var i = 0; i < videos.length; i++) {" + "var video = videos[i];" + "if (browserVideoContainsPoint(video)) { return video; }" + "if (!video || typeof video.getBoundingClientRect !== 'function') { continue; }" + "var rect = video.getBoundingClientRect();" + "var visibleWidth = Math.max(0, Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0));" + "var visibleHeight = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0));" + "var visibleArea = visibleWidth * visibleHeight;" + "if (visibleArea <= 0) { continue; }" + "if (!video.paused && !video.ended && video.readyState >= 2) { return video; }" + "if (visibleArea > bestVisibleArea) {" + "bestVisibleArea = visibleArea;" + "bestVisibleVideo = video;" + "}" + "}" + "return bestVisibleVideo;" + "}" + "function browserResolvePrimarySource(video) {" + "if (!video) { return ''; }" + "if (video.currentSrc) { return browserAbsoluteURL(video.currentSrc); }" + "if (video.src) { return browserAbsoluteURL(video.src); }" + "var sources = video.querySelectorAll('source');" + "for (var i = 0; i < sources.length; i++) {" + "var sourceSrc = sources[i].src || sources[i].getAttribute('src') || '';" + "if (sourceSrc) { return browserAbsoluteURL(sourceSrc); }" + "}" + "return '';" + "}" + "function browserResolveSourceList(video) {" + "var values = [];" + "if (!video) { return values; }" + "if (video.currentSrc) { values.push(browserAbsoluteURL(video.currentSrc)); }" + "if (video.src && values.indexOf(browserAbsoluteURL(video.src)) === -1) { values.push(browserAbsoluteURL(video.src)); }" + "var sources = video.querySelectorAll('source');" + "for (var i = 0; i < sources.length; i++) {" + "var sourceSrc = sources[i].src || sources[i].getAttribute('src') || '';" + "sourceSrc = browserAbsoluteURL(sourceSrc);" + "if (sourceSrc && values.indexOf(sourceSrc) === -1) { values.push(sourceSrc); }" + "}" + "return values;" + "}" + "function browserStoreVideoInfo(video) {" + "if (!video) { return; }" + "window.__browserPrimedVideoInfo = JSON.stringify({" + "src: browserResolvePrimarySource(video)," + "sources: browserResolveSourceList(video)," + "poster: browserAbsoluteURL(video.poster || '')," + "title: video.getAttribute('title') || video.getAttribute('aria-label') || document.title || ''," + "tagName: video.tagName ? video.tagName.toLowerCase() : ''," + "paused: !!video.paused" + "});" + "}" + "var video = browserResolveVideoElement();" + "if (!video) { return 'no-video'; }" + "try { if (video.focus) { video.focus(); } } catch (error) {}" + "var finish = function() {" + "try { if (video.pause) { video.pause(); } } catch (error) {}" + "browserStoreVideoInfo(video);" + "};" + "try {" + "var playResult = video.play ? video.play() : null;" + "if (playResult && typeof playResult.then === 'function') {" + "playResult.then(function() { setTimeout(finish, 0); }).catch(function() { setTimeout(finish, 0); });" + "} else {" + "setTimeout(finish, 0);" + "}" + "} catch (error) {" + "setTimeout(finish, 0);" + "}" + "return 'started';"]; + + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:0.75]; + while ([deadline timeIntervalSinceNow] > 0) { + NSString *result = [webView stringByEvaluatingJavaScriptFromString:@"window.__browserPrimedVideoInfo || ''"]; + NSDictionary *videoInfo = [self JSONObjectFromJavaScriptString:result]; + if (videoInfo.count > 0) { + return videoInfo; + } + [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.02]]; + } + + return nil; +} + +- (NSDictionary *)activateVideoTargetAtDOMPoint:(CGPoint)point + webView:(BrowserWebView *)webView + timeout:(NSTimeInterval)timeout { + if (webView == nil) { + return nil; + } + + NSTimeInterval effectiveTimeout = timeout > 0.0 ? timeout : 1.5; + [self evaluateResolvedElementJavaScriptAtPoint:point + webView:webView + body:@"function browserMatchesVideoIntent(element) {" + "if (!element) { return false; }" + "if (browserLooksLikeDismissControl(element)) { return false; }" + "var value = [" + "element.id || ''," + "element.className || ''," + "element.getAttribute ? (element.getAttribute('role') || '') : ''," + "element.getAttribute ? (element.getAttribute('aria-label') || '') : ''," + "element.getAttribute ? (element.getAttribute('aria-description') || '') : ''," + "element.getAttribute ? (element.getAttribute('title') || '') : ''" + "].join(' ').toLowerCase();" + "if (!value) { return false; }" + "var hasPlayWord = (/(^|[^a-z])(play|watch|resume|trailer)([^a-z]|$)/).test(value);" + "var hasControlWord = value.indexOf('ytp-') !== -1 || value.indexOf('video-play') !== -1 || value.indexOf('play-button') !== -1;" + "return hasPlayWord || hasControlWord;" + "}" + "function browserLooksLikeDismissControl(element) {" + "if (!element) { return false; }" + "var tagName = element.tagName ? element.tagName.toLowerCase() : '';" + "var role = element.getAttribute ? (element.getAttribute('role') || '').toLowerCase() : '';" + "var isButtonLike = tagName === 'button' || tagName === 'a' || role === 'button' ||" + "(typeof element.onclick === 'function') ||" + "(typeof element.tabIndex === 'number' && element.tabIndex >= 0);" + "if (!isButtonLike) { return false; }" + "var text = (element.textContent || '').replace(/\\s+/g, ' ').trim();" + "var shortText = text.length <= 3 ? text : '';" + "var label = [" + "element.id || ''," + "element.className || ''," + "element.getAttribute ? (element.getAttribute('aria-label') || '') : ''," + "element.getAttribute ? (element.getAttribute('title') || '') : ''," + "element.getAttribute ? (element.getAttribute('name') || '') : ''," + "shortText" + "].join(' ').toLowerCase();" + "if (!label) { return false; }" + "if ((/(^|[^a-z])(close|dismiss|cancel|collapse|minimi[sz]e|exit)([^a-z]|$)/).test(label) ||" + "label.indexOf('modal-close') !== -1 ||" + "label.indexOf('icon-close') !== -1) {" + "return true;" + "}" + "if (shortText === '×' || shortText === '✕' || shortText === '✖' || shortText === 'x' || shortText === 'X') {" + "return true;" + "}" + "return false;" + "}" + "function browserActivationTarget() {" + "function browserContainsVideoAncestor(element) {" + "var candidate = element;" + "var depth = 0;" + "while (candidate && depth < 10) {" + "if (candidate.tagName && candidate.tagName.toLowerCase() === 'video') { return true; }" + "candidate = candidate.parentElement;" + "depth += 1;" + "}" + "return false;" + "}" + "function browserLooksLikeNavigationTarget(element) {" + "if (!element) { return false; }" + "var tagName = element.tagName ? element.tagName.toLowerCase() : '';" + "if (tagName !== 'a' && tagName !== 'button') { return false; }" + "if (element.closest && element.closest('nav, header, [role=\"navigation\"], .ac-gn, .ac-gn-content, .ac-gn-list, .globalnav')) { return true; }" + "if (tagName === 'a') {" + "var href = (element.getAttribute ? (element.getAttribute('href') || '') : '').trim().toLowerCase();" + "if (href && href !== '#' && href.indexOf('javascript:') !== 0 && href.indexOf('mailto:') !== 0 && href.indexOf('tel:') !== 0) {" + "return true;" + "}" + "}" + "return false;" + "}" + "var candidate = interactiveElement || resolvedElement;" + "if (browserLooksLikeNavigationTarget(candidate) &&" + "!browserContainsVideoAncestor(candidate) &&" + "!browserMatchesVideoIntent(candidate)) {" + "return null;" + "}" + "var depth = 0;" + "while (candidate && depth < 10) {" + "if (browserLooksLikeDismissControl(candidate)) { return null; }" + "if (candidate.tagName && candidate.tagName.toLowerCase() === 'video') { return candidate; }" + "if (browserMatchesVideoIntent(candidate)) { return candidate; }" + "candidate = candidate.parentElement;" + "depth += 1;" + "}" + "return interactiveElement || resolvedElement || null;" + "}" + "function dispatchPointerLikeEvent(target, type, constructorName) {" + "if (!target) { return; }" + "try {" + "var Constructor = window[constructorName];" + "if (Constructor) {" + "var event = new Constructor(type, { bubbles: true, cancelable: true, composed: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y, button: 0, buttons: 1, pointerType: 'mouse' });" + "target.dispatchEvent(event);" + "return;" + "}" + "} catch (error) {}" + "var mouseEvent = document.createEvent('MouseEvents');" + "mouseEvent.initMouseEvent(type, true, true, window, 1, x, y, x, y, false, false, false, false, 0, null);" + "target.dispatchEvent(mouseEvent);" + "}" + "var target = browserActivationTarget();" + "if (!target) { return 'no-target'; }" + "try { if (target.focus) { target.focus(); } } catch (error) {}" + "dispatchPointerLikeEvent(target, 'pointerdown', 'PointerEvent');" + "dispatchPointerLikeEvent(target, 'mousedown', 'MouseEvent');" + "dispatchPointerLikeEvent(target, 'pointerup', 'PointerEvent');" + "dispatchPointerLikeEvent(target, 'mouseup', 'MouseEvent');" + "if (typeof target.click === 'function') { target.click(); }" + "else { dispatchPointerLikeEvent(target, 'click', 'MouseEvent'); }" + "return 'clicked';"]; + + NSDictionary *lastVideoInfo = nil; + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:effectiveTimeout]; + while ([deadline timeIntervalSinceNow] > 0) { + NSDictionary *directVideoInfo = [self directVideoInfoAtDOMPoint:point webView:webView]; + NSString *directSrc = [directVideoInfo[@"src"] isKindOfClass:[NSString class]] ? directVideoInfo[@"src"] : @""; + NSArray *directSources = [directVideoInfo[@"sources"] isKindOfClass:[NSArray class]] ? directVideoInfo[@"sources"] : @[]; + if (directSrc.length > 0 || directSources.count > 0) { + return directVideoInfo; + } + + NSDictionary *videoInfo = [self videoInfoAtDOMPoint:point webView:webView]; + if (videoInfo.count > 0) { + lastVideoInfo = videoInfo; + NSString *videoSrc = [videoInfo[@"src"] isKindOfClass:[NSString class]] ? videoInfo[@"src"] : @""; + NSArray *videoSources = [videoInfo[@"sources"] isKindOfClass:[NSArray class]] ? videoInfo[@"sources"] : @[]; + if (videoSrc.length > 0 || videoSources.count > 0) { + return videoInfo; + } + } + + [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + } + + return lastVideoInfo; +} + +- (NSDictionary *)JSONObjectFromJavaScriptString:(NSString *)string { + if (string.length == 0) { + return nil; + } + + NSData *data = [string dataUsingEncoding:NSUTF8StringEncoding]; + if (data == nil) { + return nil; + } + + id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if (![object isKindOfClass:[NSDictionary class]]) { + return nil; + } + + return object; +} + +@end diff --git a/_Project/Browser/BrowserMenuCoordinator.h b/_Project/Browser/BrowserMenuCoordinator.h new file mode 100644 index 0000000..5e32f3f --- /dev/null +++ b/_Project/Browser/BrowserMenuCoordinator.h @@ -0,0 +1,35 @@ +#import +#import "BrowserWebView.h" + +@class BrowserPreferencesStore; + +@protocol BrowserMenuCoordinatorHost + +@property (nonatomic, readonly) BrowserWebView *browserWebView; +@property (nonatomic, copy) NSString *browserPreviousURL; +@property (nonatomic) NSUInteger browserTextFontSize; +@property (nonatomic) BOOL browserFullscreenVideoPlaybackEnabled; +@property (nonatomic, readonly) BOOL browserTopMenuShowing; + +- (void)browserPresentViewController:(UIViewController *)viewController; +- (void)browserLoadHomePage; +- (void)browserShowHints; +- (void)browserShowTabOverview; +- (void)browserCreateNewTabLoadingHomePage:(BOOL)loadHomePage; +- (void)browserHideTopNav; +- (void)browserShowTopNav; +- (void)browserUpdateTextFontSize; +- (void)browserCaptureSnapshotForCurrentTab; +- (void)browserRecreateActiveWebViewPreservingCurrentURL; +- (void)browserBringCursorToFront; +- (void)browserPlayVideoUnderCursorIfAvailable; + +@end + +@interface BrowserMenuCoordinator : NSObject + +- (instancetype)initWithHost:(id)host + preferencesStore:(BrowserPreferencesStore *)preferencesStore; +- (void)showAdvancedMenu; + +@end diff --git a/_Project/Browser/BrowserMenuCoordinator.m b/_Project/Browser/BrowserMenuCoordinator.m new file mode 100644 index 0000000..4489cb4 --- /dev/null +++ b/_Project/Browser/BrowserMenuCoordinator.m @@ -0,0 +1,1107 @@ +#import "BrowserMenuCoordinator.h" +#import "BrowserPreferencesStore.h" +#import "BrowserWebView.h" + +static UIColor *MenuTextColor(void) { + if (@available(tvOS 13, *)) { + return UIColor.labelColor; + } else { + return UIColor.blackColor; + } +} + +static NSString * const kBrowserMediaDiagnosticsLogPrefix = @"[MediaDiagnostics]"; +static NSString * const kBrowserWebKitMediaPrefsLogPrefix = @"[WebKitMediaPrefs]"; + +typedef void (^BrowserAdvancedMenuItemHandler)(void); + +@interface BrowserAdvancedMenuItem : NSObject + +@property (nonatomic, copy) NSString *title; +@property (nonatomic) UIAlertActionStyle style; +@property (nonatomic, copy) BrowserAdvancedMenuItemHandler handler; + ++ (instancetype)itemWithTitle:(NSString *)title + style:(UIAlertActionStyle)style + handler:(BrowserAdvancedMenuItemHandler)handler; + +@end + +@implementation BrowserAdvancedMenuItem + ++ (instancetype)itemWithTitle:(NSString *)title + style:(UIAlertActionStyle)style + handler:(BrowserAdvancedMenuItemHandler)handler { + BrowserAdvancedMenuItem *item = [BrowserAdvancedMenuItem new]; + item.title = title ?: @""; + item.style = style; + item.handler = handler; + return item; +} + +@end + +@interface BrowserAdvancedMenuSection : NSObject + +@property (nonatomic, copy) NSString *title; +@property (nonatomic, copy) NSArray *items; + ++ (instancetype)sectionWithTitle:(NSString *)title items:(NSArray *)items; + +@end + +@implementation BrowserAdvancedMenuSection + ++ (instancetype)sectionWithTitle:(NSString *)title items:(NSArray *)items { + BrowserAdvancedMenuSection *section = [BrowserAdvancedMenuSection new]; + section.title = title ?: @""; + section.items = [items copy] ?: @[]; + return section; +} + +@end + +@interface BrowserAdvancedMenuViewController : UIViewController + +- (instancetype)initWithTitle:(NSString *)title + sections:(NSArray *)sections + footerText:(NSString *)footerText; + +@end + +@interface BrowserAdvancedMenuViewController () + +@property (nonatomic, copy) NSString *menuTitle; +@property (nonatomic, copy) NSArray *sections; +@property (nonatomic, copy) NSString *footerText; +@property (nonatomic) UIView *dimView; +@property (nonatomic) UIVisualEffectView *panelView; +@property (nonatomic) UITableView *tableView; +@property (nonatomic) NSLayoutConstraint *panelTrailingConstraint; +@property (nonatomic) CGFloat panelWidth; +@property (nonatomic) BOOL didAnimateIn; +@property (nonatomic) BOOL dismissalInProgress; +@property (nonatomic) BOOL usingNativeGlassEffect; + +@end + +@implementation BrowserAdvancedMenuViewController + +- (UIVisualEffect *)panelEffect { + Class glassEffectClass = NSClassFromString(@"UIGlassEffect"); + if (glassEffectClass != Nil) { + id effect = [[glassEffectClass alloc] init]; + if ([effect isKindOfClass:[UIVisualEffect class]]) { + return (UIVisualEffect *)effect; + } + } + return [UIBlurEffect effectWithStyle:UIBlurEffectStyleLight]; +} + +- (instancetype)initWithTitle:(NSString *)title + sections:(NSArray *)sections + footerText:(NSString *)footerText { + self = [super initWithNibName:nil bundle:nil]; + if (self) { + _menuTitle = [title copy] ?: @"Menu"; + _sections = [sections copy] ?: @[]; + _footerText = [footerText copy] ?: @""; + self.modalPresentationStyle = UIModalPresentationOverCurrentContext; + self.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = UIColor.clearColor; + + self.panelWidth = MIN(MAX(CGRectGetWidth(UIScreen.mainScreen.bounds) * 0.38, 480.0), 700.0); + self.usingNativeGlassEffect = (NSClassFromString(@"UIGlassEffect") != Nil); + + UIView *dimView = [UIView new]; + dimView.translatesAutoresizingMaskIntoConstraints = NO; + dimView.backgroundColor = self.usingNativeGlassEffect ? UIColor.clearColor : [UIColor colorWithWhite:0.0 alpha:0.45]; + dimView.alpha = 0.0; + [self.view addSubview:dimView]; + self.dimView = dimView; + + UIVisualEffectView *panelView = [[UIVisualEffectView alloc] initWithEffect:[self panelEffect]]; + panelView.translatesAutoresizingMaskIntoConstraints = NO; + panelView.backgroundColor = UIColor.clearColor; + panelView.layer.cornerRadius = 28.0; + panelView.layer.masksToBounds = YES; + panelView.layer.borderWidth = self.usingNativeGlassEffect ? 0.0 : 1.0; + panelView.layer.borderColor = [UIColor colorWithWhite:1.0 alpha:0.28].CGColor; + [self.view addSubview:panelView]; + self.panelView = panelView; + + UIView *panelTint = nil; + if (!self.usingNativeGlassEffect) { + panelTint = [UIView new]; + panelTint.translatesAutoresizingMaskIntoConstraints = NO; + panelTint.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.08]; + [panelView.contentView addSubview:panelTint]; + } + + UILabel *titleLabel = [UILabel new]; + titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + titleLabel.text = self.menuTitle; + titleLabel.font = [UIFont boldSystemFontOfSize:34.0]; + titleLabel.textAlignment = NSTextAlignmentLeft; + if (@available(tvOS 13.0, *)) { + titleLabel.textColor = UIColor.labelColor; + } else { + titleLabel.textColor = UIColor.whiteColor; + } + [panelView.contentView addSubview:titleLabel]; + + UIView *separator = [UIView new]; + separator.translatesAutoresizingMaskIntoConstraints = NO; + if (@available(tvOS 13.0, *)) { + separator.backgroundColor = UIColor.separatorColor; + } else { + separator.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.2]; + } + [panelView.contentView addSubview:separator]; + + UILabel *footerLabel = [UILabel new]; + footerLabel.translatesAutoresizingMaskIntoConstraints = NO; + footerLabel.text = self.footerText; + footerLabel.textAlignment = NSTextAlignmentCenter; + footerLabel.font = [UIFont systemFontOfSize:20.0 weight:UIFontWeightRegular]; + if (@available(tvOS 13.0, *)) { + footerLabel.textColor = UIColor.secondaryLabelColor; + } else { + footerLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.7]; + } + [panelView.contentView addSubview:footerLabel]; + + UITableView *tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain]; + tableView.translatesAutoresizingMaskIntoConstraints = NO; + tableView.dataSource = self; + tableView.delegate = self; + tableView.rowHeight = 68.0; + tableView.backgroundColor = UIColor.clearColor; + tableView.preservesSuperviewLayoutMargins = NO; + tableView.layoutMargins = UIEdgeInsetsZero; + if (@available(tvOS 11.0, *)) { + tableView.directionalLayoutMargins = NSDirectionalEdgeInsetsZero; + tableView.insetsLayoutMarginsFromSafeArea = NO; + } + tableView.cellLayoutMarginsFollowReadableWidth = NO; + tableView.clipsToBounds = NO; + tableView.layer.cornerRadius = 0.0; + tableView.remembersLastFocusedIndexPath = YES; + tableView.contentInset = UIEdgeInsetsZero; + tableView.showsVerticalScrollIndicator = NO; + if (@available(tvOS 11.0, *)) { + tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + tableView.insetsContentViewsToSafeArea = NO; + } + [panelView.contentView addSubview:tableView]; + self.tableView = tableView; + + self.panelTrailingConstraint = [panelView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor + constant:self.panelWidth + 32.0]; + + NSMutableArray *constraints = [NSMutableArray arrayWithArray:@[ + [dimView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [dimView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [dimView.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [dimView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + + [panelView.widthAnchor constraintEqualToConstant:self.panelWidth], + [panelView.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:16.0], + [panelView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor constant:-16.0], + self.panelTrailingConstraint, + + [titleLabel.leadingAnchor constraintEqualToAnchor:panelView.leadingAnchor constant:32.0], + [titleLabel.trailingAnchor constraintEqualToAnchor:panelView.trailingAnchor constant:-32.0], + [titleLabel.topAnchor constraintEqualToAnchor:panelView.topAnchor constant:26.0], + + [separator.leadingAnchor constraintEqualToAnchor:panelView.leadingAnchor constant:20.0], + [separator.trailingAnchor constraintEqualToAnchor:panelView.trailingAnchor constant:-20.0], + [separator.topAnchor constraintEqualToAnchor:titleLabel.bottomAnchor constant:20.0], + [separator.heightAnchor constraintEqualToConstant:1.0], + + [tableView.topAnchor constraintEqualToAnchor:separator.bottomAnchor constant:12.0], + [tableView.leadingAnchor constraintEqualToAnchor:panelView.leadingAnchor constant:16.0], + [tableView.trailingAnchor constraintEqualToAnchor:panelView.trailingAnchor constant:-16.0], + [tableView.bottomAnchor constraintEqualToAnchor:footerLabel.topAnchor constant:-8.0], + + [footerLabel.leadingAnchor constraintEqualToAnchor:panelView.leadingAnchor constant:24.0], + [footerLabel.trailingAnchor constraintEqualToAnchor:panelView.trailingAnchor constant:-24.0], + [footerLabel.bottomAnchor constraintEqualToAnchor:panelView.bottomAnchor constant:-12.0], + ]]; + if (panelTint != nil) { + [constraints addObject:[panelTint.leadingAnchor constraintEqualToAnchor:panelView.contentView.leadingAnchor]]; + [constraints addObject:[panelTint.trailingAnchor constraintEqualToAnchor:panelView.contentView.trailingAnchor]]; + [constraints addObject:[panelTint.topAnchor constraintEqualToAnchor:panelView.contentView.topAnchor]]; + [constraints addObject:[panelTint.bottomAnchor constraintEqualToAnchor:panelView.contentView.bottomAnchor]]; + } + [NSLayoutConstraint activateConstraints:constraints]; +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + if (self.didAnimateIn) { + return; + } + self.didAnimateIn = YES; + self.panelTrailingConstraint.constant = -16.0; + [UIView animateWithDuration:0.28 + delay:0.0 + options:UIViewAnimationOptionCurveEaseOut + animations:^{ + self.dimView.alpha = 1.0; + [self.view layoutIfNeeded]; + } completion:nil]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + + if (!self.isBeingDismissed || self.dismissalInProgress) { + return; + } + + self.panelTrailingConstraint.constant = self.panelWidth + 32.0; + id coordinator = self.transitionCoordinator; + if (coordinator != nil) { + [coordinator animateAlongsideTransition:^(__unused id context) { + self.dimView.alpha = 0.0; + [self.view layoutIfNeeded]; + } completion:nil]; + return; + } + + [UIView animateWithDuration:0.22 + delay:0.0 + options:UIViewAnimationOptionCurveEaseIn + animations:^{ + self.dimView.alpha = 0.0; + [self.view layoutIfNeeded]; + } completion:nil]; +} + +- (void)dismissMenuWithCompletion:(void (^)(void))completion { + if (self.dismissalInProgress) { + return; + } + self.dismissalInProgress = YES; + self.panelTrailingConstraint.constant = self.panelWidth + 32.0; + [UIView animateWithDuration:0.22 + delay:0.0 + options:UIViewAnimationOptionCurveEaseIn + animations:^{ + self.dimView.alpha = 0.0; + [self.view layoutIfNeeded]; + } completion:^(__unused BOOL finished) { + [self dismissViewControllerAnimated:NO completion:completion]; + }]; +} + +- (NSInteger)numberOfSectionsInTableView:(__unused UITableView *)tableView { + return (NSInteger)self.sections.count; +} + +- (NSInteger)tableView:(__unused UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + if (section < 0 || section >= (NSInteger)self.sections.count) { + return 0; + } + return (NSInteger)self.sections[(NSUInteger)section].items.count; +} + +- (NSString *)tableView:(__unused UITableView *)tableView titleForHeaderInSection:(NSInteger)section { + if (section < 0 || section >= (NSInteger)self.sections.count) { + return nil; + } + return self.sections[(NSUInteger)section].title; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + static NSString * const kCellIdentifier = @"BrowserAdvancedMenuCell"; + static NSInteger const kMenuTitleLabelTag = 9191; + static NSInteger const kMenuFocusBackgroundTag = 9292; + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier]; + if (cell == nil) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:kCellIdentifier]; + cell.backgroundColor = UIColor.clearColor; + cell.contentView.backgroundColor = UIColor.clearColor; + cell.clipsToBounds = NO; + cell.contentView.clipsToBounds = NO; + cell.preservesSuperviewLayoutMargins = NO; + cell.layoutMargins = UIEdgeInsetsZero; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + if ([cell respondsToSelector:@selector(setFocusStyle:)]) { + [cell setValue:@(1) forKey:@"focusStyle"]; // UITableViewCellFocusStyleCustom + } + + UIView *focusBackgroundView = [UIView new]; + focusBackgroundView.translatesAutoresizingMaskIntoConstraints = NO; + focusBackgroundView.tag = kMenuFocusBackgroundTag; + focusBackgroundView.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.18]; + focusBackgroundView.layer.cornerRadius = 12.0; + focusBackgroundView.alpha = 0.0; + [cell.contentView addSubview:focusBackgroundView]; + + UILabel *titleLabel = [UILabel new]; + titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + titleLabel.tag = kMenuTitleLabelTag; + titleLabel.font = [UIFont systemFontOfSize:31.0 weight:UIFontWeightRegular]; + titleLabel.textAlignment = NSTextAlignmentLeft; + titleLabel.numberOfLines = 1; + [cell.contentView addSubview:titleLabel]; + + [NSLayoutConstraint activateConstraints:@[ + [focusBackgroundView.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:0.0], + [focusBackgroundView.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:0.0], + [focusBackgroundView.topAnchor constraintEqualToAnchor:cell.contentView.topAnchor constant:2.0], + [focusBackgroundView.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor constant:-2.0], + + [titleLabel.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:24.0], + [titleLabel.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-24.0], + [titleLabel.centerYAnchor constraintEqualToAnchor:cell.contentView.centerYAnchor], + ]]; + } + + BrowserAdvancedMenuSection *section = self.sections[(NSUInteger)indexPath.section]; + BrowserAdvancedMenuItem *item = section.items[(NSUInteger)indexPath.row]; + UILabel *titleLabel = (UILabel *)[cell.contentView viewWithTag:kMenuTitleLabelTag]; + UIView *focusBackgroundView = [cell.contentView viewWithTag:kMenuFocusBackgroundTag]; + titleLabel.text = item.title; + UIColor *titleColor = nil; + if (item.style == UIAlertActionStyleDestructive) { + titleColor = UIColor.redColor; + } else if (@available(tvOS 13.0, *)) { + titleColor = UIColor.labelColor; + } else { + titleColor = UIColor.whiteColor; + } + titleLabel.textColor = titleColor; + focusBackgroundView.alpha = cell.isFocused ? 1.0 : 0.0; + return cell; +} + +- (void)tableView:(UITableView *)tableView +didUpdateFocusInContext:(UITableViewFocusUpdateContext *)context +withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator { + NSIndexPath *previousIndexPath = context.previouslyFocusedIndexPath; + NSIndexPath *nextIndexPath = context.nextFocusedIndexPath; + + UITableViewCell *previousCell = previousIndexPath ? [tableView cellForRowAtIndexPath:previousIndexPath] : nil; + UITableViewCell *nextCell = nextIndexPath ? [tableView cellForRowAtIndexPath:nextIndexPath] : nil; + + [coordinator addCoordinatedAnimations:^{ + UIView *previousFocusBackground = [previousCell.contentView viewWithTag:9292]; + previousFocusBackground.alpha = 0.0; + + UIView *nextFocusBackground = [nextCell.contentView viewWithTag:9292]; + nextFocusBackground.alpha = 1.0; + } completion:nil]; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + BrowserAdvancedMenuSection *section = self.sections[(NSUInteger)indexPath.section]; + BrowserAdvancedMenuItem *item = section.items[(NSUInteger)indexPath.row]; + BrowserAdvancedMenuItemHandler handler = item.handler; + [self dismissMenuWithCompletion:^{ + if (handler != nil) { + handler(); + } + }]; +} + +- (NSArray> *)preferredFocusEnvironments { + return @[self.tableView]; +} + +- (void)pressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event { + UIPress *press = presses.anyObject; + if (press != nil && press.type == UIPressTypeMenu) { + [self dismissMenuWithCompletion:nil]; + return; + } + [super pressesEnded:presses withEvent:event]; +} + +@end + +@interface BrowserMenuCoordinator () + +@property (nonatomic, weak) id host; +@property (nonatomic) BrowserPreferencesStore *preferencesStore; + +@end + +@implementation BrowserMenuCoordinator + +- (instancetype)initWithHost:(id)host + preferencesStore:(BrowserPreferencesStore *)preferencesStore { + self = [super init]; + if (self) { + _host = host; + _preferencesStore = preferencesStore ?: [BrowserPreferencesStore new]; + [_preferencesStore ensureUserAgentConsistency]; + } + return self; +} + +- (void)showAdvancedMenu { + BrowserAdvancedMenuViewController *menuViewController = [[BrowserAdvancedMenuViewController alloc] initWithTitle:@"tvOS Browser" + sections:[self advancedMenuSections] + footerText:[self advancedMenuFooterText]]; + [self.host browserPresentViewController:menuViewController]; +} + +- (UIAlertController *)browserAlertControllerWithTitle:(NSString *)title message:(NSString *)message { + return [UIAlertController alertControllerWithTitle:title + message:message + preferredStyle:UIAlertControllerStyleAlert]; +} + +- (UIAlertAction *)browserActionWithTitle:(NSString *)title + style:(UIAlertActionStyle)style + handler:(void (^ __nullable)(UIAlertAction *action))handler { + return [UIAlertAction actionWithTitle:title style:style handler:handler]; +} + +- (BrowserAdvancedMenuItem *)advancedMenuItemWithTitle:(NSString *)title + style:(UIAlertActionStyle)style + handler:(BrowserAdvancedMenuItemHandler)handler { + return [BrowserAdvancedMenuItem itemWithTitle:title style:style handler:handler]; +} + +- (UIAlertAction *)browserCancelAction { + return [self browserActionWithTitle:nil style:UIAlertActionStyleCancel handler:nil]; +} + +- (BOOL)stringHasVisibleContent:(NSString *)string { + NSString *trimmedString = [string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + return trimmedString.length > 0; +} + +- (NSString *)displayTitleForStoredTitle:(NSString *)storedTitle + URLString:(NSString *)URLString + includeURL:(BOOL)includeURL { + NSString *displayTitle = [self stringHasVisibleContent:storedTitle] ? storedTitle : URLString; + if (includeURL && [self stringHasVisibleContent:storedTitle] && [self stringHasVisibleContent:URLString]) { + return [NSString stringWithFormat:@"%@ - %@", storedTitle, URLString]; + } + return displayTitle ?: @""; +} + +- (void)loadStoredURLString:(NSString *)URLString { + if (![self stringHasVisibleContent:URLString]) { + return; + } + NSURL *URL = [NSURL URLWithString:URLString]; + if (URL == nil) { + return; + } + + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:URL]; + NSString *userAgent = self.preferencesStore.userAgent; + if (userAgent.length > 0) { + [request setValue:userAgent forHTTPHeaderField:@"User-Agent"]; + } + [[self.host browserWebView] loadRequest:request]; +} + +- (void)saveFavoritesArray:(NSArray *)favorites { + [[NSUserDefaults standardUserDefaults] setObject:favorites forKey:@"FAVORITES"]; + [[NSUserDefaults standardUserDefaults] synchronize]; +} + +- (void)presentDeleteFavoriteMenu { + NSArray *favorites = [[NSUserDefaults standardUserDefaults] arrayForKey:@"FAVORITES"]; + UIAlertController *alertController = [self browserAlertControllerWithTitle:@"Delete a Favorite" + message:@"Select a Favorite to Delete"]; + __weak typeof(self) weakSelf = self; + + [favorites enumerateObjectsUsingBlock:^(NSArray *entry, NSUInteger index, BOOL *stop) { + NSString *URLString = entry.count > 0 ? entry[0] : @""; + NSString *title = entry.count > 1 ? entry[1] : @""; + if (![weakSelf stringHasVisibleContent:URLString]) { + return; + } + + NSString *displayTitle = [weakSelf displayTitleForStoredTitle:title URLString:URLString includeURL:NO]; + [alertController addAction:[weakSelf browserActionWithTitle:displayTitle + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + NSMutableArray *updatedFavorites = [favorites mutableCopy]; + [updatedFavorites removeObjectAtIndex:index]; + [weakSelf saveFavoritesArray:updatedFavorites]; + }]]; + }]; + + [alertController addAction:[self browserCancelAction]]; + [self.host browserPresentViewController:alertController]; +} + +- (void)presentAddFavoritePrompt { + NSString *pageTitle = [[self.host browserWebView] title]; + NSURLRequest *request = [[self.host browserWebView] request]; + NSString *currentURL = request.URL.absoluteString ?: @""; + UIAlertController *alertController = [self browserAlertControllerWithTitle:@"Name New Favorite" + message:currentURL]; + __weak typeof(self) weakSelf = self; + + [alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) { + textField.keyboardType = UIKeyboardTypeDefault; + textField.placeholder = @"Name New Favorite"; + textField.text = pageTitle; + textField.textColor = MenuTextColor(); + [textField setReturnKeyType:UIReturnKeyDone]; + }]; + + [alertController addAction:[self browserActionWithTitle:@"Save" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction *action) { + UITextField *titleTextField = alertController.textFields.firstObject; + NSString *savedTitle = titleTextField.text; + if (![weakSelf stringHasVisibleContent:savedTitle]) { + savedTitle = currentURL; + } + + NSArray *favoriteEntry = @[currentURL, savedTitle ?: @""]; + NSMutableArray *favorites = [[[NSUserDefaults standardUserDefaults] arrayForKey:@"FAVORITES"] mutableCopy]; + if (favorites == nil) { + favorites = [NSMutableArray array]; + } + [favorites addObject:favoriteEntry]; + [weakSelf saveFavoritesArray:favorites]; + }]]; + [alertController addAction:[self browserCancelAction]]; + [self.host browserPresentViewController:alertController]; +} + +- (void)presentFavoritesMenu { + NSArray *favorites = [[NSUserDefaults standardUserDefaults] arrayForKey:@"FAVORITES"]; + UIAlertController *alertController = [self browserAlertControllerWithTitle:@"Favorites" message:@""]; + __weak typeof(self) weakSelf = self; + + [favorites enumerateObjectsUsingBlock:^(NSArray *entry, NSUInteger index, BOOL *stop) { + NSString *URLString = entry.count > 0 ? entry[0] : @""; + NSString *title = entry.count > 1 ? entry[1] : @""; + NSString *displayTitle = [weakSelf displayTitleForStoredTitle:title URLString:URLString includeURL:NO]; + if (![weakSelf stringHasVisibleContent:displayTitle]) { + return; + } + + [alertController addAction:[weakSelf browserActionWithTitle:displayTitle + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + [weakSelf loadStoredURLString:URLString]; + }]]; + }]; + + if (favorites.count > 0) { + [alertController addAction:[self browserActionWithTitle:@"Delete a Favorite" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction *action) { + [weakSelf presentDeleteFavoriteMenu]; + }]]; + } + + [alertController addAction:[self browserActionWithTitle:@"Add Current Page to Favorites" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + [weakSelf presentAddFavoritePrompt]; + }]]; + [alertController addAction:[self browserCancelAction]]; + [self.host browserPresentViewController:alertController]; +} + +- (void)presentHistoryMenu { + NSArray *historyEntries = [[NSUserDefaults standardUserDefaults] arrayForKey:@"HISTORY"]; + UIAlertController *alertController = [self browserAlertControllerWithTitle:@"History" message:@""]; + __weak typeof(self) weakSelf = self; + + if (historyEntries.count > 0) { + [alertController addAction:[self browserActionWithTitle:@"Clear History" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction *action) { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"HISTORY"]; + [[NSUserDefaults standardUserDefaults] synchronize]; + }]]; + } + + [historyEntries enumerateObjectsUsingBlock:^(NSArray *entry, NSUInteger index, BOOL *stop) { + NSString *URLString = entry.count > 0 ? entry[0] : @""; + NSString *title = entry.count > 1 ? entry[1] : @""; + NSString *displayTitle = [weakSelf displayTitleForStoredTitle:title URLString:URLString includeURL:YES]; + if (![weakSelf stringHasVisibleContent:displayTitle]) { + return; + } + + [alertController addAction:[weakSelf browserActionWithTitle:displayTitle + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + [weakSelf loadStoredURLString:URLString]; + }]]; + }]; + + [alertController addAction:[self browserCancelAction]]; + [self.host browserPresentViewController:alertController]; +} + +- (void)applyUserAgent:(NSString *)userAgent mobileMode:(BOOL)mobileMode { + self.preferencesStore.userAgent = userAgent; + self.preferencesStore.mobileModeEnabled = mobileMode; + + NSURLRequest *request = [[self.host browserWebView] request]; + if (request != nil && [self stringHasVisibleContent:request.URL.absoluteString]) { + [self.host browserCaptureSnapshotForCurrentTab]; + } + + __weak typeof(self) weakSelf = self; + [BrowserWebView resetWebsiteDataWithCompletion:^{ + [weakSelf.host browserRecreateActiveWebViewPreservingCurrentURL]; + [weakSelf.host browserBringCursorToFront]; + }]; +} + +- (void)setPageScalingEnabled:(BOOL)enabled { + self.preferencesStore.scalePagesToFit = enabled; + [[self.host browserWebView] setScalesPageToFit:enabled]; + if (enabled) { + [[self.host browserWebView] setContentMode:UIViewContentModeScaleAspectFit]; + } + [[self.host browserWebView] reload]; +} + +- (void)clearCacheAndReload { + __weak typeof(self) weakSelf = self; + [BrowserWebView clearCachedDataWithCompletion:^{ + weakSelf.host.browserPreviousURL = @""; + [[weakSelf.host browserWebView] reload]; + }]; +} + +- (void)clearCookiesAndReload { + __weak typeof(self) weakSelf = self; + [BrowserWebView clearCookiesWithCompletion:^{ + weakSelf.host.browserPreviousURL = @""; + [[weakSelf.host browserWebView] reload]; + }]; +} + +- (NSString *)mediaDiagnosticsJavaScript { + return @"(function(){" + "function canPlay(type){" + "try {" + "var video=document.createElement('video');" + "if (!video || typeof video.canPlayType!=='function') { return 'n/a'; }" + "var value=video.canPlayType(type);" + "return value ? String(value) : '';" + "} catch (error) { return 'error'; }" + "}" + "function mse(type){" + "try {" + "if (typeof MediaSource==='undefined' || typeof MediaSource.isTypeSupported!=='function') { return 'n/a'; }" + "return MediaSource.isTypeSupported(type) ? 'yes' : 'no';" + "} catch (error) { return 'error'; }" + "}" + "function probeGlobal(name){" + "try {" + "var value=window[name];" + "if (typeof value==='undefined') { return 'undefined'; }" + "if (value === null) { return 'null'; }" + "return typeof value;" + "} catch (error) { return 'error'; }" + "}" + "var video=document.querySelector('video');" + "var result={" + "href:(window.location && window.location.href) ? String(window.location.href) : ''," + "title:(document && document.title) ? String(document.title) : ''," + "userAgent:(navigator && navigator.userAgent) ? String(navigator.userAgent) : ''," + "platform:(navigator && navigator.platform) ? String(navigator.platform) : ''," + "mediaSource:(typeof MediaSource!=='undefined') ? 'yes' : 'no'," + "managedMediaSource:(typeof ManagedMediaSource!=='undefined') ? 'yes' : 'no'," + "mediaCapabilities:(typeof navigator.mediaCapabilities!=='undefined') ? 'yes' : 'no'," + "videoElement:video ? 'yes' : 'no'," + "videoSrc:video ? String(video.currentSrc||video.src||'') : ''," + "globalMediaSource:probeGlobal('MediaSource')," + "globalManagedMediaSource:probeGlobal('ManagedMediaSource')," + "globalWebKitMediaSource:probeGlobal('WebKitMediaSource')," + "globalSourceBuffer:probeGlobal('SourceBuffer')," + "globalManagedSourceBuffer:probeGlobal('ManagedSourceBuffer')," + "globalWebKitSourceBuffer:probeGlobal('WebKitSourceBuffer')," + "hls:canPlay('application/vnd.apple.mpegurl')," + "mp4H264:canPlay('video/mp4; codecs=\"avc1.42E01E, mp4a.40.2\"')," + "mp4Hevc:canPlay('video/mp4; codecs=\"hvc1.1.6.L93.B0, mp4a.40.2\"')," + "webmVp9:canPlay('video/webm; codecs=\"vp9\"')," + "mp4Av1:canPlay('video/mp4; codecs=\"av01.0.05M.08, mp4a.40.2\"')," + "webmAv1:canPlay('video/webm; codecs=\"av01.0.05M.08\"')," + "mseMp4H264:mse('video/mp4; codecs=\"avc1.42E01E, mp4a.40.2\"')," + "mseWebmVp9:mse('video/webm; codecs=\"vp9\"')," + "mseMp4Av1:mse('video/mp4; codecs=\"av01.0.05M.08, mp4a.40.2\"')," + "mseWebmAv1:mse('video/webm; codecs=\"av01.0.05M.08\"')" + "};" + "return JSON.stringify(result);" + "})()"; +} + +- (NSDictionary *)mediaDiagnosticsDictionary { + NSString *resultString = [[self.host browserWebView] stringByEvaluatingJavaScriptFromString:[self mediaDiagnosticsJavaScript]]; + if (![self stringHasVisibleContent:resultString]) { + return nil; + } + + NSData *resultData = [resultString dataUsingEncoding:NSUTF8StringEncoding]; + if (resultData == nil) { + return nil; + } + + id object = [NSJSONSerialization JSONObjectWithData:resultData options:0 error:nil]; + if (![object isKindOfClass:[NSDictionary class]]) { + return nil; + } + return object; +} + +- (NSString *)stringValueForDiagnosticsKey:(NSString *)key dictionary:(NSDictionary *)dictionary fallback:(NSString *)fallback { + id value = dictionary[key]; + if ([value isKindOfClass:[NSString class]] && [self stringHasVisibleContent:value]) { + return value; + } + if ([value respondsToSelector:@selector(stringValue)]) { + NSString *stringValue = [value stringValue]; + if ([self stringHasVisibleContent:stringValue]) { + return stringValue; + } + } + return fallback; +} + +- (void)presentMediaDiagnostics { + NSDictionary *diagnostics = [self mediaDiagnosticsDictionary]; + if (diagnostics == nil) { + UIAlertController *alertController = [self browserAlertControllerWithTitle:@"Media Diagnostics" + message:@"The page did not return diagnostics data."]; + [alertController addAction:[self browserCancelAction]]; + [self.host browserPresentViewController:alertController]; + return; + } + + BOOL mobileModeEnabled = self.preferencesStore.mobileModeEnabled; + NSString *message = [NSString stringWithFormat: + @"Mode: %@\n" + "URL: %@\n" + "UA: %@\n\n" + "MediaSource: %@\n" + "ManagedMediaSource: %@\n" + "MediaCapabilities: %@\n" + "Video Element: %@\n" + "Video Src: %@\n\n" + "Global MediaSource: %@\n" + "Global ManagedMediaSource: %@\n" + "Global WebKitMediaSource: %@\n" + "Global SourceBuffer: %@\n" + "Global ManagedSourceBuffer: %@\n" + "Global WebKitSourceBuffer: %@\n\n" + "canPlay HLS: %@\n" + "canPlay MP4 H.264: %@\n" + "canPlay MP4 HEVC: %@\n" + "canPlay WebM VP9: %@\n" + "canPlay MP4 AV1: %@\n" + "canPlay WebM AV1: %@\n\n" + "MSE MP4 H.264: %@\n" + "MSE WebM VP9: %@\n" + "MSE MP4 AV1: %@\n" + "MSE WebM AV1: %@", + mobileModeEnabled ? @"Mobile" : @"Desktop", + [self stringValueForDiagnosticsKey:@"href" dictionary:diagnostics fallback:@"Unavailable"], + [self stringValueForDiagnosticsKey:@"userAgent" dictionary:diagnostics fallback:@"Unavailable"], + [self stringValueForDiagnosticsKey:@"mediaSource" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"managedMediaSource" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"mediaCapabilities" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"videoElement" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"videoSrc" dictionary:diagnostics fallback:@"Unavailable"], + [self stringValueForDiagnosticsKey:@"globalMediaSource" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"globalManagedMediaSource" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"globalWebKitMediaSource" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"globalSourceBuffer" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"globalManagedSourceBuffer" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"globalWebKitSourceBuffer" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"hls" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"mp4H264" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"mp4Hevc" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"webmVp9" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"mp4Av1" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"webmAv1" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"mseMp4H264" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"mseWebmVp9" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"mseMp4Av1" dictionary:diagnostics fallback:@"n/a"], + [self stringValueForDiagnosticsKey:@"mseWebmAv1" dictionary:diagnostics fallback:@"n/a"]]; + + NSLog(@"%@ %@", kBrowserMediaDiagnosticsLogPrefix, message); + + UIAlertController *alertController = [self browserAlertControllerWithTitle:@"Media Diagnostics" + message:message]; + [alertController addAction:[self browserCancelAction]]; + [self.host browserPresentViewController:alertController]; +} + +- (void)presentWebKitRuntimeMediaPreferences { + NSString *report = [[self.host browserWebView] runtimeMediaPreferenceReport]; + if (![self stringHasVisibleContent:report]) { + report = @"No runtime WebKit media preference information was returned."; + } + + NSLog(@"%@ %@", kBrowserWebKitMediaPrefsLogPrefix, report); + + NSString *message = report; + if (message.length > 1800) { + message = [[message substringToIndex:1800] stringByAppendingString:@"\n\nFull report logged to console."]; + } + + UIAlertController *alertController = [self browserAlertControllerWithTitle:@"WebKit Media Prefs" + message:message]; + [alertController addAction:[self browserCancelAction]]; + [self.host browserPresentViewController:alertController]; +} + +- (BrowserAdvancedMenuItem *)topNavigationVisibilityMenuItem { + NSString *title = self.host.browserTopMenuShowing ? @"Hide Top Navigation bar" : @"Show Top Navigation bar"; + return [self advancedMenuItemWithTitle:title + style:UIAlertActionStyleDefault + handler:^{ + if (self.host.browserTopMenuShowing) { + UIAlertController *alertController = [self browserAlertControllerWithTitle:@"Hide Top Navigation bar?" + message:@"You can still open the side menu by double-tapping the Play/Pause button."]; + [alertController addAction:[self browserActionWithTitle:@"Cancel" + style:UIAlertActionStyleCancel + handler:nil]]; + [alertController addAction:[self browserActionWithTitle:@"Hide Bar" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction *action) { + [self.host browserHideTopNav]; + }]]; + [self.host browserPresentViewController:alertController]; + } else { + [self.host browserShowTopNav]; + } + }]; +} + +- (BrowserAdvancedMenuItem *)homePageMenuItem { + return [self advancedMenuItemWithTitle:@"Go To Home Page" + style:UIAlertActionStyleDefault + handler:^{ + [self.host browserLoadHomePage]; + }]; +} + +- (BrowserAdvancedMenuItem *)setCurrentPageAsHomePageMenuItem { + return [self advancedMenuItemWithTitle:@"Set Current Page As Home Page" + style:UIAlertActionStyleDefault + handler:^{ + NSURLRequest *request = [[self.host browserWebView] request]; + if (request != nil && [self stringHasVisibleContent:request.URL.absoluteString]) { + self.preferencesStore.homePageURLString = request.URL.absoluteString; + } + }]; +} + +- (BrowserAdvancedMenuItem *)usageGuideMenuItem { + return [self advancedMenuItemWithTitle:@"Usage Guide" + style:UIAlertActionStyleDefault + handler:^{ + [self.host browserShowHints]; + }]; +} + +- (UIAlertAction *)wkWebViewProofOfConceptAction { + return [self browserActionWithTitle:@"Open WKWebView PoC" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + Class proofOfConceptControllerClass = NSClassFromString(@"BrowserWKWebViewProofOfConceptViewController"); + UIViewController *viewController = nil; + if (proofOfConceptControllerClass != Nil) { + viewController = [proofOfConceptControllerClass new]; + viewController.modalPresentationStyle = UIModalPresentationFullScreen; + } else { + viewController = [UIAlertController alertControllerWithTitle:@"WKWebView PoC Missing" + message:@"The proof-of-concept controller was not compiled into this build." + preferredStyle:UIAlertControllerStyleAlert]; + [(UIAlertController *)viewController addAction:[self browserCancelAction]]; + } + [self.host browserPresentViewController:viewController]; + }]; +} + +- (BrowserAdvancedMenuItem *)showTabsMenuItem { + return [self advancedMenuItemWithTitle:@"Show Tabs" + style:UIAlertActionStyleDefault + handler:^{ + [self.host browserShowTabOverview]; + }]; +} + +- (BrowserAdvancedMenuItem *)newTabMenuItem { + return [self advancedMenuItemWithTitle:@"Open New Tab" + style:UIAlertActionStyleDefault + handler:^{ + [self.host browserCreateNewTabLoadingHomePage:YES]; + }]; +} + +- (BrowserAdvancedMenuItem *)favoritesMenuItem { + return [self advancedMenuItemWithTitle:@"Favorites" + style:UIAlertActionStyleDefault + handler:^{ + [self presentFavoritesMenu]; + }]; +} + +- (BrowserAdvancedMenuItem *)historyMenuItem { + return [self advancedMenuItemWithTitle:@"History" + style:UIAlertActionStyleDefault + handler:^{ + [self presentHistoryMenu]; + }]; +} + +- (BrowserAdvancedMenuItem *)userAgentModeMenuItem { + BOOL mobileModeEnabled = self.preferencesStore.mobileModeEnabled; + NSString *title = mobileModeEnabled ? @"Switch To Desktop User Agent" : @"Switch To Mobile User Agent"; + NSString *userAgent = mobileModeEnabled ? BrowserPreferencesStore.desktopUserAgent : BrowserPreferencesStore.mobileUserAgent; + BOOL mobileMode = !mobileModeEnabled; + + return [self advancedMenuItemWithTitle:title + style:UIAlertActionStyleDefault + handler:^{ + [self applyUserAgent:userAgent mobileMode:mobileMode]; + }]; +} + +- (BrowserAdvancedMenuItem *)pageScalingMenuItem { + BOOL scalesPageToFit = [[self.host browserWebView] scalesPageToFit]; + NSString *title = scalesPageToFit ? @"Stop Scaling Pages to Fit" : @"Scale Pages to Fit"; + return [self advancedMenuItemWithTitle:title + style:UIAlertActionStyleDefault + handler:^{ + [self setPageScalingEnabled:!scalesPageToFit]; + }]; +} + +- (UIAlertAction *)playVideoUnderCursorAction { + return [self browserActionWithTitle:@"Play Active Video" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.host browserPlayVideoUnderCursorIfAvailable]; + }); + }]; +} + +- (BrowserAdvancedMenuItem *)fullscreenVideoPlaybackToggleMenuItem { + BOOL enabled = self.host.browserFullscreenVideoPlaybackEnabled; + NSString *title = enabled ? @"Disable Full Screen player" : @"Enable Full Screen player"; + return [self advancedMenuItemWithTitle:title + style:UIAlertActionStyleDefault + handler:^{ + self.host.browserFullscreenVideoPlaybackEnabled = !enabled; + }]; +} + +- (NSArray *)advancedMenuSections { + BrowserAdvancedMenuItem *increaseFontSizeItem = [self advancedMenuItemWithTitle:@"Increase Font Size" + style:UIAlertActionStyleDefault + handler:^{ + self.host.browserTextFontSize += 5; + [self.host browserUpdateTextFontSize]; + }]; + BrowserAdvancedMenuItem *decreaseFontSizeItem = [self advancedMenuItemWithTitle:@"Decrease Font Size" + style:UIAlertActionStyleDefault + handler:^{ + self.host.browserTextFontSize -= 5; + [self.host browserUpdateTextFontSize]; + }]; + BrowserAdvancedMenuItem *resetFontSizeItem = [self advancedMenuItemWithTitle:@"Reset Font Size" + style:UIAlertActionStyleDefault + handler:^{ + self.host.browserTextFontSize = 100; + [self.host browserUpdateTextFontSize]; + }]; + BrowserAdvancedMenuItem *mediaDiagnosticsItem = [self advancedMenuItemWithTitle:@"Media Diagnostics" + style:UIAlertActionStyleDefault + handler:^{ + [self presentMediaDiagnostics]; + }]; + BrowserAdvancedMenuItem *webkitMediaPrefsItem = [self advancedMenuItemWithTitle:@"Inspect WebKit Media Prefs" + style:UIAlertActionStyleDefault + handler:^{ + [self presentWebKitRuntimeMediaPreferences]; + }]; + BrowserAdvancedMenuItem *clearCacheItem = [self advancedMenuItemWithTitle:@"Clear Cache" + style:UIAlertActionStyleDestructive + handler:^{ + [self clearCacheAndReload]; + }]; + BrowserAdvancedMenuItem *clearCookiesItem = [self advancedMenuItemWithTitle:@"Clear Cookies" + style:UIAlertActionStyleDestructive + handler:^{ + [self clearCookiesAndReload]; + }]; + + return @[ + [BrowserAdvancedMenuSection sectionWithTitle:@"Navigation" + items:@[ + [self homePageMenuItem], + [self setCurrentPageAsHomePageMenuItem], + [self favoritesMenuItem], + [self historyMenuItem], + [self showTabsMenuItem], + [self newTabMenuItem], + ]], + [BrowserAdvancedMenuSection sectionWithTitle:@"Appearance" + items:@[ + [self topNavigationVisibilityMenuItem], + [self pageScalingMenuItem], + increaseFontSizeItem, + decreaseFontSizeItem, + resetFontSizeItem, + ]], + [BrowserAdvancedMenuSection sectionWithTitle:@"Video Playback" + items:@[ + [self fullscreenVideoPlaybackToggleMenuItem], + ]], + [BrowserAdvancedMenuSection sectionWithTitle:@"Compatibility" + items:@[ + [self userAgentModeMenuItem], + ]], + [BrowserAdvancedMenuSection sectionWithTitle:@"Diagnostics" + items:@[ + mediaDiagnosticsItem, + webkitMediaPrefsItem, + ]], + [BrowserAdvancedMenuSection sectionWithTitle:@"Maintenance" + items:@[ + clearCacheItem, + clearCookiesItem, + ]], + [BrowserAdvancedMenuSection sectionWithTitle:@"Help" + items:@[ + [self usageGuideMenuItem], + ]], + ]; +} + +- (NSString *)advancedMenuFooterText { + NSDictionary *infoDictionary = NSBundle.mainBundle.infoDictionary; + NSString *version = infoDictionary[@"CFBundleShortVersionString"]; + BOOL hasVersion = [self stringHasVisibleContent:version]; + + if (hasVersion) { + return [NSString stringWithFormat:@"Version %@", version]; + } + return @""; +} + +@end diff --git a/_Project/Browser/BrowserNativeVideoAssetLoader.h b/_Project/Browser/BrowserNativeVideoAssetLoader.h new file mode 100644 index 0000000..15f0be5 --- /dev/null +++ b/_Project/Browser/BrowserNativeVideoAssetLoader.h @@ -0,0 +1,17 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface BrowserNativeVideoAssetLoader : NSObject + +- (instancetype)initWithRequestHeaders:(nullable NSDictionary *)requestHeaders + cookies:(nullable NSArray *)cookies NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +- (NSURL *)assetURLForPlaybackURL:(NSURL *)playbackURL; +- (BOOL)attachToAsset:(id)asset; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserNativeVideoAssetLoader.m b/_Project/Browser/BrowserNativeVideoAssetLoader.m new file mode 100644 index 0000000..6de50e8 --- /dev/null +++ b/_Project/Browser/BrowserNativeVideoAssetLoader.m @@ -0,0 +1,395 @@ +#import "BrowserNativeVideoAssetLoader.h" + +#import +#import + +static NSString * const kBrowserNativeVideoAssetLoaderLogPrefix = @"[NativeVideoAssetLoader]"; +static NSString * const kBrowserNativeVideoHTTPProxyScheme = @"browserhttp"; +static NSString * const kBrowserNativeVideoHTTPSProxyScheme = @"browserhttps"; + +@interface BrowserNativeVideoAssetLoader () + +@property (nonatomic, copy) NSDictionary *requestHeaders; +@property (nonatomic, copy) NSArray *cookies; +@property (nonatomic, strong) NSURLSession *session; +@property (nonatomic, strong) dispatch_queue_t resourceLoaderQueue; +@property (nonatomic, strong) NSMapTable *taskByLoadingRequest; + +@end + +@implementation BrowserNativeVideoAssetLoader + +- (void)log:(NSString *)format, ... NS_FORMAT_FUNCTION(1,2) { + va_list arguments; + va_start(arguments, format); + NSString *message = [[NSString alloc] initWithFormat:format arguments:arguments]; + va_end(arguments); + NSLog(@"%@ %@", kBrowserNativeVideoAssetLoaderLogPrefix, message); +} + +- (instancetype)initWithRequestHeaders:(NSDictionary *)requestHeaders + cookies:(NSArray *)cookies { + self = [super init]; + if (self) { + _requestHeaders = [requestHeaders copy] ?: @{}; + _cookies = [cookies copy] ?: @[]; + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; + configuration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + _session = [NSURLSession sessionWithConfiguration:configuration]; + _resourceLoaderQueue = dispatch_queue_create("com.browser.nativevideo.assetloader", DISPATCH_QUEUE_SERIAL); + _taskByLoadingRequest = [NSMapTable weakToStrongObjectsMapTable]; + } + return self; +} + +- (NSURL *)assetURLForPlaybackURL:(NSURL *)playbackURL { + NSURLComponents *components = [NSURLComponents componentsWithURL:playbackURL resolvingAgainstBaseURL:NO]; + NSString *scheme = components.scheme.lowercaseString; + if ([scheme isEqualToString:@"https"]) { + components.scheme = kBrowserNativeVideoHTTPSProxyScheme; + } else if ([scheme isEqualToString:@"http"]) { + components.scheme = kBrowserNativeVideoHTTPProxyScheme; + } + return components.URL ?: playbackURL; +} + +- (BOOL)attachToAsset:(id)asset { + if (asset == nil) { + return NO; + } + + SEL resourceLoaderSelector = NSSelectorFromString(@"resourceLoader"); + if (![asset respondsToSelector:resourceLoaderSelector]) { + return NO; + } + + AVAssetResourceLoader *resourceLoader = ((id (*)(id, SEL))objc_msgSend)(asset, resourceLoaderSelector); + [resourceLoader setDelegate:self queue:self.resourceLoaderQueue]; + return YES; +} + +- (NSURL *)playbackURLFromAssetURL:(NSURL *)assetURL { + if (assetURL == nil) { + return nil; + } + + NSURLComponents *components = [NSURLComponents componentsWithURL:assetURL resolvingAgainstBaseURL:NO]; + NSString *scheme = components.scheme.lowercaseString; + if ([scheme isEqualToString:kBrowserNativeVideoHTTPSProxyScheme]) { + components.scheme = @"https"; + } else if ([scheme isEqualToString:kBrowserNativeVideoHTTPProxyScheme]) { + components.scheme = @"http"; + } + return components.URL; +} + +- (NSString *)cookieHeaderValue { + return [self cookieHeaderValueForURL:nil]; +} + +- (BOOL)cookie:(NSHTTPCookie *)cookie matchesURL:(NSURL *)URL { + if (cookie == nil || URL == nil) { + return NO; + } + + NSString *host = URL.host.lowercaseString ?: @""; + NSString *cookieDomain = cookie.domain.lowercaseString ?: @""; + if (host.length == 0 || cookieDomain.length == 0) { + return NO; + } + + if ([cookieDomain hasPrefix:@"."]) { + cookieDomain = [cookieDomain substringFromIndex:1]; + } + + BOOL domainMatches = [host isEqualToString:cookieDomain] || [host hasSuffix:[@"." stringByAppendingString:cookieDomain]]; + if (!domainMatches) { + return NO; + } + + if (cookie.isSecure && ![URL.scheme.lowercaseString isEqualToString:@"https"]) { + return NO; + } + + NSString *cookiePath = cookie.path.length > 0 ? cookie.path : @"/"; + NSString *requestPath = URL.path.length > 0 ? URL.path : @"/"; + return [requestPath hasPrefix:cookiePath]; +} + +- (NSArray *)cookiesForURL:(NSURL *)URL { + if (self.cookies.count == 0 || URL == nil) { + return @[]; + } + + NSMutableArray *matchingCookies = [NSMutableArray array]; + for (NSHTTPCookie *cookie in self.cookies) { + if ([self cookie:cookie matchesURL:URL]) { + [matchingCookies addObject:cookie]; + } + } + return matchingCookies; +} + +- (NSString *)cookieHeaderValueForURL:(NSURL *)URL { + NSArray *cookies = URL != nil ? [self cookiesForURL:URL] : self.cookies; + if (cookies.count == 0) { + return nil; + } + NSDictionary *cookieHeaders = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies]; + return cookieHeaders[@"Cookie"]; +} + +- (NSMutableURLRequest *)requestForPlaybackURL:(NSURL *)playbackURL loadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest { + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:playbackURL]; + request.HTTPMethod = @"GET"; + request.timeoutInterval = 30.0; + + NSString *host = playbackURL.host.lowercaseString ?: @""; + BOOL isGoogleVideoHost = [host containsString:@"googlevideo.com"]; + [self.requestHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, __unused BOOL *stop) { + if (value.length > 0) { + if (isGoogleVideoHost && [key caseInsensitiveCompare:@"Origin"] == NSOrderedSame) { + return; + } + [request setValue:value forHTTPHeaderField:key]; + } + }]; + + NSString *cookieHeader = [self cookieHeaderValueForURL:playbackURL]; + if (cookieHeader.length > 0) { + [request setValue:cookieHeader forHTTPHeaderField:@"Cookie"]; + } + + AVAssetResourceLoadingDataRequest *dataRequest = loadingRequest.dataRequest; + if (dataRequest != nil) { + long long startOffset = dataRequest.currentOffset != 0 ? dataRequest.currentOffset : dataRequest.requestedOffset; + if (startOffset < 0) { + startOffset = 0; + } + + NSString *rangeHeader = nil; + if (dataRequest.requestsAllDataToEndOfResource) { + rangeHeader = [NSString stringWithFormat:@"bytes=%lld-", startOffset]; + } else if (dataRequest.requestedLength > 0) { + long long endOffset = startOffset + dataRequest.requestedLength - 1; + rangeHeader = [NSString stringWithFormat:@"bytes=%lld-%lld", startOffset, endOffset]; + } + + if (rangeHeader.length > 0) { + [request setValue:rangeHeader forHTTPHeaderField:@"Range"]; + } + } + + return request; +} + +- (BOOL)isPlaylistResponse:(NSHTTPURLResponse *)response data:(NSData *)data requestURL:(NSURL *)requestURL { + NSString *contentType = [response valueForHTTPHeaderField:@"Content-Type"].lowercaseString ?: @""; + NSString *pathExtension = requestURL.pathExtension.lowercaseString ?: @""; + if ([contentType containsString:@"mpegurl"] || [contentType containsString:@"m3u"] || [pathExtension isEqualToString:@"m3u8"]) { + return YES; + } + + if (data.length >= 7) { + NSData *prefixData = [data subdataWithRange:NSMakeRange(0, MIN((NSUInteger)128, data.length))]; + NSString *prefixString = [[NSString alloc] initWithData:prefixData encoding:NSUTF8StringEncoding]; + if ([prefixString containsString:@"#EXTM3U"]) { + return YES; + } + } + return NO; +} + +- (NSString *)proxyURLStringForPlaylistEntry:(NSString *)entry baseURL:(NSURL *)baseURL { + if (entry.length == 0) { + return entry; + } + + NSURL *resolvedURL = [NSURL URLWithString:entry relativeToURL:baseURL].absoluteURL; + if (resolvedURL == nil) { + return entry; + } + + NSString *scheme = resolvedURL.scheme.lowercaseString; + if (!([scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"])) { + return entry; + } + + return [[self assetURLForPlaybackURL:resolvedURL] absoluteString] ?: entry; +} + +- (NSString *)rewrittenPlaylistLine:(NSString *)line baseURL:(NSURL *)baseURL { + NSString *trimmedLine = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (trimmedLine.length == 0) { + return line; + } + + if (![trimmedLine hasPrefix:@"#"]) { + return [self proxyURLStringForPlaylistEntry:trimmedLine baseURL:baseURL]; + } + + NSError *error = nil; + NSRegularExpression *URIExpression = [NSRegularExpression regularExpressionWithPattern:@"URI=\"([^\"]+)\"" options:0 error:&error]; + if (URIExpression == nil || error != nil) { + return line; + } + + NSMutableString *rewrittenLine = [line mutableCopy]; + NSArray *matches = [URIExpression matchesInString:line options:0 range:NSMakeRange(0, line.length)]; + for (NSTextCheckingResult *match in [matches reverseObjectEnumerator]) { + if (match.numberOfRanges < 2) { + continue; + } + NSRange valueRange = [match rangeAtIndex:1]; + NSString *originalValue = [line substringWithRange:valueRange]; + NSString *replacementValue = [self proxyURLStringForPlaylistEntry:originalValue baseURL:baseURL]; + [rewrittenLine replaceCharactersInRange:valueRange withString:replacementValue]; + } + return rewrittenLine; +} + +- (NSData *)rewrittenPlaylistDataFromData:(NSData *)data responseURL:(NSURL *)responseURL { + NSString *playlistString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (playlistString.length == 0) { + return data; + } + + NSMutableArray *rewrittenLines = [NSMutableArray array]; + [playlistString enumerateLinesUsingBlock:^(NSString *line, __unused BOOL *stop) { + [rewrittenLines addObject:[self rewrittenPlaylistLine:line baseURL:responseURL]]; + }]; + + NSString *rewrittenString = [rewrittenLines componentsJoinedByString:@"\n"]; + if ([playlistString hasSuffix:@"\n"]) { + rewrittenString = [rewrittenString stringByAppendingString:@"\n"]; + } + return [rewrittenString dataUsingEncoding:NSUTF8StringEncoding] ?: data; +} + +- (void)fillContentInfoForLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest + response:(NSHTTPURLResponse *)response + data:(NSData *)data + requestURL:(NSURL *)requestURL { + AVAssetResourceLoadingContentInformationRequest *contentInformationRequest = loadingRequest.contentInformationRequest; + if (contentInformationRequest == nil) { + return; + } + + NSString *contentType = [response valueForHTTPHeaderField:@"Content-Type"] ?: response.MIMEType; + if ([self isPlaylistResponse:response data:data requestURL:requestURL]) { + contentType = @"application/vnd.apple.mpegurl"; + } + + if (contentType.length > 0) { + contentInformationRequest.contentType = contentType; + } + + long long expectedLength = response.expectedContentLength; + if (expectedLength > 0) { + contentInformationRequest.contentLength = expectedLength; + } else if (data.length > 0) { + contentInformationRequest.contentLength = (long long)data.length; + } + + NSString *acceptRanges = [response valueForHTTPHeaderField:@"Accept-Ranges"] ?: @""; + contentInformationRequest.byteRangeAccessSupported = [acceptRanges.lowercaseString containsString:@"bytes"] || response.statusCode == 206; +} + +- (NSData *)responseDataForLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest data:(NSData *)data { + AVAssetResourceLoadingDataRequest *dataRequest = loadingRequest.dataRequest; + if (dataRequest == nil || data.length == 0) { + return data ?: [NSData data]; + } + + if (dataRequest.requestedOffset <= 0 && dataRequest.currentOffset <= 0) { + return data; + } + + long long startOffset = dataRequest.currentOffset != 0 ? dataRequest.currentOffset : dataRequest.requestedOffset; + if (startOffset < 0 || startOffset >= (long long)data.length) { + return [NSData data]; + } + + NSUInteger length = data.length - (NSUInteger)startOffset; + if (!dataRequest.requestsAllDataToEndOfResource && dataRequest.requestedLength > 0) { + length = MIN(length, (NSUInteger)dataRequest.requestedLength); + } + return [data subdataWithRange:NSMakeRange((NSUInteger)startOffset, length)]; +} + +- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest { + __unused AVAssetResourceLoader *unusedResourceLoader = resourceLoader; + NSURL *requestURL = loadingRequest.request.URL; + NSURL *playbackURL = [self playbackURLFromAssetURL:requestURL]; + if (playbackURL == nil) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorBadURL userInfo:nil]; + [loadingRequest finishLoadingWithError:error]; + return NO; + } + + NSMutableURLRequest *request = [self requestForPlaybackURL:playbackURL loadingRequest:loadingRequest]; + [self log:@"requesting resource url=%@ range=%@", playbackURL.absoluteString ?: @"", [request valueForHTTPHeaderField:@"Range"] ?: @""]; + + __weak typeof(self) weakSelf = self; + NSURLSessionDataTask *task = [self.session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + + dispatch_async(strongSelf.resourceLoaderQueue, ^{ + [strongSelf.taskByLoadingRequest removeObjectForKey:loadingRequest]; + + if (error != nil) { + [strongSelf log:@"resource failed url=%@ error=%@", playbackURL.absoluteString ?: @"", error]; + [loadingRequest finishLoadingWithError:error]; + return; + } + + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + if (![httpResponse isKindOfClass:[NSHTTPURLResponse class]]) { + NSError *invalidResponseError = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorBadServerResponse userInfo:nil]; + [loadingRequest finishLoadingWithError:invalidResponseError]; + return; + } + + if (httpResponse.statusCode < 200 || httpResponse.statusCode >= 300) { + NSString *bodyPreview = data.length > 0 ? [[NSString alloc] initWithData:[data subdataWithRange:NSMakeRange(0, MIN((NSUInteger)160, data.length))] encoding:NSUTF8StringEncoding] : @""; + [strongSelf log:@"resource HTTP status=%ld url=%@ preview=%@", + (long)httpResponse.statusCode, + playbackURL.absoluteString ?: @"", + bodyPreview ?: @""]; + NSError *statusError = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorBadServerResponse userInfo:@{ + NSLocalizedDescriptionKey: [NSString stringWithFormat:@"HTTP %ld", (long)httpResponse.statusCode] + }]; + [loadingRequest finishLoadingWithError:statusError]; + return; + } + + NSData *responseData = data ?: [NSData data]; + if ([strongSelf isPlaylistResponse:httpResponse data:responseData requestURL:playbackURL]) { + responseData = [strongSelf rewrittenPlaylistDataFromData:responseData responseURL:playbackURL]; + } + + [strongSelf fillContentInfoForLoadingRequest:loadingRequest response:httpResponse data:responseData requestURL:playbackURL]; + NSData *dataForRequest = [strongSelf responseDataForLoadingRequest:loadingRequest data:responseData]; + if (dataForRequest.length > 0) { + [loadingRequest.dataRequest respondWithData:dataForRequest]; + } + [loadingRequest finishLoading]; + }); + }]; + + [self.taskByLoadingRequest setObject:task forKey:loadingRequest]; + [task resume]; + return YES; +} + +- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest { + __unused AVAssetResourceLoader *unusedResourceLoader = resourceLoader; + NSURLSessionDataTask *task = [self.taskByLoadingRequest objectForKey:loadingRequest]; + [task cancel]; + [self.taskByLoadingRequest removeObjectForKey:loadingRequest]; +} + +@end diff --git a/_Project/Browser/BrowserNativeVideoPlayerViewController.h b/_Project/Browser/BrowserNativeVideoPlayerViewController.h new file mode 100644 index 0000000..0d047a9 --- /dev/null +++ b/_Project/Browser/BrowserNativeVideoPlayerViewController.h @@ -0,0 +1,18 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface BrowserNativeVideoPlayerViewController : AVPlayerViewController + +- (instancetype)initWithURL:(NSURL *)URL title:(nullable NSString *)title; +- (instancetype)initWithURL:(NSURL *)URL + title:(nullable NSString *)title + requestHeaders:(nullable NSDictionary *)requestHeaders + cookies:(nullable NSArray *)cookies NS_DESIGNATED_INITIALIZER; + +- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE; +- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserNativeVideoPlayerViewController.m b/_Project/Browser/BrowserNativeVideoPlayerViewController.m new file mode 100644 index 0000000..757f10a --- /dev/null +++ b/_Project/Browser/BrowserNativeVideoPlayerViewController.m @@ -0,0 +1,270 @@ +#import "BrowserNativeVideoPlayerViewController.h" +#import "BrowserNativeVideoAssetLoader.h" + +#import + +static NSString * const kBrowserNativeVideoPlayerLogPrefix = @"[NativeVideoPlayer]"; +static NSString * const kBrowserNativePlayerInputLogPrefix = @"[InputTrace][NativePlayer]"; + +static NSString *BrowserNativePlayerPressTypeString(UIPressType type) { + switch (type) { + case UIPressTypeMenu: return @"Menu"; + case UIPressTypePlayPause: return @"PlayPause"; + case UIPressTypeSelect: return @"Select"; + case UIPressTypeUpArrow: return @"Up"; + case UIPressTypeDownArrow: return @"Down"; + case UIPressTypeLeftArrow: return @"Left"; + case UIPressTypeRightArrow: return @"Right"; + default: return [NSString stringWithFormat:@"Type-%ld", (long)type]; + } +} + +static NSString *BrowserNativePlayerPressPhaseString(UIPressPhase phase) { + switch (phase) { + case UIPressPhaseBegan: return @"Began"; + case UIPressPhaseChanged: return @"Changed"; + case UIPressPhaseStationary: return @"Stationary"; + case UIPressPhaseEnded: return @"Ended"; + case UIPressPhaseCancelled: return @"Cancelled"; + default: return [NSString stringWithFormat:@"Phase-%ld", (long)phase]; + } +} + +@interface BrowserNativeVideoPlayerViewController () + +@property (nonatomic, strong) NSURL *videoURL; +@property (nonatomic, copy) NSString *videoTitle; +@property (nonatomic, copy) NSDictionary *requestHeaders; +@property (nonatomic, copy) NSArray *requestCookies; +@property (nonatomic, strong) BrowserNativeVideoAssetLoader *assetLoader; + +@end + +@implementation BrowserNativeVideoPlayerViewController + +- (void)log:(NSString *)format, ... NS_FORMAT_FUNCTION(1,2) { + va_list arguments; + va_start(arguments, format); + NSString *message = [[NSString alloc] initWithFormat:format arguments:arguments]; + va_end(arguments); + NSLog(@"%@ %@", kBrowserNativeVideoPlayerLogPrefix, message); +} + +- (instancetype)initWithURL:(NSURL *)URL title:(NSString *)title { + return [self initWithURL:URL title:title requestHeaders:nil cookies:nil]; +} + +- (instancetype)initWithURL:(NSURL *)URL + title:(NSString *)title + requestHeaders:(NSDictionary *)requestHeaders + cookies:(NSArray *)cookies { + self = [super initWithNibName:nil bundle:nil]; + if (self) { + _videoURL = URL; + _videoTitle = [title copy] ?: @""; + _requestHeaders = [requestHeaders copy] ?: @{}; + _requestCookies = [cookies copy] ?: @[]; + self.modalPresentationStyle = UIModalPresentationFullScreen; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.view.backgroundColor = UIColor.blackColor; + self.showsPlaybackControls = YES; + AVPlayerItem *playerItem = nil; + if (self.requestHeaders.count > 0 || self.requestCookies.count > 0) { + NSMutableDictionary *assetOptions = [NSMutableDictionary dictionary]; + if (self.requestHeaders.count > 0) { + assetOptions[@"AVURLAssetHTTPHeaderFieldsKey"] = self.requestHeaders; + NSString *userAgent = self.requestHeaders[@"User-Agent"]; + if (userAgent.length > 0) { + assetOptions[@"AVURLAssetHTTPUserAgentKey"] = userAgent; + } + } + if (self.requestCookies.count > 0) { + assetOptions[@"AVURLAssetHTTPCookiesKey"] = self.requestCookies; + } + self.assetLoader = [[BrowserNativeVideoAssetLoader alloc] initWithRequestHeaders:self.requestHeaders cookies:self.requestCookies]; + NSURL *assetURL = [self.assetLoader assetURLForPlaybackURL:self.videoURL]; + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:assetURL options:assetOptions]; + [self.assetLoader attachToAsset:asset]; + playerItem = [AVPlayerItem playerItemWithAsset:asset]; + [self log:@"using request headers %@ cookies=%lu", self.requestHeaders, (unsigned long)self.requestCookies.count]; + } else { + playerItem = [AVPlayerItem playerItemWithURL:self.videoURL]; + } + self.player = [AVPlayer playerWithPlayerItem:playerItem]; + [self log:@"created player url=%@", self.videoURL.absoluteString ?: @""]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handlePlayerItemFailedToPlayToEndTime:) + name:AVPlayerItemFailedToPlayToEndTimeNotification + object:self.player.currentItem]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handlePlayerItemNewErrorLogEntry:) + name:AVPlayerItemNewErrorLogEntryNotification + object:self.player.currentItem]; + + [self.player.currentItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:NULL]; + if (@available(tvOS 10.0, *)) { + [self.player addObserver:self + forKeyPath:@"timeControlStatus" + options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew + context:NULL]; + } +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + NSLog(@"%@ viewDidAppear", kBrowserNativePlayerInputLogPrefix); + [self log:@"viewDidAppear play"]; + [self.player play]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + NSLog(@"%@ viewWillDisappear", kBrowserNativePlayerInputLogPrefix); + [self log:@"viewWillDisappear pause"]; + [self.player pause]; +} + +- (void)dealloc { + @try { + [self.player.currentItem removeObserver:self forKeyPath:@"status"]; + } @catch (__unused NSException *exception) {} + @try { + [self.player removeObserver:self forKeyPath:@"timeControlStatus"]; + } @catch (__unused NSException *exception) {} + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)togglePlayback { + if (self.player.rate > 0.0) { + [self log:@"toggle pause"]; + [self.player pause]; + } else { + [self log:@"toggle play"]; + [self.player play]; + } +} + +- (void)skipByInterval:(NSTimeInterval)delta { + if (self.player.currentItem == nil) { + return; + } + + NSTimeInterval currentTime = CMTimeGetSeconds(self.player.currentTime); + if (!isfinite(currentTime)) { + currentTime = 0.0; + } + + NSTimeInterval duration = CMTimeGetSeconds(self.player.currentItem.duration); + NSTimeInterval targetTime = currentTime + delta; + if (isfinite(duration) && duration > 0.0) { + targetTime = MIN(MAX(targetTime, 0.0), MAX(duration - 0.05, 0.0)); + } else { + targetTime = MAX(targetTime, 0.0); + } + + [self log:@"seek delta=%0.3f from=%0.3f to=%0.3f", delta, currentTime, targetTime]; + CMTime seekTime = CMTimeMakeWithSeconds(targetTime, NSEC_PER_SEC); + [self.player seekToTime:seekTime toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero]; +} + +- (void)scrubByHorizontalDelta:(CGFloat)delta { + // Approximate touch-surface horizontal movement to timeline seek. + NSTimeInterval secondsDelta = (NSTimeInterval)delta / 4.0; + if (fabs(secondsDelta) < 0.01) { + return; + } + [self skipByInterval:secondsDelta]; +} + +- (void)closePlayer { + [self log:@"close player"]; + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (void)handlePlayerItemFailedToPlayToEndTime:(NSNotification *)notification { + NSError *error = notification.userInfo[AVPlayerItemFailedToPlayToEndTimeErrorKey]; + [self log:@"failedToPlayToEnd error=%@", error]; +} + +- (void)handlePlayerItemNewErrorLogEntry:(NSNotification *)notification { + AVPlayerItemErrorLog *errorLog = self.player.currentItem.errorLog; + AVPlayerItemErrorLogEvent *lastEvent = errorLog.events.lastObject; + [self log:@"errorLog domain=%@ status=%ld comment=%@ serverAddress=%@ playbackSessionID=%@", + lastEvent.errorDomain ?: @"", + (long)lastEvent.errorStatusCode, + lastEvent.errorComment ?: @"", + lastEvent.serverAddress ?: @"", + lastEvent.playbackSessionID ?: @""]; +} + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { + if (object == self.player.currentItem && [keyPath isEqualToString:@"status"]) { + switch (self.player.currentItem.status) { + case AVPlayerItemStatusUnknown: + [self log:@"item status=unknown error=%@", self.player.currentItem.error]; + break; + case AVPlayerItemStatusReadyToPlay: + [self log:@"item status=ready duration=%f likelyToKeepUp=%d bufferEmpty=%d", + CMTimeGetSeconds(self.player.currentItem.duration), + self.player.currentItem.isPlaybackLikelyToKeepUp, + self.player.currentItem.isPlaybackBufferEmpty]; + break; + case AVPlayerItemStatusFailed: + [self log:@"item status=failed error=%@", self.player.currentItem.error]; + break; + } + return; + } + + if (object == self.player && [keyPath isEqualToString:@"timeControlStatus"]) { + if (@available(tvOS 10.0, *)) { + NSString *status = @"unknown"; + switch (self.player.timeControlStatus) { + case AVPlayerTimeControlStatusPaused: + status = @"paused"; + break; + case AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate: + status = @"waiting"; + break; + case AVPlayerTimeControlStatusPlaying: + status = @"playing"; + break; + } + [self log:@"timeControlStatus=%@ reason=%@", status, self.player.reasonForWaitingToPlay ?: @""]; + return; + } + } + + [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; +} + +- (void)pressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)event { + UIPress *press = presses.anyObject; + if (press != nil && (press.type == UIPressTypeMenu || press.type == UIPressTypePlayPause || press.type == UIPressTypeSelect)) { + NSLog(@"%@ pressesBegan type=%@ phase=%@", + kBrowserNativePlayerInputLogPrefix, + BrowserNativePlayerPressTypeString(press.type), + BrowserNativePlayerPressPhaseString(press.phase)); + } + [super pressesBegan:presses withEvent:event]; +} + +- (void)pressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event { + UIPress *press = presses.anyObject; + if (press != nil && (press.type == UIPressTypeMenu || press.type == UIPressTypePlayPause || press.type == UIPressTypeSelect)) { + NSLog(@"%@ pressesEnded type=%@ phase=%@", + kBrowserNativePlayerInputLogPrefix, + BrowserNativePlayerPressTypeString(press.type), + BrowserNativePlayerPressPhaseString(press.phase)); + } + [super pressesEnded:presses withEvent:event]; +} + +@end diff --git a/_Project/Browser/BrowserNavigationService.h b/_Project/Browser/BrowserNavigationService.h new file mode 100644 index 0000000..0fab91c --- /dev/null +++ b/_Project/Browser/BrowserNavigationService.h @@ -0,0 +1,19 @@ +#import + +@class BrowserTabViewModel; +@class BrowserPreferencesStore; + +@interface BrowserNavigationService : NSObject + +- (instancetype)initWithPreferencesStore:(BrowserPreferencesStore *)preferencesStore; +- (NSURLRequest *)homePageRequest; +- (NSURLRequest *)requestForURLString:(NSString *)URLString; +- (NSURLRequest *)requestForEnteredAddressString:(NSString *)addressString; +- (NSURLRequest *)googleSearchRequestForQuery:(NSString *)query; +- (NSURLRequest *)googleSearchRequestForFailedRequestURLString:(NSString *)requestURLString; +- (void)updateTab:(BrowserTabViewModel *)tab + withPageTitle:(NSString *)pageTitle + currentURLString:(NSString *)currentURLString; +- (BOOL)shouldIgnoreLoadError:(NSError *)error; + +@end diff --git a/_Project/Browser/BrowserNavigationService.m b/_Project/Browser/BrowserNavigationService.m new file mode 100644 index 0000000..6f4bdc8 --- /dev/null +++ b/_Project/Browser/BrowserNavigationService.m @@ -0,0 +1,156 @@ +#import "BrowserNavigationService.h" + +#import "BrowserPreferencesStore.h" +#import "BrowserTabViewModel.h" + +static NSString * const kHistoryDefaultsKey = @"HISTORY"; +static NSUInteger const kMaximumHistoryCount = 100; + +@interface BrowserNavigationService () + +@property (nonatomic) BrowserPreferencesStore *preferencesStore; + +@end + +@implementation BrowserNavigationService + +- (instancetype)init { + return [self initWithPreferencesStore:[BrowserPreferencesStore new]]; +} + +- (instancetype)initWithPreferencesStore:(BrowserPreferencesStore *)preferencesStore { + self = [super init]; + if (self) { + _preferencesStore = preferencesStore ?: [BrowserPreferencesStore new]; + [_preferencesStore ensureUserAgentConsistency]; + } + return self; +} + +- (NSURLRequest *)homePageRequest { + NSString *homePageURLString = self.preferencesStore.homePageURLString; + if (homePageURLString.length == 0) { + homePageURLString = @"http://www.google.com"; + } + return [self requestForURLString:homePageURLString]; +} + +- (NSURLRequest *)requestForEnteredAddressString:(NSString *)addressString { + NSString *trimmedAddress = [self trimmedString:addressString]; + if (trimmedAddress.length == 0) { + return nil; + } + + if (![trimmedAddress hasPrefix:@"http://"] && ![trimmedAddress hasPrefix:@"https://"]) { + trimmedAddress = [@"http://" stringByAppendingString:trimmedAddress]; + } + return [self requestForURLString:trimmedAddress]; +} + +- (NSURLRequest *)googleSearchRequestForQuery:(NSString *)query { + NSString *sanitizedQuery = [self sanitizedSearchQuery:query]; + if (sanitizedQuery.length == 0) { + return nil; + } + + NSString *searchURLString = [NSString stringWithFormat:@"https://www.google.com/search?q=%@", sanitizedQuery]; + return [self requestForURLString:searchURLString]; +} + +- (NSURLRequest *)googleSearchRequestForFailedRequestURLString:(NSString *)requestURLString { + NSString *searchQuery = [self trimmedString:requestURLString]; + if (searchQuery.length == 0) { + return nil; + } + + if ([searchQuery hasSuffix:@"/"]) { + searchQuery = [searchQuery substringToIndex:searchQuery.length - 1]; + } + searchQuery = [searchQuery stringByReplacingOccurrencesOfString:@"http://" withString:@""]; + searchQuery = [searchQuery stringByReplacingOccurrencesOfString:@"https://" withString:@""]; + searchQuery = [searchQuery stringByReplacingOccurrencesOfString:@"www." withString:@""]; + + return [self googleSearchRequestForQuery:searchQuery]; +} + +- (void)updateTab:(BrowserTabViewModel *)tab + withPageTitle:(NSString *)pageTitle + currentURLString:(NSString *)currentURLString { + if (tab == nil) { + return; + } + + NSString *safeTitle = pageTitle ?: @""; + NSString *safeURLString = currentURLString ?: @""; + tab.title = safeTitle.length > 0 ? safeTitle : @"New Tab"; + tab.URLString = safeURLString; + + [self persistHistoryItemWithURLString:safeURLString title:safeTitle]; +} + +- (BOOL)shouldIgnoreLoadError:(NSError *)error { + NSInteger errorCode = error.code; + return errorCode == 999 || errorCode == 204; +} + +- (NSURLRequest *)requestForURLString:(NSString *)URLString { + NSURL *URL = [NSURL URLWithString:URLString]; + if (URL == nil) { + return nil; + } + + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:URL]; + NSString *userAgent = self.preferencesStore.userAgent; + if (userAgent.length > 0) { + [request setValue:userAgent forHTTPHeaderField:@"User-Agent"]; + } + return request; +} + +- (NSString *)trimmedString:(NSString *)string { + if (string == nil) { + return @""; + } + return [string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; +} + +- (NSString *)sanitizedSearchQuery:(NSString *)query { + NSString *trimmedQuery = [self trimmedString:query]; + if (trimmedQuery.length == 0) { + return @""; + } + + NSString *searchQuery = [trimmedQuery stringByReplacingOccurrencesOfString:@" " withString:@"+"]; + searchQuery = [searchQuery stringByReplacingOccurrencesOfString:@"." withString:@"+"]; + while ([searchQuery containsString:@"++"]) { + searchQuery = [searchQuery stringByReplacingOccurrencesOfString:@"++" withString:@"+"]; + } + + return [searchQuery stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; +} + +- (void)persistHistoryItemWithURLString:(NSString *)URLString title:(NSString *)title { + if (URLString.length == 0) { + return; + } + + NSArray *historyItem = @[URLString, title ?: @""]; + NSMutableArray *historyItems = [NSMutableArray arrayWithObject:historyItem]; + NSArray *storedHistory = [[NSUserDefaults standardUserDefaults] arrayForKey:kHistoryDefaultsKey]; + if (storedHistory.count > 0) { + NSArray *latestItem = storedHistory.firstObject; + if ([latestItem isKindOfClass:[NSArray class]] && latestItem.count > 0 && [latestItem[0] isEqualToString:URLString]) { + [historyItems removeObjectAtIndex:0]; + } + [historyItems addObjectsFromArray:storedHistory]; + } + + while (historyItems.count > kMaximumHistoryCount) { + [historyItems removeLastObject]; + } + + [[NSUserDefaults standardUserDefaults] setObject:historyItems forKey:kHistoryDefaultsKey]; + [[NSUserDefaults standardUserDefaults] synchronize]; +} + +@end diff --git a/_Project/Browser/BrowserPageActionCoordinator.h b/_Project/Browser/BrowserPageActionCoordinator.h new file mode 100644 index 0000000..135ea27 --- /dev/null +++ b/_Project/Browser/BrowserPageActionCoordinator.h @@ -0,0 +1,32 @@ +#import +#import + +@class BrowserDOMInteractionService; +@class BrowserNavigationService; +@class BrowserVideoPlaybackCoordinator; +@class BrowserWebView; + +NS_ASSUME_NONNULL_BEGIN + +@protocol BrowserPageActionCoordinatorHost + +- (void)browserPageActionCoordinatorPresentViewController:(UIViewController *)viewController; +- (BOOL)browserPageActionCoordinatorCreateNewTabWithRequest:(NSURLRequest *)request; + +@end + +@interface BrowserPageActionCoordinator : NSObject + +- (instancetype)initWithHost:(id)host + domInteractionService:(BrowserDOMInteractionService *)domInteractionService + navigationService:(BrowserNavigationService *)navigationService + videoPlaybackCoordinator:(BrowserVideoPlaybackCoordinator *)videoPlaybackCoordinator NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +- (NSString *)hoverStateAtDOMPoint:(CGPoint)point webView:(BrowserWebView *)webView; +- (BOOL)handlePageSelectionAtDOMPoint:(CGPoint)point webView:(BrowserWebView *)webView; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserPageActionCoordinator.m b/_Project/Browser/BrowserPageActionCoordinator.m new file mode 100644 index 0000000..40d4279 --- /dev/null +++ b/_Project/Browser/BrowserPageActionCoordinator.m @@ -0,0 +1,237 @@ +#import "BrowserPageActionCoordinator.h" + +#import "BrowserDOMInteractionService.h" +#import "BrowserNavigationService.h" +#import "BrowserVideoPlaybackCoordinator.h" +#import "BrowserWebView.h" + +static UIColor *BrowserPageActionTextColor(void) { + if (@available(tvOS 13, *)) { + return UIColor.labelColor; + } else { + return UIColor.blackColor; + } +} + +@interface BrowserPageActionCoordinator () + +@property (nonatomic, weak) id host; +@property (nonatomic) BrowserDOMInteractionService *domInteractionService; +@property (nonatomic) BrowserNavigationService *navigationService; +@property (nonatomic) BrowserVideoPlaybackCoordinator *videoPlaybackCoordinator; + +@end + +@implementation BrowserPageActionCoordinator + +- (instancetype)initWithHost:(id)host + domInteractionService:(BrowserDOMInteractionService *)domInteractionService + navigationService:(BrowserNavigationService *)navigationService + videoPlaybackCoordinator:(BrowserVideoPlaybackCoordinator *)videoPlaybackCoordinator { + self = [super init]; + if (self) { + _host = host; + _domInteractionService = domInteractionService; + _navigationService = navigationService; + _videoPlaybackCoordinator = videoPlaybackCoordinator; + } + return self; +} + +- (NSString *)hoverStateAtDOMPoint:(CGPoint)point webView:(BrowserWebView *)webView { + return [self.domInteractionService evaluateHoverStateJavaScriptAtPoint:point webView:webView]; +} + +- (BOOL)handleTargetBlankLinkAtDOMPoint:(CGPoint)point webView:(BrowserWebView *)webView { + NSDictionary *linkInfo = [self.domInteractionService linkInfoAtDOMPoint:point webView:webView]; + NSString *href = [linkInfo[@"href"] isKindOfClass:[NSString class]] ? linkInfo[@"href"] : @""; + NSString *target = [linkInfo[@"target"] isKindOfClass:[NSString class]] ? linkInfo[@"target"] : @""; + + if (href.length == 0 || ![target isEqualToString:@"_blank"]) { + return NO; + } + + NSURLRequest *request = [self.navigationService requestForURLString:href]; + if (request == nil) { + return NO; + } + + return [self.host browserPageActionCoordinatorCreateNewTabWithRequest:request]; +} + +- (void)presentEditableFieldPromptForFieldType:(NSString *)fieldType + point:(CGPoint)point + webView:(BrowserWebView *)webView { + NSString *fieldTitle = [self.domInteractionService evaluateEditableElementJavaScriptAtPoint:point + webView:webView + body:@"var target = browserEditableTarget();" + "if (!target) { return ''; }" + "return target.title || target.getAttribute('aria-label') || target.name || target.placeholder || '';"]; + if ([fieldTitle isEqualToString:@""]) { + fieldTitle = fieldType; + } + NSString *placeholder = [self.domInteractionService evaluateEditableElementJavaScriptAtPoint:point + webView:webView + body:@"var target = browserEditableTarget();" + "if (!target) { return ''; }" + "return target.placeholder || target.getAttribute('aria-label') || '';"]; + if ([placeholder isEqualToString:@""]) { + placeholder = [fieldTitle isEqualToString:fieldType] ? @"Text Input" : [NSString stringWithFormat:@"%@ Input", fieldTitle]; + } + NSString *testedFormResponse = [self.domInteractionService evaluateEditableElementJavaScriptAtPoint:point + webView:webView + body:@"var target = browserEditableTarget();" + "return (target && target.form && target.form.hasAttribute('onsubmit')) ? 'true' : 'false';"]; + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Input Text" + message:[fieldTitle capitalizedString] + preferredStyle:UIAlertControllerStyleAlert]; + + __weak typeof(self) weakSelf = self; + [alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) { + if ([fieldType isEqualToString:@"url"]) { + textField.keyboardType = UIKeyboardTypeURL; + } else if ([fieldType isEqualToString:@"email"]) { + textField.keyboardType = UIKeyboardTypeEmailAddress; + } else if ([fieldType isEqualToString:@"tel"] || + [fieldType isEqualToString:@"number"] || + [fieldType isEqualToString:@"date"] || + [fieldType isEqualToString:@"datetime"] || + [fieldType isEqualToString:@"datetime-local"]) { + textField.keyboardType = UIKeyboardTypeNumbersAndPunctuation; + } else { + textField.keyboardType = UIKeyboardTypeDefault; + } + textField.placeholder = [placeholder capitalizedString]; + if ([fieldType isEqualToString:@"password"]) { + textField.secureTextEntry = YES; + } + textField.text = [weakSelf.domInteractionService evaluateEditableElementJavaScriptAtPoint:point + webView:webView + body:@"var target = browserEditableTarget();" + "if (!target) { return ''; }" + "if (typeof target.value !== 'undefined') { return target.value; }" + "return target.textContent || '';"]; + textField.textColor = BrowserPageActionTextColor(); + [textField setReturnKeyType:UIReturnKeyDone]; + }]; + + UIAlertAction *submitAction = [UIAlertAction actionWithTitle:@"Submit" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + UITextField *inputTextField = alertController.textFields.firstObject; + NSString *escapedText = [weakSelf.domInteractionService javaScriptEscapedString:inputTextField.text]; + [weakSelf.domInteractionService evaluateEditableElementJavaScriptAtPoint:point + webView:webView + body:[NSString stringWithFormat:@"var target = browserEditableTarget();" + "if (!target) { return 'false'; }" + "if (typeof target.value !== 'undefined') { target.value = '%@'; }" + "else { target.textContent = '%@'; }" + "if (target.dispatchEvent) {" + "target.dispatchEvent(new Event('input', { bubbles: true }));" + "target.dispatchEvent(new Event('change', { bubbles: true }));" + "}" + "if (target.form) { target.form.submit(); }" + "return 'true';", escapedText, escapedText]]; + }]; + UIAlertAction *doneAction = [UIAlertAction actionWithTitle:@"Done" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + UITextField *inputTextField = alertController.textFields.firstObject; + NSString *escapedText = [weakSelf.domInteractionService javaScriptEscapedString:inputTextField.text]; + [weakSelf.domInteractionService evaluateEditableElementJavaScriptAtPoint:point + webView:webView + body:[NSString stringWithFormat:@"var target = browserEditableTarget();" + "if (!target) { return 'false'; }" + "if (typeof target.value !== 'undefined') { target.value = '%@'; }" + "else { target.textContent = '%@'; }" + "if (target.dispatchEvent) {" + "target.dispatchEvent(new Event('input', { bubbles: true }));" + "target.dispatchEvent(new Event('change', { bubbles: true }));" + "}" + "return 'true';", escapedText, escapedText]]; + }]; + [alertController addAction:doneAction]; + if ([testedFormResponse isEqualToString:@"true"]) { + [alertController addAction:submitAction]; + } + [alertController addAction:[UIAlertAction actionWithTitle:nil style:UIAlertActionStyleCancel handler:nil]]; + [self.host browserPageActionCoordinatorPresentViewController:alertController]; + + UITextField *inputTextField = alertController.textFields.firstObject; + if ([[inputTextField.text stringByReplacingOccurrencesOfString:@" " withString:@""] isEqualToString:@""]) { + [inputTextField becomeFirstResponder]; + } +} + +- (BOOL)handlePageSelectionAtDOMPoint:(CGPoint)point webView:(BrowserWebView *)webView { + if ([self.videoPlaybackCoordinator handleSelectPressForVideoAtCursor]) { + return YES; + } + if ([self handleTargetBlankLinkAtDOMPoint:point webView:webView]) { + return YES; + } + + NSString *fieldType = [self.domInteractionService evaluateResolvedElementJavaScriptAtPoint:point + webView:webView + body:@"function browserEditableTargetAtPoint() {" + "var candidate = editableElement;" + "if (!candidate && resolvedElement && resolvedElement.matches) {" + "if (resolvedElement.matches(editableSelector) || resolvedElement.matches('textarea, select')) {" + "candidate = resolvedElement;" + "}" + "}" + "if (!candidate) { return null; }" + "window.__browserLastEditableElement = candidate;" + "return candidate;" + "}" + "var target = browserEditableTargetAtPoint();" + "if (!target) { return ''; }" + "var tagName = target.tagName ? target.tagName.toLowerCase() : '';" + "var type = (target.type || '').toLowerCase();" + "if (tagName === 'textarea' || target.isContentEditable) { return 'text'; }" + "if (tagName === 'input' && !type) { return 'text'; }" + "return type;"]; + [self.domInteractionService evaluateResolvedElementJavaScriptAtPoint:point + webView:webView + body:@"var target = editableElement || interactiveElement || resolvedElement;" + "if (!target) { return 'false'; }" + "try { if (target.focus) { target.focus(); } } catch (error) {}" + "function dispatchPointerLikeEvent(type, constructorName) {" + "try {" + "var Constructor = window[constructorName];" + "if (Constructor) {" + "var event = new Constructor(type, { bubbles: true, cancelable: true, composed: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y, button: 0, buttons: 1, pointerType: 'mouse' });" + "return target.dispatchEvent(event);" + "}" + "} catch (error) {}" + "var mouseEvent = document.createEvent('MouseEvents');" + "mouseEvent.initMouseEvent(type, true, true, window, 1, x, y, x, y, false, false, false, false, 0, null);" + "return target.dispatchEvent(mouseEvent);" + "}" + "dispatchPointerLikeEvent('pointerdown', 'PointerEvent');" + "dispatchPointerLikeEvent('mousedown', 'MouseEvent');" + "dispatchPointerLikeEvent('pointerup', 'PointerEvent');" + "dispatchPointerLikeEvent('mouseup', 'MouseEvent');" + "if (typeof target.click === 'function') { target.click(); }" + "else { dispatchPointerLikeEvent('click', 'MouseEvent'); }" + "return 'true';"]; + fieldType = fieldType.lowercaseString; + if ([fieldType isEqualToString:@"date"] || + [fieldType isEqualToString:@"datetime"] || + [fieldType isEqualToString:@"datetime-local"] || + [fieldType isEqualToString:@"email"] || + [fieldType isEqualToString:@"month"] || + [fieldType isEqualToString:@"number"] || + [fieldType isEqualToString:@"password"] || + [fieldType isEqualToString:@"search"] || + [fieldType isEqualToString:@"tel"] || + [fieldType isEqualToString:@"text"] || + [fieldType isEqualToString:@"time"] || + [fieldType isEqualToString:@"url"] || + [fieldType isEqualToString:@"week"]) { + [self presentEditableFieldPromptForFieldType:fieldType point:point webView:webView]; + } + return YES; +} + +@end diff --git a/_Project/Browser/BrowserPreferencesStore.h b/_Project/Browser/BrowserPreferencesStore.h new file mode 100644 index 0000000..b1c9317 --- /dev/null +++ b/_Project/Browser/BrowserPreferencesStore.h @@ -0,0 +1,23 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface BrowserPreferencesStore : NSObject + ++ (NSString *)desktopUserAgent; ++ (NSString *)mobileUserAgent; + +@property (nonatomic, copy) NSString *userAgent; +@property (nonatomic) BOOL mobileModeEnabled; +@property (nonatomic) BOOL topNavigationBarVisible; +@property (nonatomic) NSUInteger textFontSize; +@property (nonatomic) BOOL fullscreenVideoPlaybackEnabled; +@property (nonatomic) BOOL scalePagesToFit; +@property (nonatomic) BOOL dontShowHintsOnLaunch; +@property (nonatomic, copy) NSString *homePageURLString; + +- (void)ensureUserAgentConsistency; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserPreferencesStore.m b/_Project/Browser/BrowserPreferencesStore.m new file mode 100644 index 0000000..52eb6c6 --- /dev/null +++ b/_Project/Browser/BrowserPreferencesStore.m @@ -0,0 +1,121 @@ +#import "BrowserPreferencesStore.h" + +static NSString * const kUserAgentDefaultsKey = @"UserAgent"; +static NSString * const kMobileModeDefaultsKey = @"MobileMode"; +static NSString * const kShowTopNavigationBarDefaultsKey = @"ShowTopNavigationBar"; +static NSString * const kTextFontSizeDefaultsKey = @"TextFontSize"; +static NSString * const kEnableFullscreenVideoPlaybackDefaultsKey = @"EnableFullscreenVideoPlayback"; +static NSString * const kScalePagesToFitDefaultsKey = @"ScalePagesToFit"; +static NSString * const kDontShowHintsOnLaunchDefaultsKey = @"DontShowHintsOnLaunch"; +static NSString * const kHomepageDefaultsKey = @"homepage"; + +static NSUInteger const kDefaultTextFontSize = 100; +static NSUInteger const kMinimumTextFontSize = 50; +static NSUInteger const kMaximumTextFontSize = 200; + +@implementation BrowserPreferencesStore + ++ (NSString *)desktopUserAgent { + return @"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; +} + ++ (NSString *)mobileUserAgent { + return @"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; +} + +- (NSUserDefaults *)defaults { + return [NSUserDefaults standardUserDefaults]; +} + +- (void)ensureUserAgentConsistency { + if (self.userAgent.length > 0) { + return; + } + self.userAgent = self.mobileModeEnabled ? BrowserPreferencesStore.mobileUserAgent : BrowserPreferencesStore.desktopUserAgent; +} + +- (NSString *)userAgent { + NSString *userAgent = [[self defaults] stringForKey:kUserAgentDefaultsKey]; + if (userAgent.length > 0) { + return userAgent; + } + return self.mobileModeEnabled ? BrowserPreferencesStore.mobileUserAgent : BrowserPreferencesStore.desktopUserAgent; +} + +- (void)setUserAgent:(NSString *)userAgent { + [[self defaults] setObject:userAgent ?: @"" forKey:kUserAgentDefaultsKey]; + [[self defaults] synchronize]; +} + +- (BOOL)mobileModeEnabled { + return [[self defaults] boolForKey:kMobileModeDefaultsKey]; +} + +- (void)setMobileModeEnabled:(BOOL)mobileModeEnabled { + [[self defaults] setBool:mobileModeEnabled forKey:kMobileModeDefaultsKey]; + [[self defaults] synchronize]; +} + +- (BOOL)topNavigationBarVisible { + NSNumber *showTopNavBar = [[self defaults] objectForKey:kShowTopNavigationBarDefaultsKey]; + return showTopNavBar ? showTopNavBar.boolValue : YES; +} + +- (void)setTopNavigationBarVisible:(BOOL)topNavigationBarVisible { + [[self defaults] setObject:@(topNavigationBarVisible) forKey:kShowTopNavigationBarDefaultsKey]; + [[self defaults] synchronize]; +} + +- (NSUInteger)textFontSize { + NSNumber *textFontSizeValue = [[self defaults] objectForKey:kTextFontSizeDefaultsKey]; + if (textFontSizeValue == nil) { + return kDefaultTextFontSize; + } + NSUInteger textFontSize = textFontSizeValue.unsignedIntegerValue; + return MIN(kMaximumTextFontSize, MAX(kMinimumTextFontSize, textFontSize)); +} + +- (void)setTextFontSize:(NSUInteger)textFontSize { + textFontSize = MIN(kMaximumTextFontSize, MAX(kMinimumTextFontSize, textFontSize)); + [[self defaults] setObject:@(textFontSize) forKey:kTextFontSizeDefaultsKey]; + [[self defaults] synchronize]; +} + +- (BOOL)fullscreenVideoPlaybackEnabled { + return [[self defaults] boolForKey:kEnableFullscreenVideoPlaybackDefaultsKey]; +} + +- (void)setFullscreenVideoPlaybackEnabled:(BOOL)fullscreenVideoPlaybackEnabled { + [[self defaults] setBool:fullscreenVideoPlaybackEnabled forKey:kEnableFullscreenVideoPlaybackDefaultsKey]; + [[self defaults] synchronize]; +} + +- (BOOL)scalePagesToFit { + return [[self defaults] boolForKey:kScalePagesToFitDefaultsKey]; +} + +- (void)setScalePagesToFit:(BOOL)scalePagesToFit { + [[self defaults] setBool:scalePagesToFit forKey:kScalePagesToFitDefaultsKey]; + [[self defaults] synchronize]; +} + +- (BOOL)dontShowHintsOnLaunch { + return [[self defaults] boolForKey:kDontShowHintsOnLaunchDefaultsKey]; +} + +- (void)setDontShowHintsOnLaunch:(BOOL)dontShowHintsOnLaunch { + [[self defaults] setBool:dontShowHintsOnLaunch forKey:kDontShowHintsOnLaunchDefaultsKey]; + [[self defaults] synchronize]; +} + +- (NSString *)homePageURLString { + NSString *value = [[self defaults] stringForKey:kHomepageDefaultsKey]; + return value ?: @""; +} + +- (void)setHomePageURLString:(NSString *)homePageURLString { + [[self defaults] setObject:homePageURLString ?: @"" forKey:kHomepageDefaultsKey]; + [[self defaults] synchronize]; +} + +@end diff --git a/_Project/Browser/BrowserRemoteInputController.h b/_Project/Browser/BrowserRemoteInputController.h new file mode 100644 index 0000000..35078ea --- /dev/null +++ b/_Project/Browser/BrowserRemoteInputController.h @@ -0,0 +1,52 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol BrowserRemoteInputControllerHost + +- (nullable UIScrollView *)browserRemoteInputControllerActiveScrollView; +- (nullable UIViewController *)browserRemoteInputControllerPresentedViewController; +- (BOOL)browserRemoteInputControllerTopBarFocusActive; +- (BOOL)browserRemoteInputControllerCanActivateTopBarFocus; +- (void)browserRemoteInputControllerActivateTopBarFocus; +- (void)browserRemoteInputControllerDeactivateTopBarFocus; +- (BOOL)browserRemoteInputControllerTabOverviewVisible; +- (BOOL)browserRemoteInputControllerTabOverviewContainsPoint:(CGPoint)point; +- (BOOL)browserRemoteInputControllerHandleTabOverviewSelectionAtPoint:(CGPoint)point; +- (void)browserRemoteInputControllerDismissTabOverview; +- (void)browserRemoteInputControllerHandleTabOverviewAlternateAction; +- (void)browserRemoteInputControllerHandlePrimaryAction; +- (void)browserRemoteInputControllerHandleMenuPress; +- (void)browserRemoteInputControllerHandlePlayPausePress; +- (void)browserRemoteInputControllerHandleAdvancedMenuPress; +- (NSString *)browserRemoteInputControllerHoverStateAtCursorPoint:(CGPoint)point; +- (void)browserRemoteInputControllerSetWebInteractionEnabled:(BOOL)enabled; +- (void)browserRemoteInputControllerPersistSession; + +@end + +@interface BrowserRemoteInputController : NSObject + +@property (nonatomic, readonly) UIImageView *cursorView; +@property (nonatomic, readonly) UIPanGestureRecognizer *manualScrollPanRecognizer; +@property (nonatomic, readonly) UITapGestureRecognizer *playPauseDoubleTapRecognizer; +@property (nonatomic, readonly, getter=isCursorModeEnabled) BOOL cursorModeEnabled; + +- (instancetype)initWithHost:(id)host + rootView:(UIView *)rootView NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +- (void)handleGlobalSelectPressEndedNotification; +- (void)handlePressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)event; +- (BOOL)handlePressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event; +- (BOOL)handleTouchesBegan:(NSSet *)touches withEvent:(UIEvent *)event; +- (BOOL)handleTouchesMoved:(NSSet *)touches withEvent:(UIEvent *)event; +- (void)handleTouchesEnded; +- (void)setCursorModeEnabled:(BOOL)cursorModeEnabled; +- (void)refreshInteractionState; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserRemoteInputController.m b/_Project/Browser/BrowserRemoteInputController.m new file mode 100644 index 0000000..da65e17 --- /dev/null +++ b/_Project/Browser/BrowserRemoteInputController.m @@ -0,0 +1,454 @@ +#import "BrowserRemoteInputController.h" + +static UIImage *BrowserDefaultCursor(void) { + static UIImage *image; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + image = [UIImage imageNamed:@"Cursor"]; + }); + return image; +} + +static UIImage *BrowserPointerCursor(void) { + static UIImage *image; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + image = [UIImage imageNamed:@"Pointer"]; + }); + return image; +} + +static NSString *BrowserPressTypeString(UIPressType type) { + switch (type) { + case UIPressTypeMenu: return @"Menu"; + case UIPressTypePlayPause: return @"PlayPause"; + case UIPressTypeSelect: return @"Select"; + case UIPressTypeUpArrow: return @"Up"; + case UIPressTypeDownArrow: return @"Down"; + case UIPressTypeLeftArrow: return @"Left"; + case UIPressTypeRightArrow: return @"Right"; + default: return [NSString stringWithFormat:@"Type-%ld", (long)type]; + } +} + +static NSString *BrowserPressPhaseString(UIPressPhase phase) { + switch (phase) { + case UIPressPhaseBegan: return @"Began"; + case UIPressPhaseChanged: return @"Changed"; + case UIPressPhaseStationary: return @"Stationary"; + case UIPressPhaseEnded: return @"Ended"; + case UIPressPhaseCancelled: return @"Cancelled"; + default: return [NSString stringWithFormat:@"Phase-%ld", (long)phase]; + } +} + +@interface BrowserRemoteInputController () + +@property (nonatomic, weak) id host; +@property (nonatomic, weak) UIView *rootView; +@property (nonatomic, readwrite) UIImageView *cursorView; +@property (nonatomic, readwrite) UIPanGestureRecognizer *manualScrollPanRecognizer; +@property (nonatomic, readwrite) UITapGestureRecognizer *playPauseDoubleTapRecognizer; +@property (nonatomic, readwrite, getter=isCursorModeEnabled) BOOL cursorModeEnabled; +@property (nonatomic) CGPoint lastTouchLocation; +@property (nonatomic) CADisplayLink *manualScrollDisplayLink; +@property (nonatomic) CGPoint manualScrollVelocity; +@property (nonatomic) CFTimeInterval manualScrollLastTimestamp; +@property (nonatomic) CFTimeInterval manualScrollLastMovementTimestamp; +@property (nonatomic) CFTimeInterval lastDirectSelectPressTimestamp; +@property (nonatomic) CFTimeInterval lastSelectPressTimestamp; +@property (nonatomic) BOOL awaitingSecondSelectPress; + +@end + +@implementation BrowserRemoteInputController + +- (instancetype)initWithHost:(id)host + rootView:(UIView *)rootView { + self = [super init]; + if (self) { + _host = host; + _rootView = rootView; + _lastTouchLocation = CGPointMake(-1, -1); + _cursorModeEnabled = YES; + + _cursorView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 64, 64)]; + _cursorView.center = CGPointMake(CGRectGetMidX([UIScreen mainScreen].bounds), CGRectGetMidY([UIScreen mainScreen].bounds)); + _cursorView.image = BrowserDefaultCursor(); + + _playPauseDoubleTapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handlePlayPauseDoubleTap:)]; + _playPauseDoubleTapRecognizer.numberOfTapsRequired = 2; + _playPauseDoubleTapRecognizer.allowedPressTypes = @[@(UIPressTypePlayPause)]; + [rootView addGestureRecognizer:_playPauseDoubleTapRecognizer]; + + _manualScrollPanRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleManualScrollPan:)]; + _manualScrollPanRecognizer.allowedTouchTypes = @[ @(UITouchTypeIndirect) ]; + _manualScrollPanRecognizer.cancelsTouchesInView = NO; + _manualScrollPanRecognizer.enabled = NO; + [rootView addGestureRecognizer:_manualScrollPanRecognizer]; + } + return self; +} + +- (void)setCursorModeEnabled:(BOOL)cursorModeEnabled { + BOOL wasCursorModeEnabled = self.cursorModeEnabled; + _cursorModeEnabled = cursorModeEnabled; + self.lastTouchLocation = CGPointMake(-1, -1); + [self stopManualScrollInertia]; + [self refreshInteractionState]; + if (!wasCursorModeEnabled && cursorModeEnabled) { + [self.host browserRemoteInputControllerPersistSession]; + } +} + +- (void)refreshInteractionState { + UIScrollView *scrollView = [self.host browserRemoteInputControllerActiveScrollView]; + BOOL topBarFocusActive = [self.host browserRemoteInputControllerTopBarFocusActive]; + BOOL shouldAllowWebInteraction = !self.cursorModeEnabled && + ![self.host browserRemoteInputControllerTabOverviewVisible] && + !topBarFocusActive; + scrollView.scrollEnabled = shouldAllowWebInteraction; + self.manualScrollPanRecognizer.enabled = shouldAllowWebInteraction; + [self.host browserRemoteInputControllerSetWebInteractionEnabled:shouldAllowWebInteraction]; + self.cursorView.hidden = !self.cursorModeEnabled || + [self.host browserRemoteInputControllerTabOverviewVisible] || + topBarFocusActive; +} + +- (BOOL)applyManualScrollDelta:(CGPoint)delta { + UIScrollView *scrollView = [self.host browserRemoteInputControllerActiveScrollView]; + if (scrollView == nil) { + return NO; + } + + CGPoint contentOffset = scrollView.contentOffset; + CGFloat maxOffsetX = MAX(0.0, scrollView.contentSize.width - CGRectGetWidth(scrollView.bounds)); + CGFloat maxOffsetY = MAX(0.0, scrollView.contentSize.height - CGRectGetHeight(scrollView.bounds)); + CGFloat nextOffsetX = MIN(MAX(contentOffset.x + delta.x, 0.0), maxOffsetX); + CGFloat nextOffsetY = MIN(MAX(contentOffset.y + delta.y, 0.0), maxOffsetY); + CGPoint nextOffset = CGPointMake(nextOffsetX, nextOffsetY); + [scrollView setContentOffset:nextOffset animated:NO]; + return !CGPointEqualToPoint(contentOffset, nextOffset); +} + +- (void)stopManualScrollInertia { + [self.manualScrollDisplayLink invalidate]; + self.manualScrollDisplayLink = nil; + self.manualScrollVelocity = CGPointZero; + self.manualScrollLastTimestamp = 0; + self.manualScrollLastMovementTimestamp = 0; +} + +- (void)startManualScrollInertiaWithVelocity:(CGPoint)velocity { + [self stopManualScrollInertia]; + if (fabs(velocity.x) < 25.0 && fabs(velocity.y) < 25.0) { + return; + } + + self.manualScrollVelocity = velocity; + self.manualScrollLastTimestamp = 0; + self.manualScrollDisplayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleManualScrollDisplayLink:)]; + [self.manualScrollDisplayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; +} + +- (void)handleManualScrollDisplayLink:(CADisplayLink *)displayLink { + if (self.cursorModeEnabled || + [self.host browserRemoteInputControllerTabOverviewVisible] || + [self.host browserRemoteInputControllerTopBarFocusActive]) { + [self stopManualScrollInertia]; + return; + } + + if (self.manualScrollLastTimestamp <= 0) { + self.manualScrollLastTimestamp = displayLink.timestamp; + return; + } + + CFTimeInterval deltaTime = displayLink.timestamp - self.manualScrollLastTimestamp; + self.manualScrollLastTimestamp = displayLink.timestamp; + + CGPoint step = CGPointMake(self.manualScrollVelocity.x * deltaTime, self.manualScrollVelocity.y * deltaTime); + BOOL didMove = [self applyManualScrollDelta:step]; + + CGFloat decay = pow(0.92, deltaTime * 60.0); + self.manualScrollVelocity = CGPointMake(self.manualScrollVelocity.x * decay, self.manualScrollVelocity.y * decay); + + if (!didMove || + (fabs(self.manualScrollVelocity.x) < 10.0 && fabs(self.manualScrollVelocity.y) < 10.0)) { + [self stopManualScrollInertia]; + [self.host browserRemoteInputControllerPersistSession]; + } +} + +- (void)handleGlobalSelectPressEndedNotification { + if ([self.host browserRemoteInputControllerPresentedViewController] != nil) { + return; + } + + if ([self.host browserRemoteInputControllerTopBarFocusActive]) { + return; + } + + if ((CACurrentMediaTime() - self.lastDirectSelectPressTimestamp) < 0.15) { + return; + } + + [self handleSelectPressEnded]; +} + +- (void)handleDeferredSelectPressAction { + if (!self.awaitingSecondSelectPress) { + return; + } + + self.awaitingSecondSelectPress = NO; + self.lastTouchLocation = CGPointMake(-1, -1); + + if ([self.host browserRemoteInputControllerPresentedViewController] != nil) { + return; + } + + if ([self.host browserRemoteInputControllerTabOverviewVisible]) { + [self.host browserRemoteInputControllerHandleTabOverviewSelectionAtPoint:self.cursorView.frame.origin]; + return; + } + + [self.host browserRemoteInputControllerHandlePrimaryAction]; +} + +- (void)handleSelectPressEnded { + CFTimeInterval now = CACurrentMediaTime(); + if (self.awaitingSecondSelectPress && (now - self.lastSelectPressTimestamp) < 0.35) { + self.awaitingSecondSelectPress = NO; + self.lastSelectPressTimestamp = now; + [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(handleDeferredSelectPressAction) object:nil]; + if (![self.host browserRemoteInputControllerTabOverviewVisible]) { + [self setCursorModeEnabled:!self.cursorModeEnabled]; + } + return; + } + + self.awaitingSecondSelectPress = YES; + self.lastSelectPressTimestamp = now; + [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(handleDeferredSelectPressAction) object:nil]; + [self performSelector:@selector(handleDeferredSelectPressAction) withObject:nil afterDelay:0.3]; +} + +- (void)handlePlayPauseDoubleTap:(UITapGestureRecognizer *)sender { + if (sender.state != UIGestureRecognizerStateEnded) { + return; + } + if ([self.host browserRemoteInputControllerTopBarFocusActive]) { + return; + } + if ([self.host browserRemoteInputControllerTabOverviewVisible]) { + [self.host browserRemoteInputControllerDismissTabOverview]; + return; + } + [self.host browserRemoteInputControllerHandleAdvancedMenuPress]; +} + +- (void)handleManualScrollPan:(UIPanGestureRecognizer *)gestureRecognizer { + if (self.cursorModeEnabled || + [self.host browserRemoteInputControllerTabOverviewVisible] || + [self.host browserRemoteInputControllerTopBarFocusActive]) { + return; + } + + if (gestureRecognizer.state == UIGestureRecognizerStateBegan) { + [self stopManualScrollInertia]; + } + + CGPoint translation = [gestureRecognizer translationInView:self.rootView]; + if (!CGPointEqualToPoint(translation, CGPointZero)) { + [self applyManualScrollDelta:CGPointMake(-translation.x, -translation.y)]; + [gestureRecognizer setTranslation:CGPointZero inView:self.rootView]; + self.manualScrollLastMovementTimestamp = CACurrentMediaTime(); + } + + if (gestureRecognizer.state == UIGestureRecognizerStateEnded) { + CFTimeInterval timeSinceLastMovement = CACurrentMediaTime() - self.manualScrollLastMovementTimestamp; + CGPoint velocity = [gestureRecognizer velocityInView:self.rootView]; + if (timeSinceLastMovement < 0.08) { + [self startManualScrollInertiaWithVelocity:CGPointMake(-velocity.x, -velocity.y)]; + } else { + [self stopManualScrollInertia]; + } + [self.host browserRemoteInputControllerPersistSession]; + } else if (gestureRecognizer.state == UIGestureRecognizerStateCancelled || + gestureRecognizer.state == UIGestureRecognizerStateFailed) { + [self stopManualScrollInertia]; + [self.host browserRemoteInputControllerPersistSession]; + } +} + +- (void)handlePressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)event { + UIPress *press = presses.anyObject; + if (press != nil && (press.type == UIPressTypeMenu || press.type == UIPressTypePlayPause || press.type == UIPressTypeSelect)) { + NSLog(@"[InputTrace][Root] pressesBegan type=%@ phase=%@ presented=%@", + BrowserPressTypeString(press.type), + BrowserPressPhaseString(press.phase), + [self.host browserRemoteInputControllerPresentedViewController] == nil ? @"(nil)" : NSStringFromClass([[self.host browserRemoteInputControllerPresentedViewController] class])); + } + (void)event; +} + +- (BOOL)handlePressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event { + (void)event; + UIPress *press = presses.anyObject; + if (press == nil) { + return NO; + } + + if (press.type == UIPressTypeMenu || press.type == UIPressTypePlayPause || press.type == UIPressTypeSelect) { + NSLog(@"[InputTrace][Root] pressesEnded type=%@ phase=%@ presented=%@ tabOverview=%@", + BrowserPressTypeString(press.type), + BrowserPressPhaseString(press.phase), + [self.host browserRemoteInputControllerPresentedViewController] == nil ? @"(nil)" : NSStringFromClass([[self.host browserRemoteInputControllerPresentedViewController] class]), + [self.host browserRemoteInputControllerTabOverviewVisible] ? @"YES" : @"NO"); + } + + if ([self.host browserRemoteInputControllerTopBarFocusActive]) { + if (press.type == UIPressTypeMenu || press.type == UIPressTypeDownArrow) { + [self.host browserRemoteInputControllerDeactivateTopBarFocus]; + return YES; + } + if (press.type == UIPressTypePlayPause) { + return YES; + } + if (press.type == UIPressTypeSelect || + press.type == UIPressTypeLeftArrow || + press.type == UIPressTypeRightArrow || + press.type == UIPressTypeUpArrow) { + return NO; + } + } + + UIViewController *presentedViewController = [self.host browserRemoteInputControllerPresentedViewController]; + if (presentedViewController != nil && ![presentedViewController isKindOfClass:[UIAlertController class]]) { + if ([self.host browserRemoteInputControllerTabOverviewVisible]) { + if (press.type == UIPressTypeMenu) { + [self.host browserRemoteInputControllerDismissTabOverview]; + return YES; + } + if (press.type == UIPressTypePlayPause) { + [self.host browserRemoteInputControllerHandleTabOverviewAlternateAction]; + return YES; + } + return NO; + } + if (press.type == UIPressTypeMenu) { + [presentedViewController dismissViewControllerAnimated:YES completion:nil]; + return YES; + } + return YES; + } + + if (press.type == UIPressTypeSelect) { + self.lastDirectSelectPressTimestamp = CACurrentMediaTime(); + [self handleSelectPressEnded]; + return YES; + } + + if (press.type == UIPressTypeUpArrow && + [self.host browserRemoteInputControllerCanActivateTopBarFocus]) { + [self.host browserRemoteInputControllerActivateTopBarFocus]; + return YES; + } + + if ([self.host browserRemoteInputControllerTabOverviewVisible]) { + if (press.type == UIPressTypeMenu || press.type == UIPressTypePlayPause) { + [self.host browserRemoteInputControllerDismissTabOverview]; + return YES; + } + if (press.type == UIPressTypeSelect) { + [self.host browserRemoteInputControllerHandleTabOverviewSelectionAtPoint:self.cursorView.frame.origin]; + return YES; + } + } + + if (press.type == UIPressTypeMenu) { + [self.host browserRemoteInputControllerHandleMenuPress]; + return YES; + } + if (press.type == UIPressTypePlayPause) { + [self.host browserRemoteInputControllerHandlePlayPausePress]; + return YES; + } + return NO; +} + +- (BOOL)handleTouchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + (void)touches; + (void)event; + if ([self.host browserRemoteInputControllerTopBarFocusActive]) { + return NO; + } + if ([self.host browserRemoteInputControllerTabOverviewVisible]) { + return NO; + } + if (!self.cursorModeEnabled) { + return NO; + } + self.lastTouchLocation = CGPointMake(-1, -1); + return YES; +} + +- (BOOL)handleTouchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { + (void)event; + if ([self.host browserRemoteInputControllerTopBarFocusActive]) { + return NO; + } + if ([self.host browserRemoteInputControllerTabOverviewVisible]) { + return NO; + } + if (!self.cursorModeEnabled) { + return NO; + } + + for (UITouch *touch in touches) { + UIScrollView *activeScrollView = [self.host browserRemoteInputControllerActiveScrollView]; + UIView *targetView = activeScrollView ?: self.rootView; + CGPoint location = [touch locationInView:targetView]; + + if (self.lastTouchLocation.x == -1 && self.lastTouchLocation.y == -1) { + self.lastTouchLocation = location; + } else { + CGFloat xDiff = location.x - self.lastTouchLocation.x; + CGFloat yDiff = location.y - self.lastTouchLocation.y; + CGRect rect = self.cursorView.frame; + + if (rect.origin.x + xDiff >= 0 && rect.origin.x + xDiff <= 1920) { + rect.origin.x += xDiff; + } + if (rect.origin.y + yDiff >= 0 && rect.origin.y + yDiff <= 1080) { + rect.origin.y += yDiff; + } + self.cursorView.frame = rect; + self.lastTouchLocation = location; + } + + self.cursorView.image = BrowserDefaultCursor(); + if ([self.host browserRemoteInputControllerTabOverviewVisible]) { + if ([self.host browserRemoteInputControllerTabOverviewContainsPoint:self.cursorView.frame.origin]) { + self.cursorView.image = BrowserPointerCursor(); + } + break; + } + if (self.cursorModeEnabled) { + NSString *containsLink = [self.host browserRemoteInputControllerHoverStateAtCursorPoint:self.cursorView.frame.origin]; + if ([containsLink isEqualToString:@"true"]) { + self.cursorView.image = BrowserPointerCursor(); + } + } + break; + } + + return YES; +} + +- (void)handleTouchesEnded { + self.lastTouchLocation = CGPointMake(-1, -1); +} + +@end diff --git a/_Project/Browser/BrowserSessionStore.h b/_Project/Browser/BrowserSessionStore.h new file mode 100644 index 0000000..5ba8b0e --- /dev/null +++ b/_Project/Browser/BrowserSessionStore.h @@ -0,0 +1,12 @@ +#import + +@class BrowserViewModel; +@class BrowserNavigationService; + +@interface BrowserSessionStore : NSObject + +- (BOOL)restoreSessionIntoViewModel:(BrowserViewModel *)viewModel; +- (void)saveSessionForViewModel:(BrowserViewModel *)viewModel; +- (nullable NSURLRequest *)consumeSavedURLToReopenRequestWithNavigationService:(BrowserNavigationService *)navigationService; + +@end diff --git a/_Project/Browser/BrowserSessionStore.m b/_Project/Browser/BrowserSessionStore.m new file mode 100644 index 0000000..dde0456 --- /dev/null +++ b/_Project/Browser/BrowserSessionStore.m @@ -0,0 +1,95 @@ +#import "BrowserSessionStore.h" + +#import "BrowserNavigationService.h" +#import "BrowserTabViewModel.h" +#import "BrowserViewModel.h" + +static NSString * const kBrowserSessionDefaultsKey = @"BrowserSession"; +static NSString * const kBrowserSessionTabsKey = @"tabs"; +static NSString * const kBrowserSessionActiveTabIndexKey = @"activeTabIndex"; +static NSString * const kBrowserSessionVersionKey = @"version"; +static NSString * const kBrowserSavedURLToReopenDefaultsKey = @"savedURLtoReopen"; +static NSNumber *BrowserSessionVersion(void) { + return @1; +} + +@implementation BrowserSessionStore + +- (BOOL)restoreSessionIntoViewModel:(BrowserViewModel *)viewModel { + NSDictionary *sessionRepresentation = [self restoredSessionRepresentation]; + if (![sessionRepresentation isKindOfClass:[NSDictionary class]]) { + return NO; + } + + NSArray *tabRepresentations = [sessionRepresentation[kBrowserSessionTabsKey] isKindOfClass:[NSArray class]] ? sessionRepresentation[kBrowserSessionTabsKey] : nil; + if (tabRepresentations.count == 0) { + return NO; + } + + NSMutableArray *tabs = [NSMutableArray array]; + for (NSDictionary *tabRepresentation in tabRepresentations) { + if (![tabRepresentation isKindOfClass:[NSDictionary class]]) { + continue; + } + BrowserTabViewModel *tab = [[BrowserTabViewModel alloc] initWithSessionRepresentation:tabRepresentation]; + if (tab != nil) { + [tabs addObject:tab]; + } + } + + if (tabs.count == 0) { + return NO; + } + + NSInteger activeTabIndex = [sessionRepresentation[kBrowserSessionActiveTabIndexKey] respondsToSelector:@selector(integerValue)] ? [sessionRepresentation[kBrowserSessionActiveTabIndexKey] integerValue] : 0; + [viewModel restoreTabs:tabs activeTabIndex:activeTabIndex]; + return YES; +} + +- (void)saveSessionForViewModel:(BrowserViewModel *)viewModel { + if (viewModel.tabs.count == 0) { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kBrowserSessionDefaultsKey]; + [[NSUserDefaults standardUserDefaults] synchronize]; + return; + } + + NSMutableArray *tabRepresentations = [NSMutableArray arrayWithCapacity:viewModel.tabs.count]; + for (BrowserTabViewModel *tab in viewModel.tabs) { + [tabRepresentations addObject:[tab sessionRepresentation]]; + } + + NSDictionary *sessionRepresentation = @{ + kBrowserSessionVersionKey: BrowserSessionVersion(), + kBrowserSessionActiveTabIndexKey: @(viewModel.activeTabIndex), + kBrowserSessionTabsKey: tabRepresentations + }; + + [[NSUserDefaults standardUserDefaults] setObject:sessionRepresentation forKey:kBrowserSessionDefaultsKey]; + [[NSUserDefaults standardUserDefaults] synchronize]; +} + +- (NSDictionary *)restoredSessionRepresentation { + NSDictionary *defaultsRepresentation = [[NSUserDefaults standardUserDefaults] objectForKey:kBrowserSessionDefaultsKey]; + if ([defaultsRepresentation isKindOfClass:[NSDictionary class]]) { + return defaultsRepresentation; + } + + return nil; +} + +- (NSURLRequest *)consumeSavedURLToReopenRequestWithNavigationService:(BrowserNavigationService *)navigationService { + NSString *savedURLString = [[NSUserDefaults standardUserDefaults] stringForKey:kBrowserSavedURLToReopenDefaultsKey]; + if (savedURLString.length == 0) { + return nil; + } + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kBrowserSavedURLToReopenDefaultsKey]; + [[NSUserDefaults standardUserDefaults] synchronize]; + + if (navigationService == nil) { + return nil; + } + return [navigationService requestForURLString:savedURLString]; +} + +@end diff --git a/_Project/Browser/BrowserTabCoordinator.h b/_Project/Browser/BrowserTabCoordinator.h new file mode 100644 index 0000000..f1c1a9a --- /dev/null +++ b/_Project/Browser/BrowserTabCoordinator.h @@ -0,0 +1,66 @@ +#import +#import + +@class BrowserNavigationService; +@class BrowserPreferencesStore; +@class BrowserSessionStore; +@class BrowserTabViewModel; +@class BrowserTopBarView; +@class BrowserViewModel; +@class BrowserWebView; + +NS_ASSUME_NONNULL_BEGIN + +@protocol BrowserTabCoordinatorHost + +- (void)browserTabCoordinatorPresentViewController:(UIViewController *)viewController; +- (void)browserTabCoordinatorUpdateTextFontSize; +- (BOOL)browserTabCoordinatorIsCursorModeEnabled; +- (BOOL)browserTabCoordinatorIsTabOverviewVisible; + +@end + +@interface BrowserTabCoordinator : NSObject + +@property (nonatomic, readonly, nullable) BrowserWebView *activeWebView; +@property (nonatomic, readonly, nullable) BrowserTabViewModel *activeTab; +@property (nonatomic, copy) NSString *requestURL; +@property (nonatomic, copy) NSString *previousURL; + +- (instancetype)initWithHost:(id)host + viewModel:(BrowserViewModel *)viewModel + preferencesStore:(BrowserPreferencesStore *)preferencesStore + navigationService:(BrowserNavigationService *)navigationService + sessionStore:(BrowserSessionStore *)sessionStore + browserContainerView:(UIView *)browserContainerView + rootView:(UIView *)rootView + topMenuView:(BrowserTopBarView *)topMenuView + cursorView:(UIImageView *)cursorView + manualScrollPanRecognizer:(UIPanGestureRecognizer *)manualScrollPanRecognizer + webViewDelegate:(id)webViewDelegate + scrollViewAllowBounces:(BOOL)scrollViewAllowBounces NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +- (void)restoreInitialStateOrCreateFirstTab; +- (void)webViewDidAppear; +- (void)loadHomePage; +- (void)createNewTabLoadingHomePage:(BOOL)loadHomePage; +- (BOOL)createNewTabWithRequest:(NSURLRequest *)request; +- (void)switchToTabAtIndex:(NSInteger)tabIndex; +- (void)closeTabAtIndex:(NSInteger)tabIndex; +- (void)recreateActiveWebViewPreservingCurrentURL; +- (void)captureSnapshotForCurrentTab; +- (void)prepareTabOverviewThumbnails; +- (void)persistSession; +- (void)handleWebViewPanGesture:(UIPanGestureRecognizer *)gestureRecognizer; +- (void)webViewDidStartLoad:(id)webView; +- (void)webViewDidFinishLoad:(id)webView; +- (void)prepareTabForRequest:(NSURLRequest *)request webView:(id)webView; +- (void)setTopNavigationVisible:(BOOL)visible; +- (BrowserTabViewModel *)tabForWebView:(id)webView; +- (BOOL)isPrimaryDocumentRequest:(NSURLRequest *)request; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserTabCoordinator.m b/_Project/Browser/BrowserTabCoordinator.m new file mode 100644 index 0000000..672c432 --- /dev/null +++ b/_Project/Browser/BrowserTabCoordinator.m @@ -0,0 +1,661 @@ +#import "BrowserTabCoordinator.h" + +#import "BrowserNavigationService.h" +#import "BrowserPreferencesStore.h" +#import "BrowserSessionStore.h" +#import "BrowserTabViewModel.h" +#import "BrowserTopBarView.h" +#import "BrowserViewModel.h" +#import "BrowserWebView.h" + +static CGFloat const kThumbnailStagingOffset = 4096.0; + +@interface BrowserTabCoordinator () + +@property (nonatomic, weak) id host; +@property (nonatomic) BrowserViewModel *viewModel; +@property (nonatomic) BrowserPreferencesStore *preferencesStore; +@property (nonatomic) BrowserNavigationService *navigationService; +@property (nonatomic) BrowserSessionStore *sessionStore; +@property (nonatomic, weak) UIView *browserContainerView; +@property (nonatomic, weak) UIView *rootView; +@property (nonatomic, weak) BrowserTopBarView *topMenuView; +@property (nonatomic, weak) UIImageView *cursorView; +@property (nonatomic, weak) UIPanGestureRecognizer *manualScrollPanRecognizer; +@property (nonatomic, weak) id webViewDelegate; +@property (nonatomic) BOOL scrollViewAllowBounces; +@property (nonatomic) NSMutableDictionary *webViewsByTabIdentifier; +@property (nonatomic) UIView *thumbnailStagingView; +@property (nonatomic, readwrite, nullable) BrowserWebView *activeWebView; + +@end + +@implementation BrowserTabCoordinator + +- (instancetype)initWithHost:(id)host + viewModel:(BrowserViewModel *)viewModel + preferencesStore:(BrowserPreferencesStore *)preferencesStore + navigationService:(BrowserNavigationService *)navigationService + sessionStore:(BrowserSessionStore *)sessionStore + browserContainerView:(UIView *)browserContainerView + rootView:(UIView *)rootView + topMenuView:(BrowserTopBarView *)topMenuView + cursorView:(UIImageView *)cursorView + manualScrollPanRecognizer:(UIPanGestureRecognizer *)manualScrollPanRecognizer + webViewDelegate:(id)webViewDelegate + scrollViewAllowBounces:(BOOL)scrollViewAllowBounces { + self = [super init]; + if (self) { + _host = host; + _viewModel = viewModel; + _preferencesStore = preferencesStore; + _navigationService = navigationService; + _sessionStore = sessionStore; + _browserContainerView = browserContainerView; + _rootView = rootView; + _topMenuView = topMenuView; + _cursorView = cursorView; + _manualScrollPanRecognizer = manualScrollPanRecognizer; + _webViewDelegate = webViewDelegate; + _scrollViewAllowBounces = scrollViewAllowBounces; + _webViewsByTabIdentifier = [NSMutableDictionary dictionary]; + [_preferencesStore ensureUserAgentConsistency]; + [self ensureThumbnailStagingView]; + } + return self; +} + +- (BrowserTabViewModel *)activeTab { + return [self.viewModel activeTab]; +} + +- (NSString *)requestURL { + return self.activeTab.requestURL; +} + +- (void)setRequestURL:(NSString *)requestURL { + self.activeTab.requestURL = requestURL ?: @""; +} + +- (NSString *)previousURL { + return self.activeTab.previousURL; +} + +- (void)setPreviousURL:(NSString *)previousURL { + self.activeTab.previousURL = previousURL ?: @""; +} + +- (BOOL)topNavigationVisible { + return self.viewModel.topNavigationBarVisible; +} + +- (CGFloat)topMenuBrowserOffset { + return self.topNavigationVisible ? self.topMenuView.frame.size.height : 0.0; +} + +- (void)setTopNavigationVisible:(BOOL)visible { + self.viewModel.topNavigationBarVisible = visible; + self.topMenuView.hidden = !visible; + [self updateTopNavAndWebView]; +} + +- (void)updateTopNavAndWebView { + if (self.activeWebView == nil) { + return; + } + if (self.topNavigationVisible) { + self.activeWebView.frame = CGRectMake(self.rootView.bounds.origin.x, + self.rootView.bounds.origin.y + self.topMenuBrowserOffset, + self.rootView.bounds.size.width, + self.rootView.bounds.size.height - self.topMenuBrowserOffset); + } else { + self.activeWebView.frame = self.rootView.bounds; + } +} + +- (CGSize)thumbnailViewportSize { + CGFloat width = CGRectGetWidth(self.rootView.bounds); + CGFloat height = CGRectGetHeight(self.rootView.bounds) - self.topMenuBrowserOffset; + return CGSizeMake(MAX(width, 1.0), MAX(height, 1.0)); +} + +- (void)ensureThumbnailStagingView { + if (self.thumbnailStagingView != nil || self.rootView == nil) { + return; + } + + CGSize viewportSize = [self thumbnailViewportSize]; + UIView *stagingView = [[UIView alloc] initWithFrame:CGRectMake(kThumbnailStagingOffset, + 0.0, + viewportSize.width, + viewportSize.height)]; + stagingView.backgroundColor = UIColor.clearColor; + stagingView.userInteractionEnabled = NO; + stagingView.clipsToBounds = YES; + [self.rootView addSubview:stagingView]; + self.thumbnailStagingView = stagingView; +} + +- (void)updateThumbnailStagingViewFrame { + [self ensureThumbnailStagingView]; + if (self.thumbnailStagingView == nil) { + return; + } + + CGSize viewportSize = [self thumbnailViewportSize]; + self.thumbnailStagingView.frame = CGRectMake(kThumbnailStagingOffset, + 0.0, + viewportSize.width, + viewportSize.height); +} + +- (void)prepareWebViewLayoutForSnapshot:(BrowserWebView *)webView { + if (webView == nil) { + return; + } + + if (webView.superview == self.thumbnailStagingView) { + webView.frame = self.thumbnailStagingView.bounds; + } + [webView setNeedsLayout]; + [webView layoutIfNeeded]; + + UIScrollView *scrollView = webView.scrollView; + [scrollView setNeedsLayout]; + [scrollView layoutIfNeeded]; + [self.rootView setNeedsLayout]; + [self.rootView layoutIfNeeded]; +} + +- (void)parkWebViewForThumbnailing:(BrowserWebView *)webView { + if (webView == nil) { + return; + } + + [self updateThumbnailStagingViewFrame]; + webView.userInteractionEnabled = NO; + UIScrollView *scrollView = webView.scrollView; + scrollView.scrollEnabled = NO; + scrollView.bounces = self.scrollViewAllowBounces; + if (webView.superview != self.thumbnailStagingView) { + [webView removeFromSuperview]; + [self.thumbnailStagingView addSubview:webView]; + } + webView.frame = self.thumbnailStagingView.bounds; +} + +- (BOOL)isWebViewStaged:(BrowserWebView *)webView { + return webView != nil && webView.superview == self.thumbnailStagingView; +} + +- (BrowserWebView *)createConfiguredWebView { + BrowserWebView *webView = [[BrowserWebView alloc] initWithUserAgent:self.preferencesStore.userAgent + allowsInlineMediaPlayback:YES]; + webView.translatesAutoresizingMaskIntoConstraints = NO; + webView.clipsToBounds = NO; + webView.delegate = self.webViewDelegate; + webView.layoutMargins = UIEdgeInsetsZero; + webView.opaque = NO; + webView.backgroundColor = UIColor.blackColor; + + UIScrollView *scrollView = webView.scrollView; + scrollView.layoutMargins = UIEdgeInsetsZero; + scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + scrollView.contentOffset = CGPointZero; + scrollView.contentInset = UIEdgeInsetsZero; + scrollView.clipsToBounds = NO; + scrollView.backgroundColor = UIColor.blackColor; + scrollView.bounces = self.scrollViewAllowBounces; + [scrollView.panGestureRecognizer addTarget:self action:@selector(handleWebViewPanGesture:)]; + scrollView.scrollEnabled = NO; + + BOOL shouldScalePagesToFit = self.preferencesStore.scalePagesToFit; + webView.scalesPageToFit = shouldScalePagesToFit; + webView.contentMode = shouldScalePagesToFit ? UIViewContentModeScaleAspectFit : UIViewContentModeScaleToFill; + webView.userInteractionEnabled = NO; + return webView; +} + +- (void)refreshActiveTabUI { + BrowserTabViewModel *tab = self.activeTab; + if (tab == nil) { + self.topMenuView.URLLabel.text = @""; + return; + } + + NSURLRequest *request = self.activeWebView.request; + NSString *currentURL = tab.URLString.length > 0 ? tab.URLString : request.URL.absoluteString; + self.topMenuView.URLLabel.text = currentURL.length > 0 ? currentURL : @"New Tab"; + + if (request != nil) { + [self.host browserTabCoordinatorUpdateTextFontSize]; + } +} + +- (BOOL)restoreBrowserSession { + return [self.sessionStore restoreSessionIntoViewModel:self.viewModel]; +} + +- (void)restoreInitialStateOrCreateFirstTab { + self.topMenuView.hidden = !self.viewModel.topNavigationBarVisible; + if (![self restoreBrowserSession]) { + [self createNewTabLoadingHomePage:NO]; + return; + } + [self initWebView]; + [self refreshActiveTabUI]; +} + +- (void)webViewDidAppear { + NSURLRequest *savedReopenRequest = [self.sessionStore consumeSavedURLToReopenRequestWithNavigationService:self.navigationService]; + if (savedReopenRequest != nil) { + [self.activeWebView loadRequest:savedReopenRequest]; + } else if (self.activeWebView.request == nil) { + [self loadStoredContentForTab:self.activeTab webView:self.activeWebView fallbackToHomePage:YES]; + } +} + +- (void)loadHomePage { + NSURLRequest *homePageRequest = [self.navigationService homePageRequest]; + if (homePageRequest != nil) { + [self.activeWebView loadRequest:homePageRequest]; + } +} + +- (void)initWebView { + self.topMenuView.hidden = !self.viewModel.topNavigationBarVisible; + + BrowserTabViewModel *tab = [self.viewModel ensureActiveTab]; + if (tab == nil) { + return; + } + + BrowserWebView *webView = self.webViewsByTabIdentifier[tab.identifier]; + if (webView == nil) { + webView = [self createConfiguredWebView]; + self.webViewsByTabIdentifier[tab.identifier] = webView; + } + self.activeWebView = webView; + [self attachActiveWebView]; +} + +- (void)attachActiveWebView { + BrowserTabViewModel *tab = self.activeTab; + if (tab == nil) { + return; + } + + BrowserWebView *activeWebView = self.webViewsByTabIdentifier[tab.identifier]; + if (activeWebView == nil) { + return; + } + + [self updateThumbnailStagingViewFrame]; + for (BrowserTabViewModel *candidate in self.viewModel.tabs) { + BrowserWebView *candidateWebView = self.webViewsByTabIdentifier[candidate.identifier]; + if (candidateWebView == nil || candidateWebView == activeWebView) { + continue; + } + [self parkWebViewForThumbnailing:candidateWebView]; + } + + self.activeWebView = activeWebView; + [self.topMenuView.loadingSpinner stopAnimating]; + [self.activeWebView removeFromSuperview]; + [self.browserContainerView addSubview:self.activeWebView]; + [self updateTopNavAndWebView]; + + UIScrollView *scrollView = self.activeWebView.scrollView; + [scrollView setNeedsLayout]; + [scrollView layoutIfNeeded]; + [self.rootView setNeedsLayout]; + [self.rootView layoutIfNeeded]; + scrollView.bounces = self.scrollViewAllowBounces; + + BOOL shouldAllowWebInteraction = ![self.host browserTabCoordinatorIsCursorModeEnabled] && + ![self.host browserTabCoordinatorIsTabOverviewVisible]; + scrollView.scrollEnabled = shouldAllowWebInteraction; + self.activeWebView.userInteractionEnabled = shouldAllowWebInteraction; + self.manualScrollPanRecognizer.enabled = shouldAllowWebInteraction; + + [self refreshActiveTabUI]; +} + +- (void)updateStoredScrollOffsetForTab:(BrowserTabViewModel *)tab { + if (tab == nil) { + return; + } + + BrowserWebView *webView = self.webViewsByTabIdentifier[tab.identifier]; + if (webView == nil) { + return; + } + + UIScrollView *scrollView = webView.scrollView; + tab.savedScrollOffset = scrollView.contentOffset; + tab.hasSavedScrollOffset = YES; +} + +- (void)persistSession { + for (BrowserTabViewModel *tab in self.viewModel.tabs) { + [self updateStoredScrollOffsetForTab:tab]; + } + [self.sessionStore saveSessionForViewModel:self.viewModel]; +} + +- (void)loadStoredContentForTab:(BrowserTabViewModel *)tab + webView:(BrowserWebView *)webView + fallbackToHomePage:(BOOL)fallbackToHomePage { + if (webView == nil) { + return; + } + + NSString *URLString = tab.URLString.length > 0 ? tab.URLString : tab.requestURL; + if (URLString.length == 0) { + if (fallbackToHomePage) { + NSURLRequest *homePageRequest = [self.navigationService homePageRequest]; + if (homePageRequest != nil) { + [webView loadRequest:homePageRequest]; + } + } + return; + } + + NSURLRequest *request = [self.navigationService requestForURLString:URLString]; + if (request != nil) { + [webView loadRequest:request]; + } else if (fallbackToHomePage) { + NSURLRequest *homePageRequest = [self.navigationService homePageRequest]; + if (homePageRequest != nil) { + [webView loadRequest:homePageRequest]; + } + } +} + +- (void)restoreSavedScrollOffsetForTab:(BrowserTabViewModel *)tab webView:(BrowserWebView *)webView { + if (tab == nil || !tab.needsScrollRestore || !tab.hasSavedScrollOffset) { + return; + } + + UIScrollView *scrollView = webView.scrollView; + CGPoint savedScrollOffset = tab.savedScrollOffset; + dispatch_async(dispatch_get_main_queue(), ^{ + [scrollView layoutIfNeeded]; + CGFloat maxOffsetX = MAX(0.0, scrollView.contentSize.width - CGRectGetWidth(scrollView.bounds)); + CGFloat maxOffsetY = MAX(0.0, scrollView.contentSize.height - CGRectGetHeight(scrollView.bounds)); + CGPoint clampedScrollOffset = CGPointMake(MIN(MAX(savedScrollOffset.x, 0.0), maxOffsetX), + MIN(MAX(savedScrollOffset.y, 0.0), maxOffsetY)); + [scrollView setContentOffset:clampedScrollOffset animated:NO]; + tab.savedScrollOffset = clampedScrollOffset; + tab.hasSavedScrollOffset = YES; + [self captureSnapshotForTab:tab]; + [self persistSession]; + }); + tab.needsScrollRestore = NO; +} + +- (void)captureSnapshotForTab:(BrowserTabViewModel *)tab { + if (tab == nil) { + return; + } + + if (!tab.needsScrollRestore) { + [self updateStoredScrollOffsetForTab:tab]; + } + + BrowserWebView *webView = self.webViewsByTabIdentifier[tab.identifier]; + if (webView == nil || CGRectIsEmpty(webView.bounds)) { + return; + } + + [self prepareWebViewLayoutForSnapshot:webView]; + UIGraphicsBeginImageContextWithOptions(webView.bounds.size, YES, 0.0); + [webView drawViewHierarchyInRect:webView.bounds afterScreenUpdates:NO]; + UIImage *snapshotImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + if (snapshotImage != nil) { + tab.snapshotImage = snapshotImage; + } +} + +- (void)captureSnapshotForCurrentTab { + [self captureSnapshotForTab:self.activeTab]; +} + +- (void)prepareTabOverviewThumbnails { + [self updateThumbnailStagingViewFrame]; + + for (BrowserTabViewModel *tab in self.viewModel.tabs) { + BrowserWebView *webView = self.webViewsByTabIdentifier[tab.identifier]; + if (tab == self.activeTab) { + [self captureSnapshotForTab:tab]; + continue; + } + + if (webView == nil) { + webView = [self createConfiguredWebView]; + self.webViewsByTabIdentifier[tab.identifier] = webView; + } + + [self parkWebViewForThumbnailing:webView]; + if (webView.request == nil) { + [self loadStoredContentForTab:tab webView:webView fallbackToHomePage:NO]; + continue; + } + + [self captureSnapshotForTab:tab]; + } +} + +- (void)showMaxTabsAlert { + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Maximum Tabs Reached" + message:@"This build keeps up to five tabs open at once." + preferredStyle:UIAlertControllerStyleAlert]; + [alertController addAction:[UIAlertAction actionWithTitle:@"Dismiss" + style:UIAlertActionStyleCancel + handler:nil]]; + [self.host browserTabCoordinatorPresentViewController:alertController]; +} + +- (void)createNewTabLoadingHomePage:(BOOL)loadHomePage { + BrowserTabViewModel *tab = [self.viewModel addTab]; + if (tab == nil) { + [self showMaxTabsAlert]; + return; + } + + (void)tab; + [self initWebView]; + [self refreshActiveTabUI]; + [self.rootView bringSubviewToFront:self.cursorView]; + + if (loadHomePage) { + [self loadHomePage]; + } + [self persistSession]; +} + +- (BOOL)createNewTabWithRequest:(NSURLRequest *)request { + if (request == nil || request.URL == nil) { + return NO; + } + + [self captureSnapshotForTab:self.activeTab]; + if ([self.viewModel addTab] == nil) { + [self showMaxTabsAlert]; + return NO; + } + + [self initWebView]; + [self refreshActiveTabUI]; + [self.rootView bringSubviewToFront:self.cursorView]; + [self.activeWebView loadRequest:request]; + [self persistSession]; + return YES; +} + +- (void)switchToTabAtIndex:(NSInteger)tabIndex { + if (tabIndex < 0 || tabIndex >= self.viewModel.tabs.count) { + return; + } + + BrowserTabViewModel *currentTab = self.activeTab; + [self captureSnapshotForTab:currentTab]; + + [self.viewModel switchToTabAtIndex:tabIndex]; + [self initWebView]; + [self.rootView bringSubviewToFront:self.cursorView]; + if (self.activeWebView.request == nil) { + [self loadStoredContentForTab:self.activeTab webView:self.activeWebView fallbackToHomePage:YES]; + } + [self persistSession]; +} + +- (void)closeTabAtIndex:(NSInteger)tabIndex { + if (tabIndex < 0 || tabIndex >= self.viewModel.tabs.count) { + return; + } + + BOOL closingActiveTab = tabIndex == self.viewModel.activeTabIndex; + BrowserTabViewModel *tab = self.viewModel.tabs[tabIndex]; + [self.webViewsByTabIdentifier[tab.identifier] removeFromSuperview]; + [self.webViewsByTabIdentifier removeObjectForKey:tab.identifier]; + [self.viewModel removeTabAtIndex:tabIndex]; + + if (self.viewModel.tabs.count == 0) { + [self createNewTabLoadingHomePage:YES]; + return; + } + + if (closingActiveTab) { + [self initWebView]; + if (self.activeWebView.request == nil) { + [self loadStoredContentForTab:self.activeTab webView:self.activeWebView fallbackToHomePage:YES]; + } + } + + [self refreshActiveTabUI]; + [self persistSession]; +} + +- (void)recreateActiveWebViewPreservingCurrentURL { + BrowserTabViewModel *tab = self.activeTab; + if (tab == nil) { + return; + } + + NSString *currentURL = self.activeWebView.request.URL.absoluteString; + [self.webViewsByTabIdentifier[tab.identifier] removeFromSuperview]; + [self.webViewsByTabIdentifier removeObjectForKey:tab.identifier]; + tab.requestURL = currentURL ?: @""; + tab.previousURL = @""; + tab.URLString = currentURL ?: @""; + [self initWebView]; + + if (currentURL.length > 0) { + NSURLRequest *request = [self.navigationService requestForURLString:currentURL]; + if (request != nil) { + [self.activeWebView loadRequest:request]; + } + } else { + [self loadHomePage]; + } + [self persistSession]; +} + +- (void)handleWebViewPanGesture:(UIPanGestureRecognizer *)gestureRecognizer { + if (gestureRecognizer.state != UIGestureRecognizerStateEnded && + gestureRecognizer.state != UIGestureRecognizerStateCancelled && + gestureRecognizer.state != UIGestureRecognizerStateFailed) { + return; + } + + UIView *gestureView = gestureRecognizer.view; + if (![gestureView isKindOfClass:[UIScrollView class]]) { + return; + } + + UIScrollView *scrollView = (UIScrollView *)gestureView; + if (scrollView != self.activeWebView.scrollView) { + return; + } + + [self persistSession]; +} + +- (BOOL)isPrimaryDocumentRequest:(NSURLRequest *)request { + NSURL *requestURL = request.URL; + NSURL *mainDocumentURL = request.mainDocumentURL; + if (requestURL == nil) { + return NO; + } + if (mainDocumentURL == nil) { + return YES; + } + return [requestURL isEqual:mainDocumentURL]; +} + +- (BrowserTabViewModel *)tabForWebView:(id)webView { + for (BrowserTabViewModel *tab in self.viewModel.tabs) { + if (self.webViewsByTabIdentifier[tab.identifier] == webView) { + return tab; + } + } + return nil; +} + +- (void)prepareTabForRequest:(NSURLRequest *)request webView:(id)webView { + BrowserTabViewModel *tab = [self tabForWebView:webView]; + if (tab == nil || ![self isPrimaryDocumentRequest:request]) { + return; + } + NSString *requestURL = request.URL.absoluteString ?: @""; + if (tab.URLString.length > 0 && ![tab.URLString isEqualToString:requestURL]) { + tab.savedScrollOffset = CGPointZero; + tab.hasSavedScrollOffset = NO; + tab.needsScrollRestore = NO; + } + tab.requestURL = requestURL; +} + +- (void)webViewDidStartLoad:(id)webView { + BrowserTabViewModel *tab = [self tabForWebView:webView]; + if (tab == nil) { + return; + } + + if (tab == self.activeTab && ![tab.previousURL isEqualToString:tab.requestURL]) { + [self.topMenuView.loadingSpinner startAnimating]; + } + tab.previousURL = tab.requestURL; +} + +- (void)webViewDidFinishLoad:(id)webView { + BrowserTabViewModel *tab = [self tabForWebView:webView]; + if (tab == nil) { + return; + } + + if (tab == self.activeTab) { + [self.topMenuView.loadingSpinner stopAnimating]; + } + + NSString *theTitle = [webView stringByEvaluatingJavaScriptFromString:@"document.title"]; + NSURLRequest *request = [webView request]; + NSString *currentURL = request.URL.absoluteString ?: @""; + [self.navigationService updateTab:tab withPageTitle:theTitle currentURLString:currentURL]; + + if (tab == self.activeTab) { + [self refreshActiveTabUI]; + } else if ([self isWebViewStaged:webView]) { + [webView pauseAllMediaPlayback]; + } + [self restoreSavedScrollOffsetForTab:tab webView:webView]; + if (!tab.needsScrollRestore) { + [self captureSnapshotForTab:tab]; + [self persistSession]; + } +} + +@end diff --git a/_Project/Browser/BrowserTabOverviewController.h b/_Project/Browser/BrowserTabOverviewController.h new file mode 100644 index 0000000..a2a25e9 --- /dev/null +++ b/_Project/Browser/BrowserTabOverviewController.h @@ -0,0 +1,43 @@ +#import +#import + +@class BrowserTabViewModel; +@class BrowserTopBarView; +@class BrowserViewModel; + +NS_ASSUME_NONNULL_BEGIN + +@protocol BrowserTabOverviewControllerHost + +- (BOOL)browserTabOverviewControllerCursorModeEnabled; +- (void)browserTabOverviewControllerSetCursorModeEnabled:(BOOL)enabled; +- (void)browserTabOverviewControllerPresentViewController:(UIViewController *)viewController; +- (void)browserTabOverviewControllerCreateNewTabLoadingHomePage:(BOOL)loadHomePage; +- (void)browserTabOverviewControllerSwitchToTabAtIndex:(NSInteger)tabIndex; +- (void)browserTabOverviewControllerCloseTabAtIndex:(NSInteger)tabIndex; + +@end + +@interface BrowserTabOverviewController : NSObject + +@property (nonatomic, readonly, getter=isVisible) BOOL visible; + +- (instancetype)initWithHost:(id)host + viewModel:(BrowserViewModel *)viewModel + rootView:(UIView *)rootView + topMenuView:(BrowserTopBarView *)topMenuView + cursorView:(UIImageView *)cursorView NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +- (void)show; +- (void)dismiss; +- (void)reload; +- (void)updateCardAtIndex:(NSInteger)tabIndex; +- (void)handleAlternateAction; +- (BOOL)containsPoint:(CGPoint)viewPoint; +- (BOOL)handleSelectionAtPoint:(CGPoint)viewPoint; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserTabOverviewController.m b/_Project/Browser/BrowserTabOverviewController.m new file mode 100644 index 0000000..813ca32 --- /dev/null +++ b/_Project/Browser/BrowserTabOverviewController.m @@ -0,0 +1,574 @@ +#import "BrowserTabOverviewController.h" + +#import "BrowserTabViewModel.h" +#import "BrowserViewModel.h" + +static CGFloat const kTopBarHorizontalInset = 40.0; +static CGFloat const kTopBarMaxWidth = 1760.0; +static CGFloat const kTopBarHeight = 86.0; +static CGFloat const kTabOverviewPanelTopInset = 120.0; +static CGFloat const kTabOverviewPanelBottomInset = 88.0; +static CGFloat const kTabOverviewPanelSideInset = 54.0; +static CGFloat const kTabOverviewHeaderTopInset = 34.0; +static CGFloat const kTabOverviewTitleHeight = 64.0; +static CGFloat const kTabOverviewSubtitleTop = 94.0; +static CGFloat const kTabOverviewSubtitleHeight = 36.0; +static CGFloat const kTabOverviewContentTopInset = 166.0; +static CGFloat const kTabOverviewFooterHeight = 28.0; +static CGFloat const kTabCardWidth = 584.0; +static CGFloat const kTabCardHeight = 535.0; +static CGFloat const kTabCardThumbnailHeight = 347.0; +static CGFloat const kTabCardSpacing = 50.4; +static CGFloat const kTabCardGlowInset = 18.0; +static CGFloat const kTabCardTitleTop = 369.0; +static CGFloat const kTabCardTitleHeight = 36.0; +static CGFloat const kTabCardURLTop = 409.0; +static CGFloat const kTabCardURLHeight = 64.0; + +@class BrowserTabOverviewViewController; + +@interface BrowserTopAlignedLabel : UILabel +@end + +@implementation BrowserTopAlignedLabel + +- (CGRect)textRectForBounds:(CGRect)bounds limitedToNumberOfLines:(NSInteger)numberOfLines { + CGRect textRect = [super textRectForBounds:bounds limitedToNumberOfLines:numberOfLines]; + textRect.origin.y = bounds.origin.y; + return textRect; +} + +- (void)drawTextInRect:(CGRect)rect { + CGRect textRect = [self textRectForBounds:rect limitedToNumberOfLines:self.numberOfLines]; + [super drawTextInRect:textRect]; +} + +@end + +@interface BrowserTabOverviewController () + +@property (nonatomic, weak) id host; +@property (nonatomic) BrowserViewModel *viewModel; +@property (nonatomic, readwrite, getter=isVisible) BOOL visible; +@property (nonatomic) BOOL cursorModeBeforeShowing; +@property (nonatomic, weak) BrowserTabOverviewViewController *presentedOverviewViewController; + +- (NSInteger)numberOfDisplayItems; +- (NSInteger)activeTabDisplayItemIndex; +- (nullable BrowserTabViewModel *)tabForDisplayItemIndex:(NSInteger)displayItemIndex; +- (void)handleSelectionForDisplayItemIndex:(NSInteger)displayItemIndex; +- (void)handleCloseRequestForDisplayItemIndex:(NSInteger)displayItemIndex; +- (void)handleAlternateAction; +- (void)reloadPresentedOverviewIfNeeded; +- (void)overviewViewControllerDidDisappear:(BrowserTabOverviewViewController *)viewController; + +@end + +@interface BrowserTabOverviewCollectionViewCell : UICollectionViewCell + +- (void)configureAsAddCard; +- (void)configureWithTab:(BrowserTabViewModel *)tab activeTab:(BOOL)activeTab; + +@end + +@interface BrowserTabOverviewCollectionViewCell () + +@property (nonatomic) UIView *cardBackgroundView; +@property (nonatomic) UIImageView *thumbnailView; +@property (nonatomic) UIView *addIconBackdropView; +@property (nonatomic) UIImageView *addIconView; +@property (nonatomic) UILabel *titleLabel; +@property (nonatomic) UILabel *urlLabel; +@property (nonatomic) UILabel *hintLabel; +@property (nonatomic) BOOL addCard; +@property (nonatomic) BOOL activeTab; + +@end + +@implementation BrowserTabOverviewCollectionViewCell + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self) { + self.backgroundColor = UIColor.clearColor; + self.contentView.backgroundColor = UIColor.clearColor; + self.clipsToBounds = NO; + self.contentView.clipsToBounds = NO; + + _cardBackgroundView = [[UIView alloc] initWithFrame:self.contentView.bounds]; + _cardBackgroundView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + _cardBackgroundView.backgroundColor = [UIColor colorWithWhite:0.14 alpha:1.0]; + _cardBackgroundView.layer.cornerRadius = 30.0; + _cardBackgroundView.clipsToBounds = YES; + [self.contentView addSubview:_cardBackgroundView]; + + _thumbnailView = [[UIImageView alloc] initWithFrame:CGRectMake(0.0, 0.0, kTabCardWidth, kTabCardThumbnailHeight)]; + _thumbnailView.backgroundColor = [UIColor colorWithWhite:0.18 alpha:1.0]; + _thumbnailView.contentMode = UIViewContentModeScaleAspectFill; + _thumbnailView.clipsToBounds = YES; + [_cardBackgroundView addSubview:_thumbnailView]; + + _addIconBackdropView = [[UIView alloc] initWithFrame:CGRectMake((kTabCardWidth - 144.0) / 2.0, (kTabCardHeight - 144.0) / 2.0, 144.0, 144.0)]; + _addIconBackdropView.backgroundColor = UIColor.clearColor; + _addIconBackdropView.hidden = YES; + [_cardBackgroundView addSubview:_addIconBackdropView]; + + _addIconView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"plus"]]; + _addIconView.frame = CGRectMake(12.0, 12.0, 120.0, 120.0); + _addIconView.contentMode = UIViewContentModeScaleAspectFit; + [_addIconBackdropView addSubview:_addIconView]; + + _titleLabel = [[UILabel alloc] initWithFrame:CGRectMake(30.0, kTabCardTitleTop, kTabCardWidth - 60.0, kTabCardTitleHeight)]; + _titleLabel.textColor = UIColor.whiteColor; + _titleLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; + [_cardBackgroundView addSubview:_titleLabel]; + + _urlLabel = [[BrowserTopAlignedLabel alloc] initWithFrame:CGRectMake(30.0, kTabCardURLTop, kTabCardWidth - 60.0, kTabCardURLHeight)]; + _urlLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.55]; + _urlLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + _urlLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; + _urlLabel.numberOfLines = 2; + [_cardBackgroundView addSubview:_urlLabel]; + + _hintLabel = [[UILabel alloc] initWithFrame:CGRectMake(30.0, kTabCardHeight - 44.0, kTabCardWidth - 60.0, 24.0)]; + _hintLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.58]; + _hintLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleCaption1]; + _hintLabel.textAlignment = NSTextAlignmentRight; + _hintLabel.hidden = YES; + [_cardBackgroundView addSubview:_hintLabel]; + } + return self; +} + +- (void)prepareForReuse { + [super prepareForReuse]; + self.transform = CGAffineTransformIdentity; +} + +- (void)configureAsAddCard { + self.addCard = YES; + self.activeTab = NO; + self.thumbnailView.hidden = YES; + self.thumbnailView.image = nil; + self.addIconBackdropView.hidden = NO; + self.titleLabel.text = @"New Tab"; + self.urlLabel.text = @"Open the home page"; + self.hintLabel.hidden = YES; + [self updateAppearance]; +} + +- (void)configureWithTab:(BrowserTabViewModel *)tab activeTab:(BOOL)activeTab { + self.addCard = NO; + self.activeTab = activeTab; + self.thumbnailView.hidden = NO; + self.thumbnailView.image = tab.snapshotImage; + self.addIconBackdropView.hidden = YES; + self.titleLabel.text = tab.title.length > 0 ? tab.title : @"New Tab"; + self.urlLabel.text = tab.URLString.length > 0 ? tab.URLString : @"Home page"; + self.hintLabel.text = @"Play/Pause to Close"; + self.hintLabel.hidden = !self.isFocused; + [self updateAppearance]; +} + +- (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator { + [super didUpdateFocusInContext:context withAnimationCoordinator:coordinator]; + [coordinator addCoordinatedAnimations:^{ + [self updateAppearance]; + } completion:nil]; +} + +- (void)updateAppearance { + BOOL focused = self.isFocused; + self.cardBackgroundView.backgroundColor = self.addCard + ? [UIColor colorWithWhite:focused ? 0.20 : 0.16 alpha:1.0] + : [UIColor colorWithWhite:(self.activeTab ? 0.18 : 0.14) alpha:1.0]; + + self.layer.shadowColor = [UIColor colorWithRed:0.23 green:0.57 blue:1.0 alpha:1.0].CGColor; + self.layer.shadowOffset = CGSizeZero; + self.layer.shadowOpacity = (focused || self.activeTab) ? 0.78 : 0.0; + self.layer.shadowRadius = focused ? 18.0 : 12.0; + self.transform = focused ? CGAffineTransformMakeScale(1.06, 1.06) : CGAffineTransformIdentity; + self.hintLabel.hidden = self.addCard || !focused; +} + +@end + +@interface BrowserTabOverviewViewController : UIViewController + +- (instancetype)initWithOverviewController:(BrowserTabOverviewController *)overviewController; +- (void)reload; +- (void)updateCardAtTabIndex:(NSInteger)tabIndex; +- (void)handleAlternateAction; + +@end + +@interface BrowserTabOverviewViewController () + +@property (nonatomic, weak) BrowserTabOverviewController *overviewController; +@property (nonatomic) UIView *dimView; +@property (nonatomic) UIVisualEffectView *panelView; +@property (nonatomic) UICollectionView *collectionView; +@property (nonatomic) UILabel *footerLabel; +@property (nonatomic) NSInteger preferredFocusItemIndex; + +@end + +@implementation BrowserTabOverviewViewController + +- (instancetype)initWithOverviewController:(BrowserTabOverviewController *)overviewController { + self = [super initWithNibName:nil bundle:nil]; + if (self) { + _overviewController = overviewController; + _preferredFocusItemIndex = NSNotFound; + self.modalPresentationStyle = UIModalPresentationOverCurrentContext; + self.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; + } + return self; +} + +- (CGFloat)panelWidth { + CGFloat width = MIN(CGRectGetWidth(self.view.bounds) - (kTopBarHorizontalInset * 2.0), kTopBarMaxWidth); + return MAX(width, 860.0); +} + +- (CGFloat)panelHeight { + CGFloat maxHeight = CGRectGetHeight(self.view.bounds) - kTabOverviewPanelTopInset - kTabOverviewPanelBottomInset; + return MAX(maxHeight, 760.0); +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = UIColor.clearColor; + + UIView *dimView = [UIView new]; + dimView.translatesAutoresizingMaskIntoConstraints = NO; + dimView.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.45]; + [self.view addSubview:dimView]; + self.dimView = dimView; + + UIVisualEffectView *panelView = [[UIVisualEffectView alloc] initWithEffect:[UIBlurEffect effectWithStyle:UIBlurEffectStyleDark]]; + panelView.translatesAutoresizingMaskIntoConstraints = NO; + panelView.alpha = 0.98; + panelView.layer.cornerRadius = kTopBarHeight / 2.0; + panelView.layer.masksToBounds = YES; + [self.view addSubview:panelView]; + self.panelView = panelView; + + UILabel *titleLabel = [UILabel new]; + titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + titleLabel.text = @"Tabs"; + titleLabel.textColor = UIColor.whiteColor; + titleLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle1]; + [panelView.contentView addSubview:titleLabel]; + + UILabel *subtitleLabel = [UILabel new]; + subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO; + subtitleLabel.text = @"Switch tabs, open a new one, or close the focused tab."; + subtitleLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.6]; + subtitleLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + [panelView.contentView addSubview:subtitleLabel]; + + UICollectionViewFlowLayout *layout = [UICollectionViewFlowLayout new]; + layout.scrollDirection = UICollectionViewScrollDirectionHorizontal; + layout.minimumLineSpacing = kTabCardSpacing; + layout.minimumInteritemSpacing = kTabCardSpacing; + layout.sectionInset = UIEdgeInsetsMake(kTabCardGlowInset, kTabCardGlowInset, kTabCardGlowInset, kTabCardGlowInset); + layout.itemSize = CGSizeMake(kTabCardWidth, kTabCardHeight); + + UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout]; + collectionView.translatesAutoresizingMaskIntoConstraints = NO; + collectionView.backgroundColor = UIColor.clearColor; + collectionView.clipsToBounds = NO; + collectionView.showsHorizontalScrollIndicator = NO; + collectionView.showsVerticalScrollIndicator = NO; + collectionView.remembersLastFocusedIndexPath = YES; + collectionView.dataSource = self; + collectionView.delegate = self; + [collectionView registerClass:[BrowserTabOverviewCollectionViewCell class] forCellWithReuseIdentifier:@"TabCard"]; + [panelView.contentView addSubview:collectionView]; + self.collectionView = collectionView; + + UILabel *footerLabel = [UILabel new]; + footerLabel.translatesAutoresizingMaskIntoConstraints = NO; + footerLabel.text = @"Select: Open Play/Pause: Close Focused Tab Menu: Dismiss"; + footerLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.62]; + footerLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleCaption1]; + footerLabel.textAlignment = NSTextAlignmentCenter; + [panelView.contentView addSubview:footerLabel]; + self.footerLabel = footerLabel; + + CGFloat panelWidth = [self panelWidth]; + CGFloat panelHeight = [self panelHeight]; + [NSLayoutConstraint activateConstraints:@[ + [dimView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [dimView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [dimView.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [dimView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + + [panelView.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [panelView.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:kTabOverviewPanelTopInset], + [panelView.widthAnchor constraintEqualToConstant:panelWidth], + [panelView.heightAnchor constraintEqualToConstant:panelHeight], + + [titleLabel.leadingAnchor constraintEqualToAnchor:panelView.contentView.leadingAnchor constant:kTabOverviewPanelSideInset], + [titleLabel.topAnchor constraintEqualToAnchor:panelView.contentView.topAnchor constant:kTabOverviewHeaderTopInset], + [titleLabel.trailingAnchor constraintLessThanOrEqualToAnchor:panelView.contentView.trailingAnchor constant:-kTabOverviewPanelSideInset], + [titleLabel.heightAnchor constraintEqualToConstant:kTabOverviewTitleHeight], + + [subtitleLabel.leadingAnchor constraintEqualToAnchor:panelView.contentView.leadingAnchor constant:kTabOverviewPanelSideInset], + [subtitleLabel.topAnchor constraintEqualToAnchor:panelView.contentView.topAnchor constant:kTabOverviewSubtitleTop], + [subtitleLabel.trailingAnchor constraintEqualToAnchor:panelView.contentView.trailingAnchor constant:-kTabOverviewPanelSideInset], + [subtitleLabel.heightAnchor constraintEqualToConstant:kTabOverviewSubtitleHeight], + + [collectionView.leadingAnchor constraintEqualToAnchor:panelView.contentView.leadingAnchor constant:kTabOverviewPanelSideInset], + [collectionView.trailingAnchor constraintEqualToAnchor:panelView.contentView.trailingAnchor constant:-kTabOverviewPanelSideInset], + [collectionView.topAnchor constraintEqualToAnchor:panelView.contentView.topAnchor constant:kTabOverviewContentTopInset], + [collectionView.bottomAnchor constraintEqualToAnchor:footerLabel.topAnchor constant:-18.0], + + [footerLabel.leadingAnchor constraintEqualToAnchor:panelView.contentView.leadingAnchor constant:kTabOverviewPanelSideInset], + [footerLabel.trailingAnchor constraintEqualToAnchor:panelView.contentView.trailingAnchor constant:-kTabOverviewPanelSideInset], + [footerLabel.bottomAnchor constraintEqualToAnchor:panelView.contentView.bottomAnchor constant:-24.0], + [footerLabel.heightAnchor constraintEqualToConstant:kTabOverviewFooterHeight], + ]]; +} + +- (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; + [self.overviewController overviewViewControllerDidDisappear:self]; +} + +- (void)reload { + NSInteger itemCount = [self.overviewController numberOfDisplayItems]; + if (self.preferredFocusItemIndex == NSNotFound) { + self.preferredFocusItemIndex = [self currentFocusedItemIndex]; + } + if (self.preferredFocusItemIndex == NSNotFound) { + self.preferredFocusItemIndex = [self.overviewController activeTabDisplayItemIndex]; + } + if (itemCount > 0) { + self.preferredFocusItemIndex = MIN(MAX(self.preferredFocusItemIndex, 0), itemCount - 1); + } + [self.collectionView reloadData]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self setNeedsFocusUpdate]; + [self updateFocusIfNeeded]; + }); +} + +- (void)updateCardAtTabIndex:(NSInteger)tabIndex { + NSInteger itemIndex = tabIndex; + if (itemIndex < 0 || itemIndex >= self.overviewController.viewModel.tabs.count) { + [self reload]; + return; + } + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:itemIndex inSection:0]; + if ([[self.collectionView indexPathsForVisibleItems] containsObject:indexPath]) { + [self.collectionView reloadItemsAtIndexPaths:@[indexPath]]; + } else { + [self.collectionView reloadData]; + } +} + +- (void)handleAlternateAction { + NSInteger focusedItemIndex = [self currentFocusedItemIndex]; + if (focusedItemIndex == NSNotFound || focusedItemIndex >= self.overviewController.viewModel.tabs.count) { + return; + } + self.preferredFocusItemIndex = focusedItemIndex; + [self.overviewController handleCloseRequestForDisplayItemIndex:focusedItemIndex]; +} + +- (NSInteger)currentFocusedItemIndex { + for (NSIndexPath *indexPath in self.collectionView.indexPathsForVisibleItems) { + UICollectionViewCell *cell = [self.collectionView cellForItemAtIndexPath:indexPath]; + if (cell.isFocused) { + return indexPath.item; + } + } + return NSNotFound; +} + +- (NSInteger)collectionView:(__unused UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { + return section == 0 ? [self.overviewController numberOfDisplayItems] : 0; +} + +- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { + BrowserTabOverviewCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"TabCard" forIndexPath:indexPath]; + if (indexPath.item == self.overviewController.viewModel.tabs.count) { + [cell configureAsAddCard]; + return cell; + } + + BrowserTabViewModel *tab = [self.overviewController tabForDisplayItemIndex:indexPath.item]; + BOOL activeTab = indexPath.item == [self.overviewController activeTabDisplayItemIndex]; + [cell configureWithTab:tab activeTab:activeTab]; + return cell; +} + +- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { + (void)collectionView; + [self.overviewController handleSelectionForDisplayItemIndex:indexPath.item]; +} + +- (NSIndexPath *)indexPathForPreferredFocusedViewInCollectionView:(UICollectionView *)collectionView { + NSInteger itemCount = [self.overviewController numberOfDisplayItems]; + if (itemCount == 0) { + return nil; + } + NSInteger preferredItemIndex = self.preferredFocusItemIndex; + if (preferredItemIndex == NSNotFound) { + preferredItemIndex = [self.overviewController activeTabDisplayItemIndex]; + } + preferredItemIndex = MIN(MAX(preferredItemIndex, 0), itemCount - 1); + return [NSIndexPath indexPathForItem:preferredItemIndex inSection:0]; +} + +- (NSArray> *)preferredFocusEnvironments { + return @[self.collectionView]; +} + +- (void)pressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event { + UIPress *press = presses.anyObject; + if (press != nil && press.type == UIPressTypeMenu) { + [self.overviewController dismiss]; + return; + } + if (press != nil && press.type == UIPressTypePlayPause) { + [self handleAlternateAction]; + return; + } + [super pressesEnded:presses withEvent:event]; +} + +@end + +@implementation BrowserTabOverviewController + +- (instancetype)initWithHost:(id)host + viewModel:(BrowserViewModel *)viewModel + rootView:(UIView *)rootView + topMenuView:(BrowserTopBarView *)topMenuView + cursorView:(UIImageView *)cursorView { + (void)rootView; + (void)topMenuView; + (void)cursorView; + self = [super init]; + if (self) { + _host = host; + _viewModel = viewModel; + } + return self; +} + +- (NSInteger)numberOfDisplayItems { + return self.viewModel.tabs.count + 1; +} + +- (NSInteger)activeTabDisplayItemIndex { + return self.viewModel.activeTabIndex == NSNotFound ? 0 : self.viewModel.activeTabIndex; +} + +- (BrowserTabViewModel *)tabForDisplayItemIndex:(NSInteger)displayItemIndex { + if (displayItemIndex < 0 || displayItemIndex >= self.viewModel.tabs.count) { + return nil; + } + return self.viewModel.tabs[displayItemIndex]; +} + +- (void)show { + if (self.visible) { + [self reload]; + return; + } + + self.cursorModeBeforeShowing = [self.host browserTabOverviewControllerCursorModeEnabled]; + self.visible = YES; + [self.host browserTabOverviewControllerSetCursorModeEnabled:NO]; + + BrowserTabOverviewViewController *viewController = [[BrowserTabOverviewViewController alloc] initWithOverviewController:self]; + self.presentedOverviewViewController = viewController; + [self.host browserTabOverviewControllerPresentViewController:viewController]; +} + +- (void)dismiss { + if (!self.visible) { + return; + } + + BrowserTabOverviewViewController *viewController = self.presentedOverviewViewController; + if (viewController == nil) { + [self overviewViewControllerDidDisappear:nil]; + return; + } + [viewController dismissViewControllerAnimated:YES completion:nil]; +} + +- (void)reload { + [self reloadPresentedOverviewIfNeeded]; +} + +- (void)updateCardAtIndex:(NSInteger)tabIndex { + [self.presentedOverviewViewController updateCardAtTabIndex:tabIndex]; +} + +- (BOOL)containsPoint:(CGPoint)viewPoint { + (void)viewPoint; + return NO; +} + +- (BOOL)handleSelectionAtPoint:(CGPoint)viewPoint { + (void)viewPoint; + return NO; +} + +- (void)handleSelectionForDisplayItemIndex:(NSInteger)displayItemIndex { + if (displayItemIndex >= self.viewModel.tabs.count) { + BrowserTabOverviewViewController *viewController = self.presentedOverviewViewController; + if (viewController == nil) { + [self.host browserTabOverviewControllerCreateNewTabLoadingHomePage:YES]; + return; + } + + [viewController dismissViewControllerAnimated:YES completion:^{ + [self.host browserTabOverviewControllerCreateNewTabLoadingHomePage:YES]; + }]; + return; + } + + [self.host browserTabOverviewControllerSwitchToTabAtIndex:displayItemIndex]; + [self dismiss]; +} + +- (void)handleCloseRequestForDisplayItemIndex:(NSInteger)displayItemIndex { + NSInteger tabIndex = displayItemIndex; + if (tabIndex < 0 || tabIndex >= self.viewModel.tabs.count || self.viewModel.tabs.count <= 1) { + return; + } + + [self.host browserTabOverviewControllerCloseTabAtIndex:tabIndex]; + [self reloadPresentedOverviewIfNeeded]; +} + +- (void)handleAlternateAction { + [self.presentedOverviewViewController handleAlternateAction]; +} + +- (void)reloadPresentedOverviewIfNeeded { + [self.presentedOverviewViewController reload]; +} + +- (void)overviewViewControllerDidDisappear:(BrowserTabOverviewViewController *)viewController { + if (viewController != nil && viewController != self.presentedOverviewViewController) { + return; + } + + self.presentedOverviewViewController = nil; + if (!self.visible) { + return; + } + + self.visible = NO; + [self.host browserTabOverviewControllerSetCursorModeEnabled:self.cursorModeBeforeShowing]; +} + +@end diff --git a/_Project/Browser/BrowserTabViewModel.h b/_Project/Browser/BrowserTabViewModel.h new file mode 100644 index 0000000..bc29c83 --- /dev/null +++ b/_Project/Browser/BrowserTabViewModel.h @@ -0,0 +1,18 @@ +#import + +@interface BrowserTabViewModel : NSObject + +@property (nonatomic, copy, readonly) NSString *identifier; +@property (nonatomic, copy) NSString *requestURL; +@property (nonatomic, copy) NSString *previousURL; +@property (nonatomic, copy) NSString *title; +@property (nonatomic, copy) NSString *URLString; +@property (nonatomic, strong) UIImage *snapshotImage; +@property (nonatomic) CGPoint savedScrollOffset; +@property (nonatomic) BOOL hasSavedScrollOffset; +@property (nonatomic) BOOL needsScrollRestore; + +- (instancetype)initWithSessionRepresentation:(NSDictionary *)sessionRepresentation; +- (NSDictionary *)sessionRepresentation; + +@end diff --git a/_Project/Browser/BrowserTabViewModel.m b/_Project/Browser/BrowserTabViewModel.m new file mode 100644 index 0000000..1e84f3b --- /dev/null +++ b/_Project/Browser/BrowserTabViewModel.m @@ -0,0 +1,59 @@ +#import "BrowserTabViewModel.h" + +@implementation BrowserTabViewModel + +- (instancetype)init { + self = [super init]; + if (self) { + _identifier = [[[NSUUID UUID] UUIDString] copy]; + _requestURL = @""; + _previousURL = @""; + _title = @"New Tab"; + _URLString = @""; + _savedScrollOffset = CGPointZero; + _hasSavedScrollOffset = NO; + _needsScrollRestore = NO; + } + return self; +} + +- (instancetype)initWithSessionRepresentation:(NSDictionary *)sessionRepresentation { + self = [self init]; + if (self == nil) { + return nil; + } + + NSString *requestURL = [sessionRepresentation[@"requestURL"] isKindOfClass:[NSString class]] ? sessionRepresentation[@"requestURL"] : @""; + NSString *previousURL = [sessionRepresentation[@"previousURL"] isKindOfClass:[NSString class]] ? sessionRepresentation[@"previousURL"] : @""; + NSString *title = [sessionRepresentation[@"title"] isKindOfClass:[NSString class]] ? sessionRepresentation[@"title"] : @"New Tab"; + NSString *URLString = [sessionRepresentation[@"URLString"] isKindOfClass:[NSString class]] ? sessionRepresentation[@"URLString"] : @""; + NSNumber *scrollOffsetX = [sessionRepresentation[@"scrollOffsetX"] isKindOfClass:[NSNumber class]] ? sessionRepresentation[@"scrollOffsetX"] : nil; + NSNumber *scrollOffsetY = [sessionRepresentation[@"scrollOffsetY"] isKindOfClass:[NSNumber class]] ? sessionRepresentation[@"scrollOffsetY"] : nil; + + self.requestURL = requestURL; + self.previousURL = previousURL; + self.title = title.length > 0 ? title : @"New Tab"; + self.URLString = URLString; + if (scrollOffsetX != nil && scrollOffsetY != nil) { + self.savedScrollOffset = CGPointMake(scrollOffsetX.doubleValue, scrollOffsetY.doubleValue); + self.hasSavedScrollOffset = YES; + self.needsScrollRestore = YES; + } + + return self; +} + +- (NSDictionary *)sessionRepresentation { + NSMutableDictionary *representation = [NSMutableDictionary dictionary]; + representation[@"requestURL"] = self.requestURL ?: @""; + representation[@"previousURL"] = self.previousURL ?: @""; + representation[@"title"] = self.title ?: @"New Tab"; + representation[@"URLString"] = self.URLString ?: @""; + if (self.hasSavedScrollOffset) { + representation[@"scrollOffsetX"] = @(self.savedScrollOffset.x); + representation[@"scrollOffsetY"] = @(self.savedScrollOffset.y); + } + return representation; +} + +@end diff --git a/_Project/Browser/BrowserTopBarView.h b/_Project/Browser/BrowserTopBarView.h new file mode 100644 index 0000000..e232d74 --- /dev/null +++ b/_Project/Browser/BrowserTopBarView.h @@ -0,0 +1,44 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, BrowserTopBarAction) { + BrowserTopBarActionBack = 0, + BrowserTopBarActionRefresh, + BrowserTopBarActionForward, + BrowserTopBarActionHome, + BrowserTopBarActionTabs, + BrowserTopBarActionURL, + BrowserTopBarActionFullscreen, + BrowserTopBarActionMenu +}; + +@class BrowserTopBarView; + +@protocol BrowserTopBarViewDelegate + +- (void)browserTopBarView:(BrowserTopBarView *)topBarView didTriggerAction:(BrowserTopBarAction)action; + +@end + +@interface BrowserTopBarView : UIVisualEffectView + +@property (nonatomic, weak, nullable) id delegate; +@property (nonatomic, readonly) UIImageView *backImageView; +@property (nonatomic, readonly) UIImageView *refreshImageView; +@property (nonatomic, readonly) UIImageView *forwardImageView; +@property (nonatomic, readonly) UIImageView *homeImageView; +@property (nonatomic, readonly) UIImageView *tabsImageView; +@property (nonatomic, readonly) UIImageView *fullscreenImageView; +@property (nonatomic, readonly) UIImageView *menuImageView; +@property (nonatomic, readonly) UILabel *URLLabel; +@property (nonatomic, readonly) UIActivityIndicatorView *loadingSpinner; +@property (nonatomic, readonly, getter=isFocusModeActive) BOOL focusModeActive; + +- (CGRect)interactiveFrameForView:(UIView *)view; +- (void)setFocusModeActive:(BOOL)focusModeActive; +- (nullable UIView *)preferredFocusItem; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserTopBarView.m b/_Project/Browser/BrowserTopBarView.m new file mode 100644 index 0000000..bf33d6d --- /dev/null +++ b/_Project/Browser/BrowserTopBarView.m @@ -0,0 +1,468 @@ +#import "BrowserTopBarView.h" + +#if __has_include() +#import +#endif + +static CGFloat const kTopBarHorizontalInset = 40.0; +static CGFloat const kTopBarVerticalInset = 8.0; +static CGFloat const kTopBarHeight = 86.0; +static CGFloat const kTopBarMaxWidth = 1760.0; +static CGFloat const kTopBarIconSize = 52.0; +static CGFloat const kTopBarLeadingPadding = 28.0; +static CGFloat const kTopBarTrailingPadding = 26.0; +static CGFloat const kTopBarIconSpacing = 24.0; +static CGFloat const kTopBarLabelSpacing = 28.0; +static CGFloat const kTopBarSpinnerSpacing = 22.0; +static CGFloat const kTopBarFocusHighlightInset = 10.0; +static CGFloat const kTopBarUniformFocusHeight = 72.0; + +@interface BrowserTopBarFocusButton : UIButton + +@property (nonatomic) BrowserTopBarAction topBarAction; + +@end + +@implementation BrowserTopBarFocusButton + +- (BOOL)canBecomeFocused { + return self.enabled && !self.hidden && self.alpha > 0.01; +} + +- (UIFocusSoundIdentifier)soundIdentifierForFocusUpdateInContext:(__unused UIFocusUpdateContext *)context { + return UIFocusSoundIdentifierDefault; +} + +@end + +@interface BrowserTopBarView () + +@property (nonatomic) UIView *chromeContainerView; +@property (nonatomic) UIVisualEffectView *chromeEffectView; +@property (nonatomic) UIImageView *backImageView; +@property (nonatomic) UIImageView *refreshImageView; +@property (nonatomic) UIImageView *forwardImageView; +@property (nonatomic) UIImageView *homeImageView; +@property (nonatomic) UIImageView *tabsImageView; +@property (nonatomic) UIImageView *fullscreenImageView; +@property (nonatomic) UIImageView *menuImageView; +@property (nonatomic) UILabel *URLLabel; +@property (nonatomic) UIActivityIndicatorView *loadingSpinner; +@property (nonatomic) UIView *focusGlowView; +@property (nonatomic) UIView *focusHighlightView; +@property (nonatomic) NSArray *focusButtons; +@property (nonatomic) BrowserTopBarFocusButton *backFocusButton; +@property (nonatomic) BrowserTopBarFocusButton *refreshFocusButton; +@property (nonatomic) BrowserTopBarFocusButton *forwardFocusButton; +@property (nonatomic) BrowserTopBarFocusButton *homeFocusButton; +@property (nonatomic) BrowserTopBarFocusButton *tabsFocusButton; +@property (nonatomic) BrowserTopBarFocusButton *URLFocusButton; +@property (nonatomic) BrowserTopBarFocusButton *fullscreenFocusButton; +@property (nonatomic) BrowserTopBarFocusButton *menuFocusButton; +@property (nonatomic) BrowserTopBarFocusButton *lastFocusedButton; +@property (nonatomic, getter=isFocusModeActive) BOOL focusModeActive; + +@end + +@implementation BrowserTopBarView + +- (instancetype)initWithCoder:(NSCoder *)coder { + self = [super initWithCoder:coder]; + if (self) { + [self commonInit]; + } + return self; +} + +- (instancetype)initWithEffect:(UIVisualEffect *)effect { + self = [super initWithEffect:effect]; + if (self) { + [self commonInit]; + } + return self; +} + +- (void)awakeFromNib { + [super awakeFromNib]; + + for (UIView *subview in [self.contentView.subviews copy]) { + if (subview != self.chromeContainerView) { + [subview removeFromSuperview]; + } + } +} + +- (void)commonInit { + self.effect = nil; + self.backgroundColor = UIColor.clearColor; + self.clipsToBounds = NO; + self.userInteractionEnabled = YES; + self.contentView.clipsToBounds = NO; + + self.chromeContainerView = [[UIView alloc] initWithFrame:CGRectZero]; + self.chromeContainerView.backgroundColor = UIColor.clearColor; + self.chromeContainerView.userInteractionEnabled = YES; + self.chromeContainerView.clipsToBounds = NO; + [self.contentView addSubview:self.chromeContainerView]; + + self.chromeEffectView = [[UIVisualEffectView alloc] initWithEffect:nil]; + self.chromeEffectView.userInteractionEnabled = YES; + self.chromeEffectView.clipsToBounds = YES; + [self.chromeContainerView addSubview:self.chromeEffectView]; + + self.focusGlowView = [[UIView alloc] initWithFrame:CGRectZero]; + self.focusGlowView.backgroundColor = UIColor.clearColor; + self.focusGlowView.userInteractionEnabled = NO; + self.focusGlowView.hidden = YES; + self.focusGlowView.layer.shadowColor = [UIColor colorWithRed:0.23 green:0.57 blue:1.0 alpha:1.0].CGColor; + self.focusGlowView.layer.shadowOffset = CGSizeZero; + self.focusGlowView.layer.shadowOpacity = 0.0; + self.focusGlowView.layer.shadowRadius = 18.0; + [self.chromeContainerView insertSubview:self.focusGlowView belowSubview:self.chromeEffectView]; + + self.focusHighlightView = [[UIView alloc] initWithFrame:CGRectZero]; + self.focusHighlightView.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.18]; + self.focusHighlightView.layer.cornerRadius = 22.0; + self.focusHighlightView.alpha = 0.0; + self.focusHighlightView.hidden = YES; + [self.chromeEffectView.contentView addSubview:self.focusHighlightView]; + + _backImageView = [self newIconViewNamed:@"go-back-left-arrow"]; + _refreshImageView = [self newIconViewNamed:@"refresh-button"]; + _forwardImageView = [self newIconViewNamed:@"right-arrow-forward"]; + _homeImageView = [self newIconViewNamed:@"house-outline"]; + _tabsImageView = [self newIconViewNamed:@"multi-tab"]; + _fullscreenImageView = [self newIconViewNamed:@"resize-arrows"]; + _menuImageView = [self newIconViewNamed:@"menu-2"]; + + _URLLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + _URLLabel.text = @"tvOS Browser"; + _URLLabel.textAlignment = NSTextAlignmentCenter; + _URLLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.72]; + _URLLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; + _URLLabel.adjustsFontSizeToFitWidth = NO; + _URLLabel.lineBreakMode = NSLineBreakByTruncatingTail; + [self.chromeEffectView.contentView addSubview:_URLLabel]; + + _loadingSpinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + _loadingSpinner.color = [UIColor colorWithWhite:1.0 alpha:0.92]; + _loadingSpinner.tintColor = [UIColor colorWithWhite:1.0 alpha:0.92]; + _loadingSpinner.hidesWhenStopped = YES; + [self.chromeEffectView.contentView addSubview:_loadingSpinner]; + + NSArray *iconViews = @[ + _backImageView, + _refreshImageView, + _forwardImageView, + _homeImageView, + _tabsImageView, + _fullscreenImageView, + _menuImageView + ]; + for (UIImageView *imageView in iconViews) { + [self.chromeEffectView.contentView addSubview:imageView]; + } + + _backFocusButton = [self newFocusButtonForAction:BrowserTopBarActionBack accessibilityLabel:@"Back"]; + _refreshFocusButton = [self newFocusButtonForAction:BrowserTopBarActionRefresh accessibilityLabel:@"Reload"]; + _forwardFocusButton = [self newFocusButtonForAction:BrowserTopBarActionForward accessibilityLabel:@"Forward"]; + _homeFocusButton = [self newFocusButtonForAction:BrowserTopBarActionHome accessibilityLabel:@"Home"]; + _tabsFocusButton = [self newFocusButtonForAction:BrowserTopBarActionTabs accessibilityLabel:@"Tabs"]; + _URLFocusButton = [self newFocusButtonForAction:BrowserTopBarActionURL accessibilityLabel:@"Enter URL or Search"]; + _fullscreenFocusButton = [self newFocusButtonForAction:BrowserTopBarActionFullscreen accessibilityLabel:@"Top Navigation Visibility"]; + _menuFocusButton = [self newFocusButtonForAction:BrowserTopBarActionMenu accessibilityLabel:@"Menu"]; + _focusButtons = @[ + _backFocusButton, + _refreshFocusButton, + _forwardFocusButton, + _homeFocusButton, + _tabsFocusButton, + _URLFocusButton, + _fullscreenFocusButton, + _menuFocusButton + ]; + for (BrowserTopBarFocusButton *button in _focusButtons) { + [self.chromeEffectView.contentView addSubview:button]; + } + + [self applyVisualStyle]; + [self setFocusModeActive:NO]; +} + +- (UIImageView *)newIconViewNamed:(NSString *)imageName { + UIImageView *imageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:imageName]]; + imageView.userInteractionEnabled = NO; + imageView.contentMode = UIViewContentModeScaleAspectFit; + imageView.alpha = 0.95; + return imageView; +} + +- (BrowserTopBarFocusButton *)newFocusButtonForAction:(BrowserTopBarAction)action + accessibilityLabel:(NSString *)accessibilityLabel { + BrowserTopBarFocusButton *button = [BrowserTopBarFocusButton buttonWithType:UIButtonTypeCustom]; + button.topBarAction = action; + button.backgroundColor = UIColor.clearColor; + button.hidden = YES; + button.enabled = NO; + button.accessibilityLabel = accessibilityLabel; + button.accessibilityTraits = UIAccessibilityTraitButton; + [button addTarget:self action:@selector(handleFocusButtonPrimaryAction:) forControlEvents:UIControlEventPrimaryActionTriggered]; + return button; +} + +- (void)applyVisualStyle { +#if __has_include() + if (@available(tvOS 26.0, *)) { + self.effect = nil; + + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; + glassEffect.interactive = YES; + glassEffect.tintColor = [UIColor colorWithWhite:1.0 alpha:0.10]; + self.chromeEffectView.effect = glassEffect; + self.chromeEffectView.alpha = 1.0; + self.chromeContainerView.layer.shadowOpacity = 0.0; + self.chromeContainerView.layer.shadowOffset = CGSizeZero; + self.chromeContainerView.layer.shadowRadius = 0.0; + self.chromeContainerView.layer.borderWidth = 0.0; + return; + } +#endif + + self.effect = nil; + self.chromeEffectView.effect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleDark]; + self.chromeEffectView.alpha = 0.98; + self.chromeContainerView.layer.shadowColor = UIColor.blackColor.CGColor; + self.chromeContainerView.layer.shadowOpacity = 0.28; + self.chromeContainerView.layer.shadowOffset = CGSizeMake(0.0, 12.0); + self.chromeContainerView.layer.shadowRadius = 22.0; + self.chromeContainerView.layer.borderColor = [UIColor colorWithWhite:1.0 alpha:0.14].CGColor; + self.chromeContainerView.layer.borderWidth = 1.0; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + self.contentView.frame = self.bounds; + + CGFloat width = MIN(CGRectGetWidth(self.bounds) - (kTopBarHorizontalInset * 2.0), kTopBarMaxWidth); + width = MAX(width, 860.0); + CGFloat originX = floor((CGRectGetWidth(self.bounds) - width) / 2.0); + CGRect chromeFrame = CGRectMake(originX, kTopBarVerticalInset, width, kTopBarHeight); + + self.chromeContainerView.frame = chromeFrame; + self.chromeContainerView.layer.cornerRadius = chromeFrame.size.height / 2.0; + self.chromeContainerView.layer.shadowPath = [UIBezierPath bezierPathWithRoundedRect:self.chromeContainerView.bounds + cornerRadius:self.chromeContainerView.layer.cornerRadius].CGPath; + + self.chromeEffectView.frame = self.chromeContainerView.bounds; + self.chromeEffectView.layer.cornerRadius = self.chromeContainerView.layer.cornerRadius; + + CGFloat iconY = floor((CGRectGetHeight(chromeFrame) - kTopBarIconSize) / 2.0); + CGFloat leftX = kTopBarLeadingPadding; + NSArray *leftIcons = @[ + self.backImageView, + self.refreshImageView, + self.forwardImageView, + self.homeImageView, + self.tabsImageView + ]; + for (UIImageView *imageView in leftIcons) { + imageView.frame = CGRectMake(leftX, iconY, kTopBarIconSize, kTopBarIconSize); + leftX += kTopBarIconSize + kTopBarIconSpacing; + } + + CGFloat rightX = CGRectGetWidth(chromeFrame) - kTopBarTrailingPadding - kTopBarIconSize; + self.menuImageView.frame = CGRectMake(rightX, iconY, kTopBarIconSize, kTopBarIconSize); + + rightX = CGRectGetMinX(self.menuImageView.frame) - kTopBarIconSpacing - kTopBarIconSize; + self.fullscreenImageView.frame = CGRectMake(rightX, iconY, kTopBarIconSize, kTopBarIconSize); + + CGFloat spinnerSide = 34.0; + rightX = CGRectGetMinX(self.fullscreenImageView.frame) - kTopBarSpinnerSpacing - spinnerSide; + self.loadingSpinner.frame = CGRectMake(rightX, + floor((CGRectGetHeight(chromeFrame) - spinnerSide) / 2.0), + spinnerSide, + spinnerSide); + + CGFloat labelOriginX = CGRectGetMaxX(self.tabsImageView.frame) + kTopBarLabelSpacing; + CGFloat labelTrailingX = CGRectGetMinX(self.loadingSpinner.frame) - kTopBarLabelSpacing; + CGFloat labelWidth = MAX(200.0, labelTrailingX - labelOriginX); + self.URLLabel.frame = CGRectMake(labelOriginX, + 0.0, + labelWidth, + CGRectGetHeight(chromeFrame)); + + self.backFocusButton.frame = [self focusFrameForIconView:self.backImageView]; + self.refreshFocusButton.frame = [self focusFrameForIconView:self.refreshImageView]; + self.forwardFocusButton.frame = [self focusFrameForIconView:self.forwardImageView]; + self.homeFocusButton.frame = [self focusFrameForIconView:self.homeImageView]; + self.tabsFocusButton.frame = [self focusFrameForIconView:self.tabsImageView]; + self.URLFocusButton.frame = [self focusFrameForLabel:self.URLLabel]; + self.fullscreenFocusButton.frame = [self focusFrameForIconView:self.fullscreenImageView]; + self.menuFocusButton.frame = [self focusFrameForMenuIconView:self.menuImageView]; + + if (!self.focusModeActive) { + [self resetFocusVisualState]; + return; + } + + [self updateHighlightFrameForCurrentFocus]; +} + +- (CGRect)interactiveFrameForView:(UIView *)view { + return [self convertRect:view.bounds fromView:view]; +} + +- (CGRect)uniformFocusFrameForRect:(CGRect)frame horizontalInset:(CGFloat)horizontalInset { + CGFloat normalizedHeight = MIN(kTopBarUniformFocusHeight, CGRectGetHeight(self.chromeEffectView.bounds)); + CGFloat originY = floor((CGRectGetHeight(self.chromeEffectView.bounds) - normalizedHeight) / 2.0); + frame.origin.x -= horizontalInset; + frame.size.width += horizontalInset * 2.0; + frame.origin.y = originY; + frame.size.height = normalizedHeight; + return CGRectIntegral(frame); +} + +- (CGRect)focusFrameForIconView:(UIView *)view { + CGRect frame = view.frame; + return [self uniformFocusFrameForRect:frame horizontalInset:12.0]; +} + +- (CGRect)focusFrameForMenuIconView:(UIView *)view { + return [self focusFrameForIconView:view]; +} + +- (CGRect)focusFrameForLabel:(UIView *)view { + CGRect frame = view.frame; + return [self uniformFocusFrameForRect:frame horizontalInset:16.0]; +} + +- (CGRect)highlightFrameForButton:(BrowserTopBarFocusButton *)button { + CGRect frame = button.frame; + frame = CGRectInset(frame, kTopBarFocusHighlightInset, 0.0); + return CGRectIntegral(frame); +} + +- (CGRect)glowFrameForButton:(BrowserTopBarFocusButton *)button { + CGRect frame = [self highlightFrameForButton:button]; + return CGRectInset(frame, -4.0, -4.0); +} + +- (void)ensureFocusGlowViewAttached { + if (self.focusGlowView.superview == self.chromeContainerView) { + return; + } + [self.focusGlowView removeFromSuperview]; + [self.chromeContainerView insertSubview:self.focusGlowView belowSubview:self.chromeEffectView]; +} + +- (void)resetFocusVisualState { + [self.focusGlowView.layer removeAllAnimations]; + [self.focusHighlightView.layer removeAllAnimations]; + self.focusGlowView.hidden = YES; + self.focusGlowView.alpha = 0.0; + self.focusGlowView.frame = CGRectZero; + self.focusGlowView.layer.shadowOpacity = 0.0; + self.focusGlowView.layer.shadowPath = nil; + + self.focusHighlightView.hidden = YES; + self.focusHighlightView.alpha = 0.0; + self.focusHighlightView.frame = CGRectZero; + + [self.focusGlowView removeFromSuperview]; + [self.layer setNeedsDisplay]; +} + +- (void)updateHighlightFrameForCurrentFocus { + if (!self.focusModeActive || self.lastFocusedButton == nil || !self.lastFocusedButton.focused) { + return; + } + [self ensureFocusGlowViewAttached]; + CGRect glowFrame = [self glowFrameForButton:self.lastFocusedButton]; + self.focusGlowView.hidden = NO; + self.focusGlowView.alpha = 1.0; + self.focusGlowView.frame = glowFrame; + self.focusGlowView.layer.cornerRadius = MIN(CGRectGetHeight(glowFrame) / 2.0, 24.0); + self.focusGlowView.layer.shadowPath = [UIBezierPath bezierPathWithRoundedRect:self.focusGlowView.bounds + cornerRadius:self.focusGlowView.layer.cornerRadius].CGPath; + self.focusGlowView.layer.shadowOpacity = 0.78; + self.focusHighlightView.hidden = YES; + self.focusHighlightView.alpha = 0.0; + self.focusHighlightView.frame = CGRectZero; +} + +- (void)setFocusModeActive:(BOOL)focusModeActive { + if (_focusModeActive == focusModeActive) { + return; + } + + _focusModeActive = focusModeActive; + for (BrowserTopBarFocusButton *button in self.focusButtons) { + button.hidden = !focusModeActive; + button.enabled = focusModeActive; + } + + if (!focusModeActive) { + self.lastFocusedButton = nil; + [self resetFocusVisualState]; + } else { + [self setNeedsLayout]; + } +} + +- (UIView *)preferredFocusItem { + if (!self.focusModeActive) { + return nil; + } + return self.lastFocusedButton ?: self.URLFocusButton; +} + +- (void)handleFocusButtonPrimaryAction:(BrowserTopBarFocusButton *)button { + id delegate = self.delegate; + if (delegate == nil) { + return; + } + [delegate browserTopBarView:self didTriggerAction:button.topBarAction]; +} + +- (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator { + [super didUpdateFocusInContext:context withAnimationCoordinator:coordinator]; + + BrowserTopBarFocusButton *nextFocusedButton = [context.nextFocusedView isKindOfClass:[BrowserTopBarFocusButton class]] ? (BrowserTopBarFocusButton *)context.nextFocusedView : nil; + BrowserTopBarFocusButton *previousFocusedButton = [context.previouslyFocusedView isKindOfClass:[BrowserTopBarFocusButton class]] ? (BrowserTopBarFocusButton *)context.previouslyFocusedView : nil; + + if (nextFocusedButton != nil) { + self.lastFocusedButton = nextFocusedButton; + } + + [coordinator addCoordinatedAnimations:^{ + if (!self.focusModeActive || nextFocusedButton == nil) { + self.focusGlowView.alpha = 0.0; + self.focusGlowView.layer.shadowOpacity = 0.0; + self.focusHighlightView.alpha = 0.0; + return; + } + + [self ensureFocusGlowViewAttached]; + CGRect glowFrame = [self glowFrameForButton:nextFocusedButton]; + self.focusGlowView.hidden = NO; + self.focusGlowView.alpha = 1.0; + self.focusGlowView.frame = glowFrame; + self.focusGlowView.layer.cornerRadius = MIN(CGRectGetHeight(glowFrame) / 2.0, 24.0); + self.focusGlowView.layer.shadowPath = [UIBezierPath bezierPathWithRoundedRect:self.focusGlowView.bounds + cornerRadius:self.focusGlowView.layer.cornerRadius].CGPath; + self.focusGlowView.layer.shadowRadius = 18.0; + self.focusGlowView.layer.shadowOpacity = 0.78; + self.focusHighlightView.hidden = YES; + self.focusHighlightView.alpha = 0.0; + self.focusHighlightView.frame = CGRectZero; + previousFocusedButton.alpha = 1.0; + nextFocusedButton.alpha = 1.0; + } completion:^{ + if (!self.focusModeActive || nextFocusedButton == nil) { + [self resetFocusVisualState]; + } + }]; +} + +@end diff --git a/_Project/Browser/BrowserVideoPlaybackCoordinator.h b/_Project/Browser/BrowserVideoPlaybackCoordinator.h new file mode 100644 index 0000000..6246168 --- /dev/null +++ b/_Project/Browser/BrowserVideoPlaybackCoordinator.h @@ -0,0 +1,31 @@ +#import +#import + +@class BrowserDOMInteractionService; +@class BrowserWebView; + +NS_ASSUME_NONNULL_BEGIN + +@protocol BrowserVideoPlaybackCoordinatorHost + +@property (nonatomic, readonly) BrowserWebView *browserWebView; +@property (nonatomic, readonly) BOOL browserIsCursorModeEnabled; +@property (nonatomic, readonly) CGPoint browserDOMCursorPoint; +@property (nonatomic, readonly, nullable) UIViewController *browserPresentedViewController; +@property (nonatomic, readonly, nullable) NSString *browserCurrentPageTitle; +@property (nonatomic, readonly) BOOL browserFullscreenVideoPlaybackEnabled; + +- (void)browserPresentViewController:(UIViewController *)viewController; + +@end + +@interface BrowserVideoPlaybackCoordinator : NSObject + +- (instancetype)initWithHost:(id)host + domInteractionService:(BrowserDOMInteractionService *)domInteractionService; +- (void)playVideoUnderCursorIfAvailable; +- (BOOL)handleSelectPressForVideoAtCursor; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserVideoPlaybackCoordinator.m b/_Project/Browser/BrowserVideoPlaybackCoordinator.m new file mode 100644 index 0000000..21bc0c2 --- /dev/null +++ b/_Project/Browser/BrowserVideoPlaybackCoordinator.m @@ -0,0 +1,344 @@ +#import "BrowserVideoPlaybackCoordinator.h" + +#import "BrowserDOMInteractionService.h" +#import "BrowserNativeVideoPlayerViewController.h" +#import "BrowserWebView.h" +#import "BrowserYouTubeExtractor.h" + +static NSString * const kUserAgentDefaultsKey = @"UserAgent"; +static BOOL const kBrowserYouTubeNativeExtractionEnabled = NO; + +@interface BrowserVideoPlaybackCoordinator () + +@property (nonatomic, weak) id host; +@property (nonatomic) BrowserDOMInteractionService *domInteractionService; +@property (nonatomic) BrowserYouTubeExtractor *youTubeExtractor; + +@end + +@implementation BrowserVideoPlaybackCoordinator + +- (BOOL)isFullscreenVideoPlaybackEnabled { + return self.host.browserFullscreenVideoPlaybackEnabled; +} + +- (instancetype)initWithHost:(id)host + domInteractionService:(BrowserDOMInteractionService *)domInteractionService { + self = [super init]; + if (self) { + _host = host; + _domInteractionService = domInteractionService; + } + return self; +} + +- (BrowserYouTubeExtractor *)youTubeExtractor { + if (_youTubeExtractor == nil) { + _youTubeExtractor = [BrowserYouTubeExtractor new]; + } + return _youTubeExtractor; +} + +- (void)playVideoUnderCursorIfAvailable { + if (![self isFullscreenVideoPlaybackEnabled]) { + return; + } + + UIViewController *presentedViewController = self.host.browserPresentedViewController; + if (!self.host.browserIsCursorModeEnabled || + (presentedViewController != nil && ![presentedViewController isKindOfClass:[UIAlertController class]])) { + return; + } + + NSURL *pageURL = self.host.browserWebView.request.URL; + CGPoint point = self.host.browserDOMCursorPoint; + NSDictionary *videoInfo = [self.domInteractionService videoInfoAtDOMPoint:point + webView:self.host.browserWebView]; + if (kBrowserYouTubeNativeExtractionEnabled && [[self youTubeExtractor] canExtractFromPageURL:pageURL]) { + [self playYouTubeVideoAtPageURL:pageURL fallbackVideoInfo:videoInfo]; + return; + } + + NSString *videoURLString = [self nativePlayableURLStringFromVideoInfo:videoInfo]; + if (![self isNativePlayableVideoURLString:videoURLString] && + [self.domInteractionService isVideoActivationTargetAtDOMPoint:point webView:self.host.browserWebView]) { + NSDictionary *activatedVideoInfo = [self.domInteractionService activateVideoTargetAtDOMPoint:point + webView:self.host.browserWebView + timeout:1.5]; + if (activatedVideoInfo.count > 0) { + videoInfo = activatedVideoInfo; + videoURLString = [self nativePlayableURLStringFromVideoInfo:videoInfo]; + } + } + + if (![self isNativePlayableVideoURLString:videoURLString]) { + [self presentUnsupportedNativeVideoAlertForVideoInfo:videoInfo ?: @{}]; + return; + } + + NSURL *videoURL = [NSURL URLWithString:videoURLString]; + NSString *title = [videoInfo[@"title"] isKindOfClass:[NSString class]] ? videoInfo[@"title"] : self.host.browserCurrentPageTitle; + [self presentNativeVideoPlayerForURL:videoURL title:title]; +} + +- (BOOL)handleSelectPressForVideoAtCursor { + if (![self isFullscreenVideoPlaybackEnabled]) { + return NO; + } + + CGPoint point = self.host.browserDOMCursorPoint; + if ([self.domInteractionService isVideoDismissTargetAtDOMPoint:point webView:self.host.browserWebView]) { + return NO; + } + + NSDictionary *directVideoInfo = [self.domInteractionService directVideoInfoAtDOMPoint:point + webView:self.host.browserWebView]; + NSString *directVideoURLString = [self nativePlayableURLStringFromVideoInfo:directVideoInfo]; + if ([self isNativePlayableVideoURLString:directVideoURLString]) { + NSURL *videoURL = [NSURL URLWithString:directVideoURLString]; + NSString *title = [directVideoInfo[@"title"] isKindOfClass:[NSString class]] ? directVideoInfo[@"title"] : self.host.browserCurrentPageTitle; + [self presentNativeVideoPlayerForURL:videoURL title:title]; + return YES; + } + + if (![self.domInteractionService isVideoActivationTargetAtDOMPoint:point webView:self.host.browserWebView]) { + return NO; + } + + NSDictionary *videoInfo = [self.domInteractionService primedVideoInfoAtDOMPoint:point webView:self.host.browserWebView]; + if (videoInfo.count == 0) { + videoInfo = [self.domInteractionService videoInfoAtDOMPoint:point webView:self.host.browserWebView]; + } + + NSString *videoURLString = [self nativePlayableURLStringFromVideoInfo:videoInfo]; + if (![self isNativePlayableVideoURLString:videoURLString]) { + NSDictionary *activatedVideoInfo = [self.domInteractionService activateVideoTargetAtDOMPoint:point + webView:self.host.browserWebView + timeout:1.5]; + if (activatedVideoInfo.count > 0) { + videoInfo = activatedVideoInfo; + videoURLString = [self nativePlayableURLStringFromVideoInfo:videoInfo]; + } + } + + if ([self isNativePlayableVideoURLString:videoURLString]) { + NSURL *videoURL = [NSURL URLWithString:videoURLString]; + NSString *title = [videoInfo[@"title"] isKindOfClass:[NSString class]] ? videoInfo[@"title"] : self.host.browserCurrentPageTitle; + [self presentNativeVideoPlayerForURL:videoURL title:title]; + } else { + [self presentUnsupportedNativeVideoAlertForVideoInfo:videoInfo ?: @{}]; + } + return YES; +} + +- (NSString *)nativePlayableURLStringFromVideoInfo:(NSDictionary *)videoInfo { + NSString *primarySource = [videoInfo[@"src"] isKindOfClass:[NSString class]] ? videoInfo[@"src"] : @""; + if ([self isNativePlayableVideoURLString:primarySource]) { + return primarySource; + } + + NSArray *sources = [videoInfo[@"sources"] isKindOfClass:[NSArray class]] ? videoInfo[@"sources"] : @[]; + for (id sourceValue in sources) { + if (![sourceValue isKindOfClass:[NSString class]]) { + continue; + } + NSString *source = (NSString *)sourceValue; + if ([self isNativePlayableVideoURLString:source]) { + return source; + } + } + + return primarySource; +} + +- (BOOL)isNativePlayableVideoURLString:(NSString *)URLString { + if (URLString.length == 0) { + return NO; + } + + NSString *lowercaseURLString = URLString.lowercaseString; + if ([lowercaseURLString hasPrefix:@"blob:"] || + [lowercaseURLString hasPrefix:@"data:"] || + [lowercaseURLString hasPrefix:@"mediastream:"]) { + return NO; + } + + NSURL *URL = [NSURL URLWithString:URLString]; + if (URL == nil) { + return NO; + } + + NSString *scheme = URL.scheme.lowercaseString; + return [scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"]; +} + +- (void)presentNativeVideoPlayerForURL:(NSURL *)URL title:(NSString *)title { + [self presentNativeVideoPlayerForURL:URL title:title requestHeaders:nil cookies:nil]; +} + +- (void)presentNativeVideoPlayerForURL:(NSURL *)URL + title:(NSString *)title + requestHeaders:(NSDictionary *)requestHeaders + cookies:(NSArray *)cookies { + if (![self isFullscreenVideoPlaybackEnabled]) { + return; + } + + if (URL == nil) { + return; + } + + [self.host.browserWebView pauseAllMediaPlayback]; + + BrowserNativeVideoPlayerViewController *playerViewController = [[BrowserNativeVideoPlayerViewController alloc] initWithURL:URL + title:title + requestHeaders:requestHeaders + cookies:cookies]; + [self.host browserPresentViewController:playerViewController]; +} + +- (NSDictionary *)browserHeadersForYouTubePlaybackURL:(NSURL *)playbackURL + pageURL:(NSURL *)pageURL { + if (playbackURL == nil || pageURL == nil) { + return nil; + } + + NSMutableDictionary *headers = [NSMutableDictionary dictionary]; + NSString *userAgent = [[NSUserDefaults standardUserDefaults] stringForKey:kUserAgentDefaultsKey]; + if (userAgent.length > 0) { + headers[@"User-Agent"] = userAgent; + } + + headers[@"Referer"] = pageURL.absoluteString ?: @"https://www.youtube.com/"; + NSString *origin = [NSString stringWithFormat:@"%@://%@", pageURL.scheme ?: @"https", pageURL.host ?: @"www.youtube.com"]; + headers[@"Origin"] = origin; + + NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:pageURL]; + if (cookies.count > 0) { + NSDictionary *cookieHeaders = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies]; + NSString *cookieHeader = cookieHeaders[@"Cookie"]; + if (cookieHeader.length > 0) { + headers[@"Cookie"] = cookieHeader; + } + } + + return headers.count > 0 ? headers : nil; +} + +- (BOOL)cookie:(NSHTTPCookie *)cookie matchesHost:(NSString *)host { + if (cookie == nil || host.length == 0) { + return NO; + } + + NSString *cookieDomain = cookie.domain.lowercaseString ?: @""; + NSString *lowercaseHost = host.lowercaseString; + if (cookieDomain.length == 0) { + return NO; + } + + if ([cookieDomain hasPrefix:@"."]) { + cookieDomain = [cookieDomain substringFromIndex:1]; + } + + return [lowercaseHost isEqualToString:cookieDomain] || [lowercaseHost hasSuffix:[@"." stringByAppendingString:cookieDomain]]; +} + +- (NSArray *)browserCookiesForYouTubePlaybackURL:(NSURL *)playbackURL + pageURL:(NSURL *)pageURL { + NSMutableArray *matchingCookies = [NSMutableArray array]; + NSMutableSet *seenCookieKeys = [NSMutableSet set]; + NSArray *allCookies = [BrowserWebView allCookies]; + NSString *pageHost = pageURL.host.lowercaseString ?: @""; + NSString *playbackHost = playbackURL.host.lowercaseString ?: @""; + + for (NSHTTPCookie *cookie in allCookies) { + BOOL matches = [self cookie:cookie matchesHost:pageHost] || + [self cookie:cookie matchesHost:playbackHost] || + [self cookie:cookie matchesHost:@"youtube.com"] || + [self cookie:cookie matchesHost:@"googlevideo.com"]; + if (!matches) { + continue; + } + + NSString *cookieKey = [NSString stringWithFormat:@"%@|%@|%@", cookie.domain ?: @"", cookie.path ?: @"", cookie.name ?: @""]; + if ([seenCookieKeys containsObject:cookieKey]) { + continue; + } + [seenCookieKeys addObject:cookieKey]; + [matchingCookies addObject:cookie]; + } + + return matchingCookies; +} + +- (void)presentUnsupportedNativeVideoAlertForVideoInfo:(NSDictionary *)videoInfo { + NSArray *sources = [videoInfo[@"sources"] isKindOfClass:[NSArray class]] ? videoInfo[@"sources"] : @[]; + NSString *sourceSummary = nil; + if (sources.count > 0) { + sourceSummary = [sources componentsJoinedByString:@"\n"]; + } else if (videoInfo.count > 0) { + sourceSummary = @"No direct media URL was exposed by the page."; + } else { + sourceSummary = @"No video element was detected under the cursor."; + } + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Native Video Unavailable" + message:[NSString stringWithFormat:@"This page is not exposing a direct video URL that AVPlayer can open.\n\nDetected sources:\n%@", sourceSummary] + preferredStyle:UIAlertControllerStyleAlert]; + [alertController addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]]; + [self.host browserPresentViewController:alertController]; +} + +- (void)presentYouTubeExtractionError:(NSError *)error fallbackVideoInfo:(NSDictionary *)videoInfo { + NSString *message = error.localizedDescription ?: @"Could not extract a better YouTube playback URL."; + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"YouTube Extraction Failed" + message:message + preferredStyle:UIAlertControllerStyleAlert]; + __weak typeof(self) weakSelf = self; + NSString *fallbackURLString = [videoInfo[@"src"] isKindOfClass:[NSString class]] ? videoInfo[@"src"] : @""; + if ([self isNativePlayableVideoURLString:fallbackURLString]) { + [alertController addAction:[UIAlertAction actionWithTitle:@"Play Current URL" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + NSURL *fallbackURL = [NSURL URLWithString:fallbackURLString]; + NSString *title = [videoInfo[@"title"] isKindOfClass:[NSString class]] ? videoInfo[@"title"] : weakSelf.host.browserCurrentPageTitle; + [weakSelf presentNativeVideoPlayerForURL:fallbackURL title:title]; + }]]; + } + [alertController addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]]; + [self.host browserPresentViewController:alertController]; +} + +- (void)playYouTubeVideoAtPageURL:(NSURL *)pageURL fallbackVideoInfo:(NSDictionary *)videoInfo { + __weak typeof(self) weakSelf = self; + [[self youTubeExtractor] extractPlaybackInfoFromPageURL:pageURL webView:self.host.browserWebView completion:^(BrowserYouTubeExtractionResult *result, NSError *error) { + if (result.playbackURL != nil) { + NSString *title = result.title.length > 0 ? result.title : weakSelf.host.browserCurrentPageTitle; + NSMutableDictionary *headers = [NSMutableDictionary dictionaryWithDictionary:result.requestHeaders ?: @{}]; + NSDictionary *fallbackHeaders = [weakSelf browserHeadersForYouTubePlaybackURL:result.playbackURL pageURL:pageURL]; + [fallbackHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, __unused BOOL *stop) { + if (headers[key].length == 0 && value.length > 0) { + headers[key] = value; + } + }]; + + NSArray *cookies = [weakSelf browserCookiesForYouTubePlaybackURL:result.playbackURL pageURL:pageURL]; + if (cookies.count > 0) { + NSDictionary *cookieHeaders = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies]; + NSString *cookieHeader = cookieHeaders[@"Cookie"]; + if (cookieHeader.length > 0) { + headers[@"Cookie"] = cookieHeader; + } + } + + [weakSelf presentNativeVideoPlayerForURL:result.playbackURL + title:title + requestHeaders:headers.count > 0 ? headers : nil + cookies:cookies]; + return; + } + + [weakSelf presentYouTubeExtractionError:error fallbackVideoInfo:videoInfo ?: @{}]; + }]; +} + +@end diff --git a/_Project/Browser/BrowserViewModel.h b/_Project/Browser/BrowserViewModel.h new file mode 100644 index 0000000..13ff9c0 --- /dev/null +++ b/_Project/Browser/BrowserViewModel.h @@ -0,0 +1,21 @@ +#import + +@class BrowserTabViewModel; + +@interface BrowserViewModel : NSObject + +@property (nonatomic, strong, readonly) NSMutableArray *tabs; +@property (nonatomic) NSInteger activeTabIndex; +@property (nonatomic) BOOL topNavigationBarVisible; +@property (nonatomic) BOOL tabOverviewVisible; +@property (nonatomic) NSUInteger textFontSize; +@property (nonatomic) BOOL fullscreenVideoPlaybackEnabled; + +- (BrowserTabViewModel *)activeTab; +- (BrowserTabViewModel *)addTab; +- (BrowserTabViewModel *)ensureActiveTab; +- (BrowserTabViewModel *)removeTabAtIndex:(NSInteger)tabIndex; +- (void)restoreTabs:(NSArray *)tabs activeTabIndex:(NSInteger)activeTabIndex; +- (void)switchToTabAtIndex:(NSInteger)tabIndex; + +@end diff --git a/_Project/Browser/BrowserViewModel.m b/_Project/Browser/BrowserViewModel.m new file mode 100644 index 0000000..74758a5 --- /dev/null +++ b/_Project/Browser/BrowserViewModel.m @@ -0,0 +1,107 @@ +#import "BrowserViewModel.h" + +#import "BrowserTabViewModel.h" + +static NSUInteger const kDefaultTextFontSize = 100; +static NSUInteger const kMinimumTextFontSize = 50; +static NSUInteger const kMaximumTextFontSize = 200; +static NSUInteger const kMaximumTabCount = 5; +@implementation BrowserViewModel + +- (instancetype)init { + self = [super init]; + if (self) { + _tabs = [NSMutableArray array]; + _activeTabIndex = NSNotFound; + _topNavigationBarVisible = YES; + _textFontSize = kDefaultTextFontSize; + _fullscreenVideoPlaybackEnabled = NO; + } + return self; +} + +- (BrowserTabViewModel *)activeTab { + if (self.activeTabIndex == NSNotFound || self.activeTabIndex < 0 || self.activeTabIndex >= self.tabs.count) { + return nil; + } + return self.tabs[self.activeTabIndex]; +} + +- (BrowserTabViewModel *)addTab { + if (self.tabs.count >= kMaximumTabCount) { + return nil; + } + + BrowserTabViewModel *tab = [BrowserTabViewModel new]; + [self.tabs addObject:tab]; + self.activeTabIndex = self.tabs.count - 1; + return tab; +} + +- (BrowserTabViewModel *)ensureActiveTab { + BrowserTabViewModel *tab = [self activeTab]; + if (tab != nil) { + return tab; + } + return [self addTab]; +} + +- (BrowserTabViewModel *)removeTabAtIndex:(NSInteger)tabIndex { + if (tabIndex < 0 || tabIndex >= self.tabs.count) { + return nil; + } + + BrowserTabViewModel *removedTab = self.tabs[tabIndex]; + [self.tabs removeObjectAtIndex:tabIndex]; + + if (self.tabs.count == 0) { + self.activeTabIndex = NSNotFound; + } else if (tabIndex == self.activeTabIndex) { + self.activeTabIndex = MIN(tabIndex, self.tabs.count - 1); + } else if (tabIndex < self.activeTabIndex) { + self.activeTabIndex -= 1; + } + + return removedTab; +} + +- (void)restoreTabs:(NSArray *)tabs activeTabIndex:(NSInteger)activeTabIndex { + [self.tabs removeAllObjects]; + if (tabs.count > 0) { + [self.tabs addObjectsFromArray:tabs]; + } + + if (self.tabs.count == 0) { + self.activeTabIndex = NSNotFound; + return; + } + + if (activeTabIndex < 0 || activeTabIndex >= self.tabs.count) { + self.activeTabIndex = 0; + return; + } + + self.activeTabIndex = activeTabIndex; +} + +- (void)switchToTabAtIndex:(NSInteger)tabIndex { + if (tabIndex < 0 || tabIndex >= self.tabs.count) { + return; + } + self.activeTabIndex = tabIndex; +} + +- (void)setTopNavigationBarVisible:(BOOL)topNavigationBarVisible { + _topNavigationBarVisible = topNavigationBarVisible; +} + +- (void)setTextFontSize:(NSUInteger)textFontSize { + textFontSize = MIN(kMaximumTextFontSize, MAX(kMinimumTextFontSize, textFontSize)); + _textFontSize = textFontSize; +} + +- (void)setFullscreenVideoPlaybackEnabled:(BOOL)fullscreenVideoPlaybackEnabled { + _fullscreenVideoPlaybackEnabled = fullscreenVideoPlaybackEnabled; +} + +@end diff --git a/_Project/Browser/BrowserWKWebViewProofOfConceptViewController.m b/_Project/Browser/BrowserWKWebViewProofOfConceptViewController.m new file mode 100644 index 0000000..579a51d --- /dev/null +++ b/_Project/Browser/BrowserWKWebViewProofOfConceptViewController.m @@ -0,0 +1,199 @@ +#import +#import +#import + +static NSString * const kWKProofOfConceptURLString = @"https://youtube.com"; + +@interface BrowserWKWebViewProofOfConceptViewController : UIViewController + +@property (nonatomic, strong) UIView *webView; +@property (nonatomic, strong) UILabel *statusLabel; +@property (nonatomic, strong) UILabel *detailLabel; +@property (nonatomic, strong) UIActivityIndicatorView *activityIndicatorView; + +@end + +@implementation BrowserWKWebViewProofOfConceptViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.view.backgroundColor = UIColor.blackColor; + + self.statusLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + self.statusLabel.translatesAutoresizingMaskIntoConstraints = NO; + self.statusLabel.text = @"WKWebView Proof of Concept"; + self.statusLabel.textColor = UIColor.whiteColor; + self.statusLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle2]; + self.statusLabel.textAlignment = NSTextAlignmentCenter; + [self.view addSubview:self.statusLabel]; + + self.detailLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + self.detailLabel.translatesAutoresizingMaskIntoConstraints = NO; + self.detailLabel.text = @"Trying to resolve WKWebView at runtime."; + self.detailLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.8]; + self.detailLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + self.detailLabel.textAlignment = NSTextAlignmentCenter; + self.detailLabel.numberOfLines = 0; + [self.view addSubview:self.detailLabel]; + + self.activityIndicatorView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + self.activityIndicatorView.translatesAutoresizingMaskIntoConstraints = NO; + self.activityIndicatorView.hidesWhenStopped = YES; + [self.activityIndicatorView startAnimating]; + [self.view addSubview:self.activityIndicatorView]; + + UILabel *dismissHintLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + dismissHintLabel.translatesAutoresizingMaskIntoConstraints = NO; + dismissHintLabel.text = @"Press Menu or Play/Pause to dismiss"; + dismissHintLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.55]; + dismissHintLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]; + dismissHintLabel.textAlignment = NSTextAlignmentCenter; + [self.view addSubview:dismissHintLabel]; + + [NSLayoutConstraint activateConstraints:@[ + [self.statusLabel.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:36.0], + [self.statusLabel.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:60.0], + [self.statusLabel.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-60.0], + + [self.activityIndicatorView.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [self.activityIndicatorView.topAnchor constraintEqualToAnchor:self.statusLabel.bottomAnchor constant:28.0], + + [self.detailLabel.topAnchor constraintEqualToAnchor:self.activityIndicatorView.bottomAnchor constant:28.0], + [self.detailLabel.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:120.0], + [self.detailLabel.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-120.0], + + [dismissHintLabel.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-24.0], + [dismissHintLabel.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:60.0], + [dismissHintLabel.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-60.0], + ]]; + + [self attemptWKWebViewLoad]; +} + +- (void)attemptWKWebViewLoad { + [self loadWebKitFrameworkIfPossible]; + + Class configurationClass = NSClassFromString(@"WKWebViewConfiguration"); + Class webViewClass = NSClassFromString(@"WKWebView"); + if (configurationClass == Nil || webViewClass == Nil) { + [self.activityIndicatorView stopAnimating]; + self.detailLabel.text = @"Runtime could not resolve WKWebView or WKWebViewConfiguration.\n\nThis proof of concept depends on a private WebKit runtime being present on the device."; + return; + } + + id configuration = ((id (*)(id, SEL))objc_msgSend)((id)configurationClass, @selector(new)); + if (configuration == nil) { + [self.activityIndicatorView stopAnimating]; + self.detailLabel.text = @"WKWebViewConfiguration exists but could not be instantiated."; + return; + } + + SEL inlineMediaPlaybackSelector = NSSelectorFromString(@"setAllowsInlineMediaPlayback:"); + if ([configuration respondsToSelector:inlineMediaPlaybackSelector]) { + ((void (*)(id, SEL, BOOL))objc_msgSend)(configuration, inlineMediaPlaybackSelector, YES); + } + + id webViewObject = ((id (*)(id, SEL))objc_msgSend)((id)webViewClass, @selector(alloc)); + SEL initializer = NSSelectorFromString(@"initWithFrame:configuration:"); + webViewObject = ((id (*)(id, SEL, CGRect, id))objc_msgSend)(webViewObject, initializer, self.view.bounds, configuration); + if (webViewObject == nil) { + [self.activityIndicatorView stopAnimating]; + self.detailLabel.text = @"WKWebView class resolved, but initWithFrame:configuration: failed."; + return; + } + + self.webView = (UIView *)webViewObject; + self.webView.frame = self.view.bounds; + self.webView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.webView.backgroundColor = UIColor.blackColor; + + SEL setNavigationDelegateSelector = NSSelectorFromString(@"setNavigationDelegate:"); + if ([webViewObject respondsToSelector:setNavigationDelegateSelector]) { + ((void (*)(id, SEL, id))objc_msgSend)(webViewObject, setNavigationDelegateSelector, self); + } + + [self.view insertSubview:self.webView atIndex:0]; + self.detailLabel.text = [NSString stringWithFormat:@"Resolved WKWebView. Loading %@.", kWKProofOfConceptURLString]; + + NSURL *URL = [NSURL URLWithString:kWKProofOfConceptURLString]; + NSURLRequest *request = [NSURLRequest requestWithURL:URL]; + if (request == nil) { + [self.activityIndicatorView stopAnimating]; + self.detailLabel.text = @"The proof-of-concept URL is invalid."; + return; + } + + SEL loadRequestSelector = NSSelectorFromString(@"loadRequest:"); + if ([webViewObject respondsToSelector:loadRequestSelector]) { + ((id (*)(id, SEL, id))objc_msgSend)(webViewObject, loadRequestSelector, request); + } else { + [self.activityIndicatorView stopAnimating]; + self.detailLabel.text = @"WKWebView resolved, but loadRequest: is unavailable."; + } +} + +- (void)loadWebKitFrameworkIfPossible { + if (NSClassFromString(@"WKWebView") != Nil) { + return; + } + + NSArray *candidatePaths = @[ + @"/System/Library/Frameworks/WebKit.framework/WebKit", + @"/System/Library/PrivateFrameworks/WebKit.framework/WebKit", + @"/System/Library/StagedFrameworks/Safari/WebKit.framework/WebKit", + ]; + + for (NSString *candidatePath in candidatePaths) { + if (dlopen(candidatePath.UTF8String, RTLD_NOW | RTLD_GLOBAL) != NULL && NSClassFromString(@"WKWebView") != Nil) { + self.detailLabel.text = [NSString stringWithFormat:@"Loaded WebKit runtime from %@.", candidatePath]; + return; + } + } +} + +- (void)webView:(id)webView didFinishNavigation:(id)navigation { + [self.activityIndicatorView stopAnimating]; + NSString *URLString = [self currentURLStringForWebView:webView]; + self.detailLabel.text = URLString.length > 0 ? [NSString stringWithFormat:@"Finished loading %@.", URLString] : @"Finished loading the proof-of-concept page."; +} + +- (void)webView:(id)webView didFailNavigation:(id)navigation withError:(NSError *)error { + [self handleLoadFailureForWebView:webView error:error]; +} + +- (void)webView:(id)webView didFailProvisionalNavigation:(id)navigation withError:(NSError *)error { + [self handleLoadFailureForWebView:webView error:error]; +} + +- (void)handleLoadFailureForWebView:(id)webView error:(NSError *)error { + [self.activityIndicatorView stopAnimating]; + NSString *URLString = [self currentURLStringForWebView:webView]; + if (URLString.length > 0) { + self.detailLabel.text = [NSString stringWithFormat:@"Failed to load %@.\n\n%@", URLString, error.localizedDescription ?: @"Unknown error."]; + } else { + self.detailLabel.text = [NSString stringWithFormat:@"WKWebView load failed.\n\n%@", error.localizedDescription ?: @"Unknown error."]; + } +} + +- (NSString *)currentURLStringForWebView:(id)webView { + SEL URLSelector = NSSelectorFromString(@"URL"); + if (webView == nil || ![webView respondsToSelector:URLSelector]) { + return @""; + } + + NSURL *URL = ((id (*)(id, SEL))objc_msgSend)(webView, URLSelector); + return URL.absoluteString ?: @""; +} + +- (void)pressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event { + UIPress *press = presses.anyObject; + if (press.type == UIPressTypeMenu || press.type == UIPressTypePlayPause) { + [self dismissViewControllerAnimated:YES completion:nil]; + return; + } + + [super pressesEnded:presses withEvent:event]; +} + +@end diff --git a/_Project/Browser/BrowserWebView.h b/_Project/Browser/BrowserWebView.h new file mode 100644 index 0000000..e72dcf8 --- /dev/null +++ b/_Project/Browser/BrowserWebView.h @@ -0,0 +1,48 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol BrowserWebViewDelegate + +@optional +- (BOOL)webView:(id _Nonnull)webView shouldStartLoadWithRequest:(NSURLRequest * _Nullable)request navigationType:(NSInteger)navigationType; +- (BOOL)webView:(id _Nonnull)webView shouldCreateNewTabWithRequest:(NSURLRequest * _Nullable)request navigationType:(NSInteger)navigationType; +- (void)webViewDidStartLoad:(id _Nonnull)webView; +- (void)webViewDidFinishLoad:(id _Nonnull)webView; +- (void)webView:(id _Nonnull)webView didFailLoadWithError:(NSError * _Nonnull)error; + +@end + +@interface BrowserWebView : UIView + +@property (nullable, nonatomic, weak) id delegate; +@property (nullable, nonatomic, readonly, strong) NSURLRequest *request; +@property (nullable, nonatomic, readonly, strong) UIScrollView *scrollView; +@property (nullable, nonatomic, readonly, copy) NSString *title; +@property (nonatomic, readonly, getter=canGoBack) BOOL canGoBack; +@property (nonatomic, readonly, getter=canGoForward) BOOL canGoForward; +@property (nonatomic, readonly, getter=isLoading) BOOL loading; +@property (nonatomic) BOOL scalesPageToFit; + +- (instancetype)initWithUserAgent:(NSString * _Nullable)userAgent + allowsInlineMediaPlayback:(BOOL)allowsInlineMediaPlayback NS_DESIGNATED_INITIALIZER; + +- (void)loadRequest:(NSURLRequest * _Nullable)request; +- (void)reload; +- (void)goBack; +- (void)goForward; +- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString * _Nonnull)script; +- (NSString * _Nonnull)runtimeMediaPreferenceReport; +- (void)setUserAgent:(NSString * _Nullable)userAgent; +- (void)pauseAllMediaPlayback; + ++ (nullable NSData *)cookieDataRepresentation; ++ (NSArray * _Nonnull)allCookies; ++ (void)restoreCookiesFromData:(NSData * _Nullable)cookieData; ++ (void)clearCachedDataWithCompletion:(void (^ _Nullable)(void))completion; ++ (void)clearCookiesWithCompletion:(void (^ _Nullable)(void))completion; ++ (void)resetWebsiteDataWithCompletion:(void (^ _Nullable)(void))completion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserWebView.m b/_Project/Browser/BrowserWebView.m new file mode 100644 index 0000000..6ec0152 --- /dev/null +++ b/_Project/Browser/BrowserWebView.m @@ -0,0 +1,1168 @@ +#import "BrowserWebView.h" + +#import +#import +#import + +static NSString * const kBrowserWebViewClassName = @"WKWebView"; +static NSString * const kBrowserWebViewConfigurationClassName = @"WKWebViewConfiguration"; +static NSString * const kBrowserWebsiteDataStoreClassName = @"WKWebsiteDataStore"; +static NSString * const kBrowserUserContentControllerClassName = @"WKUserContentController"; +static NSString * const kBrowserUserScriptClassName = @"WKUserScript"; + +static void BrowserEnsureWebKitRuntimeLoaded(void) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + if (NSClassFromString(kBrowserWebViewClassName) != Nil) { + return; + } + + NSArray *candidatePaths = @[ + @"/System/Library/Frameworks/WebKit.framework/WebKit", + @"/System/Library/PrivateFrameworks/WebKit.framework/WebKit", + @"/System/Library/StagedFrameworks/Safari/WebKit.framework/WebKit", + ]; + + for (NSString *candidatePath in candidatePaths) { + if (dlopen(candidatePath.UTF8String, RTLD_NOW | RTLD_GLOBAL) != NULL && NSClassFromString(kBrowserWebViewClassName) != Nil) { + break; + } + } + }); +} + +static void BrowserPumpRunLoopUntil(BOOL *done) { + while (!*done) { + @autoreleasepool { + [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.01]]; + } + } +} + +static NSString *BrowserStringFromJavaScriptResult(id result) { + if (result == nil || result == [NSNull null]) { + return nil; + } + if ([result isKindOfClass:[NSString class]]) { + return result; + } + if ([result respondsToSelector:@selector(stringValue)]) { + return [result stringValue]; + } + return [result description]; +} + +static BOOL BrowserSelectorNameMatchesMediaFilter(NSString *selectorName) { + static NSArray *keywords = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + keywords = @[ + @"mediasource", + @"managedmediasource", + @"sourcebuffer", + @"media", + @"video", + @"inline", + @"autoplay", + @"fullscreen", + @"pictureinpicture", + @"airplay", + @"webm", + @"vp9", + @"av1", + @"hls", + @"mse", + @"codec", + ]; + }); + + NSString *lowercaseSelectorName = selectorName.lowercaseString; + for (NSString *keyword in keywords) { + if ([lowercaseSelectorName containsString:keyword]) { + return YES; + } + } + return NO; +} + +static NSArray *BrowserFilteredSelectorNamesForClass(Class klass) { + if (klass == Nil) { + return @[]; + } + + NSMutableOrderedSet *selectorNames = [NSMutableOrderedSet orderedSet]; + for (Class currentClass = klass; currentClass != Nil && currentClass != [NSObject class]; currentClass = class_getSuperclass(currentClass)) { + unsigned int methodCount = 0; + Method *methods = class_copyMethodList(currentClass, &methodCount); + for (unsigned int methodIndex = 0; methodIndex < methodCount; methodIndex += 1) { + SEL selector = method_getName(methods[methodIndex]); + NSString *selectorName = NSStringFromSelector(selector); + if (selectorName.length > 0 && BrowserSelectorNameMatchesMediaFilter(selectorName)) { + [selectorNames addObject:selectorName]; + } + } + free(methods); + } + + return [selectorNames.array sortedArrayUsingSelector:@selector(localizedCaseInsensitiveCompare:)]; +} + +static NSString *BrowserGetterNameFromSetterName(NSString *setterName) { + if (![setterName hasPrefix:@"set"] || ![setterName hasSuffix:@":"] || setterName.length <= 4) { + return nil; + } + + NSString *propertyStem = [setterName substringWithRange:NSMakeRange(3, setterName.length - 4)]; + if (propertyStem.length == 0) { + return nil; + } + + NSString *firstCharacter = [[propertyStem substringToIndex:1] lowercaseString]; + if (propertyStem.length == 1) { + return firstCharacter; + } + + return [firstCharacter stringByAppendingString:[propertyStem substringFromIndex:1]]; +} + +static NSString *BrowserBooleanValueDescriptionForObjectAndSelector(id object, NSString *selectorName) { + if (object == nil || selectorName.length == 0) { + return nil; + } + + SEL selector = NSSelectorFromString(selectorName); + if (![object respondsToSelector:selector]) { + return nil; + } + + NSMethodSignature *signature = [object methodSignatureForSelector:selector]; + if (signature == nil || signature.numberOfArguments != 2) { + return nil; + } + + const char *returnType = signature.methodReturnType; + if (returnType == NULL) { + return nil; + } + + if (returnType[0] != 'B' && returnType[0] != 'c') { + return nil; + } + + BOOL value = ((BOOL (*)(id, SEL))objc_msgSend)(object, selector); + return value ? @"YES" : @"NO"; +} + +static id BrowserObjectResultForGetter(id object, NSString *selectorName) { + if (object == nil || selectorName.length == 0) { + return nil; + } + + SEL selector = NSSelectorFromString(selectorName); + if (![object respondsToSelector:selector]) { + return nil; + } + + NSMethodSignature *signature = [object methodSignatureForSelector:selector]; + if (signature == nil || signature.numberOfArguments != 2) { + return nil; + } + + const char *returnType = signature.methodReturnType; + if (returnType == NULL || returnType[0] != '@') { + return nil; + } + + return ((id (*)(id, SEL))objc_msgSend)(object, selector); +} + +static NSString *BrowserPreviewString(NSString *string, NSUInteger maxLength) { + if (string.length <= maxLength) { + return string; + } + return [[string substringToIndex:maxLength] stringByAppendingString:@"\n…"]; +} + +static NSString *BrowserStringValueForKnownSelectors(id object, NSArray *selectorNames) { + for (NSString *selectorName in selectorNames) { + id result = BrowserObjectResultForGetter(object, selectorName); + if (result == nil || result == [NSNull null]) { + continue; + } + NSString *stringResult = nil; + if ([result isKindOfClass:[NSString class]]) { + stringResult = result; + } else if ([result respondsToSelector:@selector(stringValue)]) { + stringResult = [result stringValue]; + } else { + stringResult = [result description]; + } + + if (stringResult.length > 0) { + return stringResult; + } + } + return nil; +} + +static NSArray *> *BrowserFeatureEntriesForPreferences(id preferences) { + if (preferences == nil) { + return @[]; + } + + NSArray *collectionSelectors = @[ + @"_experimentalFeatures", + @"_internalDebugFeatures", + @"_features", + ]; + + NSMutableArray *> *entries = [NSMutableArray array]; + for (NSString *collectionSelectorName in collectionSelectors) { + id collection = BrowserObjectResultForGetter(preferences, collectionSelectorName); + if (![collection conformsToProtocol:@protocol(NSFastEnumeration)]) { + continue; + } + + for (id feature in collection) { + NSString *name = BrowserStringValueForKnownSelectors(feature, @[@"name", @"key", @"identifier", @"title", @"details"]); + if (name.length == 0) { + continue; + } + + NSString *lowercaseName = name.lowercaseString; + if (![lowercaseName containsString:@"media"] && + ![lowercaseName containsString:@"source"] && + ![lowercaseName containsString:@"vp9"] && + ![lowercaseName containsString:@"av1"] && + ![lowercaseName containsString:@"webm"] && + ![lowercaseName containsString:@"video"] && + ![lowercaseName containsString:@"mse"] && + ![lowercaseName containsString:@"managed"]) { + continue; + } + + NSString *enabledValue = BrowserBooleanValueDescriptionForObjectAndSelector(feature, @"enabled"); + if (enabledValue == nil) { + enabledValue = BrowserBooleanValueDescriptionForObjectAndSelector(feature, @"isEnabled"); + } + if (enabledValue == nil) { + enabledValue = @"unknown"; + } + + NSString *key = BrowserStringValueForKnownSelectors(feature, @[@"key", @"identifier"]); + NSString *source = [collectionSelectorName stringByReplacingOccurrencesOfString:@"_" withString:@""]; + [entries addObject:@{ + @"source": source ?: @"features", + @"name": name, + @"enabled": enabledValue, + @"key": key ?: @"", + }]; + } + } + + return entries; +} + +static void BrowserSetBooleanSelectorIfAvailable(id object, NSString *selectorName, BOOL value) { + if (object == nil || selectorName.length == 0) { + return; + } + + SEL selector = NSSelectorFromString(selectorName); + if ([object respondsToSelector:selector]) { + ((void (*)(id, SEL, BOOL))objc_msgSend)(object, selector, value); + } +} + +static void BrowserConfigurePrivateMediaPreferences(id configuration) { + if (configuration == nil) { + return; + } + + SEL preferencesSelector = NSSelectorFromString(@"preferences"); + if (![configuration respondsToSelector:preferencesSelector]) { + return; + } + + id preferences = ((id (*)(id, SEL))objc_msgSend)(configuration, preferencesSelector); + if (preferences == nil) { + return; + } + + BrowserSetBooleanSelectorIfAvailable(preferences, @"_setMediaSourceEnabled:", YES); + BrowserSetBooleanSelectorIfAvailable(preferences, @"_setManagedMediaSourceEnabled:", YES); + BrowserSetBooleanSelectorIfAvailable(preferences, @"_setMediaCapabilityGrantsEnabled:", YES); + BrowserSetBooleanSelectorIfAvailable(preferences, @"_setVideoQualityIncludesDisplayCompositingEnabled:", YES); +} + +static NSString *BrowserYouTubeRequestCaptureScript(void) { + return + @"(function(){" + "if (window.__browserYouTubeHookInstalled) { return; }" + "window.__browserYouTubeHookInstalled = true;" + "window.__browserYouTubeIntegrity = window.__browserYouTubeIntegrity || {};" + "function assignIfPresent(key, value) {" + "if (value === undefined || value === null) { return; }" + "var stringValue = String(value || '');" + "if (!stringValue) { return; }" + "window.__browserYouTubeIntegrity[key] = stringValue;" + "}" + "function capturePayload(payload) {" + "try {" + "if (!payload || typeof payload !== 'object') { return; }" + "if (payload.serviceIntegrityDimensions) {" + "assignIfPresent('poToken', payload.serviceIntegrityDimensions.poToken || payload.serviceIntegrityDimensions.po_token);" + "}" + "if (payload.context && payload.context.serviceIntegrityDimensions) {" + "assignIfPresent('poToken', payload.context.serviceIntegrityDimensions.poToken || payload.context.serviceIntegrityDimensions.po_token);" + "}" + "if (payload.context && payload.context.client) {" + "assignIfPresent('requestClientName', payload.context.client.clientName);" + "assignIfPresent('requestClientVersion', payload.context.client.clientVersion);" + "}" + "} catch (error) {}" + "}" + "function toHeaderObject(headers) {" + "var result = {};" + "try {" + "if (!headers) { return result; }" + "if (typeof Headers !== 'undefined' && headers instanceof Headers) {" + "headers.forEach(function(value, key) { result[String(key)] = String(value); });" + "return result;" + "}" + "if (Array.isArray(headers)) {" + "headers.forEach(function(entry) {" + "if (Array.isArray(entry) && entry.length >= 2) { result[String(entry[0])] = String(entry[1]); }" + "});" + "return result;" + "}" + "if (typeof headers === 'object') {" + "Object.keys(headers).forEach(function(key) { result[String(key)] = String(headers[key]); });" + "}" + "} catch (error) {}" + "return result;" + "}" + "function rememberRequest(url, body, headers, transport) {" + "try {" + "var integrity = window.__browserYouTubeIntegrity;" + "integrity.lastPlayerRequestURL = String(url || '');" + "integrity.lastPlayerRequestBody = String(body || '');" + "integrity.lastPlayerRequestHeaders = JSON.stringify(headers || {});" + "integrity.lastPlayerRequestTransport = String(transport || '');" + "if (!integrity.firstPlayerRequestURL) {" + "integrity.firstPlayerRequestURL = integrity.lastPlayerRequestURL;" + "integrity.firstPlayerRequestBody = integrity.lastPlayerRequestBody;" + "integrity.firstPlayerRequestHeaders = integrity.lastPlayerRequestHeaders;" + "integrity.firstPlayerRequestTransport = integrity.lastPlayerRequestTransport;" + "}" + "} catch (error) {}" + "}" + "function captureBodyStringAsync(source, bodyString, headers, transport) {" + "try {" + "if (bodyString && bodyString !== '[object ReadableStream]') {" + "rememberRequest(source.url || '', bodyString, headers || {}, transport || '');" + "try { capturePayload(JSON.parse(bodyString)); } catch (error) {}" + "return;" + "}" + "if (source && typeof source.clone === 'function' && typeof source.text === 'function') {" + "source.clone().text().then(function(text) {" + "rememberRequest(source.url || '', text || '', headers || {}, transport || '');" + "try { capturePayload(JSON.parse(text || '')); } catch (error) {}" + "}).catch(function(){});" + "}" + "} catch (error) {}" + "}" + "function captureRequest(input, init) {" + "try {" + "var url = '';" + "if (typeof input === 'string') { url = input; }" + "else if (input && typeof input.url === 'string') { url = input.url; }" + "if (url.indexOf('/youtubei/v1/player') === -1) { return; }" + "var body = (init && init.body) || (input && input.body) || null;" + "var bodyString = '';" + "if (typeof body === 'string') { bodyString = body; }" + "else if (body && typeof body === 'object' && typeof body.toString === 'function') { bodyString = String(body); }" + "var headers = toHeaderObject((init && init.headers) || (input && input.headers) || null);" + "rememberRequest(url, bodyString, headers, 'fetch');" + "captureBodyStringAsync((input && typeof input.clone === 'function') ? input : null, bodyString, headers, 'fetch');" + "if (typeof bodyString !== 'string' || !bodyString || bodyString === '[object ReadableStream]') { return; }" + "try { capturePayload(JSON.parse(bodyString)); } catch (error) {}" + "} catch (error) {}" + "}" + "function captureXHRRequest(xhr, body) {" + "try {" + "var url = String((xhr && xhr.__browserYouTubeURL) || '');" + "if (url.indexOf('/youtubei/v1/player') === -1) { return; }" + "var bodyString = '';" + "if (typeof body === 'string') { bodyString = body; }" + "else if (body && typeof body === 'object' && typeof body.toString === 'function') { bodyString = String(body); }" + "var headers = xhr && xhr.__browserYouTubeHeaders ? xhr.__browserYouTubeHeaders : {};" + "rememberRequest(url, bodyString, headers, 'xhr');" + "if (typeof bodyString !== 'string' || !bodyString) { return; }" + "try { capturePayload(JSON.parse(bodyString)); } catch (error) {}" + "try { capturePayload(JSON.parse(body)); } catch (error) {}" + "} catch (error) {}" + "}" + "var cfg = (window.ytcfg && window.ytcfg.data_) || {};" + "assignIfPresent('poToken', cfg.PO_TOKEN || cfg.po_token || cfg.POTOKEN);" + "if (cfg.SERVICE_INTEGRITY_DIMENSIONS) {" + "assignIfPresent('poToken', cfg.SERVICE_INTEGRITY_DIMENSIONS.poToken || cfg.SERVICE_INTEGRITY_DIMENSIONS.po_token);" + "}" + "if (cfg.WEB_PLAYER_CONTEXT_CONFIGS) {" + "var watchConfig = cfg.WEB_PLAYER_CONTEXT_CONFIGS.WEB_PLAYER_CONTEXT_CONFIG_ID_KEVLAR_WATCH || {};" + "if (watchConfig.serviceIntegrityDimensions) {" + "assignIfPresent('poToken', watchConfig.serviceIntegrityDimensions.poToken || watchConfig.serviceIntegrityDimensions.po_token);" + "}" + "}" + "if (window.fetch) {" + "var originalFetch = window.fetch;" + "window.fetch = function(input, init) {" + "captureRequest(input, init);" + "return originalFetch.apply(this, arguments);" + "};" + "}" + "if (window.XMLHttpRequest && window.XMLHttpRequest.prototype) {" + "var originalOpen = window.XMLHttpRequest.prototype.open;" + "var originalSend = window.XMLHttpRequest.prototype.send;" + "var originalSetRequestHeader = window.XMLHttpRequest.prototype.setRequestHeader;" + "window.XMLHttpRequest.prototype.open = function(method, url) {" + "this.__browserYouTubeURL = String(url || '');" + "this.__browserYouTubeHeaders = {};" + "return originalOpen.apply(this, arguments);" + "};" + "window.XMLHttpRequest.prototype.setRequestHeader = function(key, value) {" + "try {" + "if (!this.__browserYouTubeHeaders) { this.__browserYouTubeHeaders = {}; }" + "this.__browserYouTubeHeaders[String(key)] = String(value);" + "} catch (error) {}" + "return originalSetRequestHeader.apply(this, arguments);" + "};" + "window.XMLHttpRequest.prototype.send = function(body) {" + "captureXHRRequest(this, body);" + "return originalSend.apply(this, arguments);" + "};" + "}" + "})();"; +} + +static void BrowserInstallYouTubeCaptureUserScript(id configuration) { + if (configuration == nil) { + return; + } + + Class userContentControllerClass = NSClassFromString(kBrowserUserContentControllerClassName); + Class userScriptClass = NSClassFromString(kBrowserUserScriptClassName); + if (userContentControllerClass == Nil || userScriptClass == Nil) { + return; + } + + SEL userContentControllerGetter = NSSelectorFromString(@"userContentController"); + SEL setUserContentControllerSelector = NSSelectorFromString(@"setUserContentController:"); + id userContentController = nil; + if ([configuration respondsToSelector:userContentControllerGetter]) { + userContentController = ((id (*)(id, SEL))objc_msgSend)(configuration, userContentControllerGetter); + } + + if (userContentController == nil && [configuration respondsToSelector:setUserContentControllerSelector]) { + userContentController = ((id (*)(id, SEL))objc_msgSend)((id)userContentControllerClass, @selector(new)); + ((void (*)(id, SEL, id))objc_msgSend)(configuration, setUserContentControllerSelector, userContentController); + } + + SEL addUserScriptSelector = NSSelectorFromString(@"addUserScript:"); + SEL userScriptInitializer = NSSelectorFromString(@"initWithSource:injectionTime:forMainFrameOnly:"); + if (userContentController == nil || + ![userContentController respondsToSelector:addUserScriptSelector] || + ![userScriptClass instancesRespondToSelector:userScriptInitializer]) { + return; + } + + id userScript = ((id (*)(id, SEL))objc_msgSend)((id)userScriptClass, @selector(alloc)); + userScript = ((id (*)(id, SEL, id, NSInteger, BOOL))objc_msgSend)(userScript, userScriptInitializer, BrowserYouTubeRequestCaptureScript(), 0, NO); + if (userScript != nil) { + ((void (*)(id, SEL, id))objc_msgSend)(userContentController, addUserScriptSelector, userScript); + } +} + +@interface BrowserWebView () + +@property (nullable, nonatomic, strong) id runtimeWebView; +@property (nullable, nonatomic, strong) NSURLRequest *lastRequest; +@property (nullable, nonatomic, copy) NSString *lastTitle; +@property (nonatomic, copy) NSString *userAgent; +@property (nonatomic) BOOL loading; + +@end + +@implementation BrowserWebView + +- (instancetype)initWithFrame:(CGRect)frame { + return [self initWithUserAgent:nil allowsInlineMediaPlayback:YES]; +} + +- (instancetype)initWithCoder:(NSCoder *)coder { + self = [super initWithCoder:coder]; + if (self) { + [self commonInitWithUserAgent:nil allowsInlineMediaPlayback:YES]; + } + return self; +} + +- (instancetype)initWithUserAgent:(NSString *)userAgent allowsInlineMediaPlayback:(BOOL)allowsInlineMediaPlayback { + self = [super initWithFrame:CGRectZero]; + if (self) { + [self commonInitWithUserAgent:userAgent allowsInlineMediaPlayback:allowsInlineMediaPlayback]; + } + return self; +} + +- (void)commonInitWithUserAgent:(NSString *)userAgent allowsInlineMediaPlayback:(BOOL)allowsInlineMediaPlayback { + BrowserEnsureWebKitRuntimeLoaded(); + + self.backgroundColor = UIColor.blackColor; + self.userAgent = userAgent; + self.scalesPageToFit = NO; + + Class configurationClass = NSClassFromString(kBrowserWebViewConfigurationClassName); + Class webViewClass = NSClassFromString(kBrowserWebViewClassName); + if (configurationClass == Nil || webViewClass == Nil) { + return; + } + + id configuration = ((id (*)(id, SEL))objc_msgSend)((id)configurationClass, @selector(new)); + SEL allowsInlineMediaPlaybackSelector = NSSelectorFromString(@"setAllowsInlineMediaPlayback:"); + if (configuration != nil && [configuration respondsToSelector:allowsInlineMediaPlaybackSelector]) { + ((void (*)(id, SEL, BOOL))objc_msgSend)(configuration, allowsInlineMediaPlaybackSelector, allowsInlineMediaPlayback); + } + BrowserConfigurePrivateMediaPreferences(configuration); + BrowserInstallYouTubeCaptureUserScript(configuration); + + id webViewObject = ((id (*)(id, SEL))objc_msgSend)((id)webViewClass, @selector(alloc)); + SEL initializer = NSSelectorFromString(@"initWithFrame:configuration:"); + webViewObject = ((id (*)(id, SEL, CGRect, id))objc_msgSend)(webViewObject, initializer, self.bounds, configuration); + if (webViewObject == nil) { + return; + } + + self.runtimeWebView = webViewObject; + UIView *runtimeView = (UIView *)webViewObject; + runtimeView.frame = self.bounds; + runtimeView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + runtimeView.backgroundColor = UIColor.blackColor; + + SEL navigationDelegateSelector = NSSelectorFromString(@"setNavigationDelegate:"); + if ([webViewObject respondsToSelector:navigationDelegateSelector]) { + ((void (*)(id, SEL, id))objc_msgSend)(webViewObject, navigationDelegateSelector, self); + } + + SEL UIDelegateSelector = NSSelectorFromString(@"setUIDelegate:"); + if ([webViewObject respondsToSelector:UIDelegateSelector]) { + ((void (*)(id, SEL, id))objc_msgSend)(webViewObject, UIDelegateSelector, self); + } + + [self addSubview:runtimeView]; + [self setUserAgent:userAgent]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + ((UIView *)self.runtimeWebView).frame = self.bounds; + [self applyPageScalingIfNeeded]; +} + +- (void)setUserInteractionEnabled:(BOOL)userInteractionEnabled { + [super setUserInteractionEnabled:userInteractionEnabled]; + + UIView *runtimeView = (UIView *)self.runtimeWebView; + runtimeView.userInteractionEnabled = userInteractionEnabled; + + UIScrollView *scrollView = [self scrollView]; + scrollView.userInteractionEnabled = userInteractionEnabled; +} + +- (UIScrollView *)scrollView { + SEL selector = NSSelectorFromString(@"scrollView"); + if (self.runtimeWebView == nil || ![self.runtimeWebView respondsToSelector:selector]) { + return nil; + } + return ((id (*)(id, SEL))objc_msgSend)(self.runtimeWebView, selector); +} + +- (NSURL *)currentURL { + SEL selector = NSSelectorFromString(@"URL"); + if (self.runtimeWebView == nil || ![self.runtimeWebView respondsToSelector:selector]) { + return nil; + } + return ((id (*)(id, SEL))objc_msgSend)(self.runtimeWebView, selector); +} + +- (NSURLRequest *)request { + NSURL *currentURL = [self currentURL]; + if (currentURL != nil) { + return [NSURLRequest requestWithURL:currentURL]; + } + return self.lastRequest; +} + +- (NSString *)title { + SEL selector = NSSelectorFromString(@"title"); + if (self.runtimeWebView == nil || ![self.runtimeWebView respondsToSelector:selector]) { + return self.lastTitle; + } + NSString *title = ((id (*)(id, SEL))objc_msgSend)(self.runtimeWebView, selector); + return title ?: self.lastTitle; +} + +- (BOOL)canGoBack { + SEL selector = NSSelectorFromString(@"canGoBack"); + return self.runtimeWebView != nil && [self.runtimeWebView respondsToSelector:selector] ? ((BOOL (*)(id, SEL))objc_msgSend)(self.runtimeWebView, selector) : NO; +} + +- (BOOL)canGoForward { + SEL selector = NSSelectorFromString(@"canGoForward"); + return self.runtimeWebView != nil && [self.runtimeWebView respondsToSelector:selector] ? ((BOOL (*)(id, SEL))objc_msgSend)(self.runtimeWebView, selector) : NO; +} + +- (void)loadRequest:(NSURLRequest *)request { + if (request == nil || self.runtimeWebView == nil) { + return; + } + self.lastRequest = request; + SEL selector = NSSelectorFromString(@"loadRequest:"); + if ([self.runtimeWebView respondsToSelector:selector]) { + ((id (*)(id, SEL, id))objc_msgSend)(self.runtimeWebView, selector, request); + } +} + +- (void)reload { + SEL selector = NSSelectorFromString(@"reload"); + if (self.runtimeWebView != nil && [self.runtimeWebView respondsToSelector:selector]) { + ((void (*)(id, SEL))objc_msgSend)(self.runtimeWebView, selector); + } +} + +- (void)goBack { + SEL selector = NSSelectorFromString(@"goBack"); + if (self.runtimeWebView != nil && [self.runtimeWebView respondsToSelector:selector]) { + ((id (*)(id, SEL))objc_msgSend)(self.runtimeWebView, selector); + } +} + +- (void)goForward { + SEL selector = NSSelectorFromString(@"goForward"); + if (self.runtimeWebView != nil && [self.runtimeWebView respondsToSelector:selector]) { + ((id (*)(id, SEL))objc_msgSend)(self.runtimeWebView, selector); + } +} + +- (NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script { + if (script.length == 0 || self.runtimeWebView == nil) { + return nil; + } + + SEL selector = NSSelectorFromString(@"evaluateJavaScript:completionHandler:"); + if (![self.runtimeWebView respondsToSelector:selector]) { + return nil; + } + + __block id evaluationResult = nil; + __block NSError *evaluationError = nil; + __block BOOL finished = NO; + ((void (*)(id, SEL, id, id))objc_msgSend)(self.runtimeWebView, selector, script, ^(id result, NSError *error) { + evaluationResult = result; + evaluationError = error; + finished = YES; + }); + BrowserPumpRunLoopUntil(&finished); + + if (evaluationError != nil) { + return nil; + } + return BrowserStringFromJavaScriptResult(evaluationResult); +} + +- (void)pauseAllMediaPlayback { + if (self.runtimeWebView == nil) { + return; + } + + // Prefer WebKit's internal media pause APIs when available. + SEL pauseWithCompletionHandlerSelector = NSSelectorFromString(@"pauseAllMediaPlaybackWithCompletionHandler:"); + if ([self.runtimeWebView respondsToSelector:pauseWithCompletionHandlerSelector]) { + ((void (*)(id, SEL, id))objc_msgSend)(self.runtimeWebView, pauseWithCompletionHandlerSelector, nil); + } else { + SEL pauseSelector = NSSelectorFromString(@"pauseAllMediaPlayback:"); + if ([self.runtimeWebView respondsToSelector:pauseSelector]) { + ((void (*)(id, SEL, id))objc_msgSend)(self.runtimeWebView, pauseSelector, nil); + } else { + SEL privatePauseSelector = NSSelectorFromString(@"_pauseAllMediaPlayback"); + if ([self.runtimeWebView respondsToSelector:privatePauseSelector]) { + ((void (*)(id, SEL))objc_msgSend)(self.runtimeWebView, privatePauseSelector); + } + } + } + + // JS fallback for page media elements and common iframe-based players. + NSString *pauseScript = + @"(function(){" + "function safe(fn){ try { fn(); } catch (error) {} }" + "var media = document.querySelectorAll('video,audio');" + "for (var i = 0; i < media.length; i++) {" + "var element = media[i];" + "safe(function(){ element.pause(); });" + "safe(function(){ element.autoplay = false; });" + "safe(function(){ element.removeAttribute('autoplay'); });" + "}" + "var iframePlayers = document.querySelectorAll('iframe');" + "for (var j = 0; j < iframePlayers.length; j++) {" + "var frame = iframePlayers[j];" + "var src = String(frame.src || '').toLowerCase();" + "if (!src) { continue; }" + "safe(function(){" + "if (src.indexOf('youtube.com') !== -1 || src.indexOf('youtube-nocookie.com') !== -1) {" + "frame.contentWindow.postMessage(JSON.stringify({event:'command',func:'pauseVideo',args:''}), '*');" + "}" + "});" + "safe(function(){" + "if (src.indexOf('vimeo.com') !== -1) {" + "frame.contentWindow.postMessage(JSON.stringify({method:'pause'}), '*');" + "}" + "});" + "}" + "})();"; + [self stringByEvaluatingJavaScriptFromString:pauseScript]; +} + +- (NSString *)runtimeMediaPreferenceReport { + if (self.runtimeWebView == nil) { + return @"Runtime web view unavailable."; + } + + NSArray *> *objectSelectors = @[ + @{@"label": @"WKWebView", @"selector": @""}, + @{@"label": @"Configuration", @"selector": @"configuration"}, + @{@"label": @"Configuration._preferences", @"selector": @"configuration._preferences"}, + @{@"label": @"Configuration.preferences", @"selector": @"configuration.preferences"}, + @{@"label": @"Configuration.defaultWebpagePreferences", @"selector": @"configuration.defaultWebpagePreferences"}, + @{@"label": @"Configuration.websiteDataStore", @"selector": @"configuration.websiteDataStore"}, + @{@"label": @"WKWebView._configuration", @"selector": @"_configuration"}, + @{@"label": @"WKWebView._page", @"selector": @"_page"}, + ]; + + NSMutableDictionary *seenObjects = [NSMutableDictionary dictionary]; + NSMutableString *report = [NSMutableString string]; + + for (NSDictionary *entry in objectSelectors) { + NSString *label = entry[@"label"] ?: @"Object"; + NSString *selectorPath = entry[@"selector"] ?: @""; + id currentObject = self.runtimeWebView; + + if (selectorPath.length > 0) { + NSArray *components = [selectorPath componentsSeparatedByString:@"."]; + for (NSString *component in components) { + currentObject = BrowserObjectResultForGetter(currentObject, component); + if (currentObject == nil) { + break; + } + } + } + + if (currentObject == nil) { + [report appendFormat:@"[%@] unavailable\n\n", label]; + continue; + } + + NSValue *objectKey = [NSValue valueWithNonretainedObject:currentObject]; + NSString *previousLabel = seenObjects[objectKey]; + if (previousLabel != nil) { + [report appendFormat:@"[%@] same object as %@ (%@)\n\n", label, previousLabel, NSStringFromClass([currentObject class])]; + continue; + } + seenObjects[objectKey] = label; + + NSArray *selectorNames = BrowserFilteredSelectorNamesForClass([currentObject class]); + NSMutableArray *booleanLines = [NSMutableArray array]; + for (NSString *selectorName in selectorNames) { + NSString *getterName = nil; + if ([selectorName hasPrefix:@"set"] && [selectorName hasSuffix:@":"]) { + getterName = BrowserGetterNameFromSetterName(selectorName); + } else { + getterName = selectorName; + } + + NSString *valueDescription = BrowserBooleanValueDescriptionForObjectAndSelector(currentObject, getterName); + if (valueDescription != nil) { + [booleanLines addObject:[NSString stringWithFormat:@"%@ = %@", getterName, valueDescription]]; + } + } + + [report appendFormat:@"[%@] %@\n", label, NSStringFromClass([currentObject class])]; + if (booleanLines.count > 0) { + [report appendString:@"Boolean getters:\n"]; + for (NSString *line in booleanLines) { + [report appendFormat:@"- %@\n", line]; + } + } else { + [report appendString:@"Boolean getters:\n- none resolved\n"]; + } + + [report appendString:@"Matching selectors:\n"]; + if (selectorNames.count == 0) { + [report appendString:@"- none\n\n"]; + continue; + } + + for (NSString *selectorName in selectorNames) { + [report appendFormat:@"- %@\n", selectorName]; + } + + if ([label isEqualToString:@"Configuration.preferences"]) { + NSArray *> *featureEntries = BrowserFeatureEntriesForPreferences(currentObject); + [report appendString:@"Feature entries:\n"]; + if (featureEntries.count == 0) { + [report appendString:@"- none\n"]; + } else { + for (NSDictionary *featureEntry in featureEntries) { + NSString *featureSource = featureEntry[@"source"] ?: @"features"; + NSString *featureName = featureEntry[@"name"] ?: @"Unknown"; + NSString *featureEnabled = featureEntry[@"enabled"] ?: @"unknown"; + NSString *featureKey = featureEntry[@"key"]; + if (featureKey.length > 0) { + [report appendFormat:@"- [%@] %@ (%@) = %@\n", featureSource, featureName, featureKey, featureEnabled]; + } else { + [report appendFormat:@"- [%@] %@ = %@\n", featureSource, featureName, featureEnabled]; + } + } + } + } + [report appendString:@"\n"]; + } + + return BrowserPreviewString(report, 24000); +} + +- (void)installYouTubeRequestCaptureHook { + [self stringByEvaluatingJavaScriptFromString:BrowserYouTubeRequestCaptureScript()]; +} + +- (void)setUserAgent:(NSString *)userAgent { + _userAgent = [userAgent copy]; + SEL selector = NSSelectorFromString(@"setCustomUserAgent:"); + if (self.runtimeWebView != nil && [self.runtimeWebView respondsToSelector:selector]) { + ((void (*)(id, SEL, id))objc_msgSend)(self.runtimeWebView, selector, _userAgent); + } +} + +- (void)setScalesPageToFit:(BOOL)scalesPageToFit { + _scalesPageToFit = scalesPageToFit; + [self applyPageScalingIfNeeded]; +} + +- (void)applyPageScalingIfNeeded { + if (self.runtimeWebView == nil) { + return; + } + + UIScrollView *scrollView = [self scrollView]; + if (scrollView == nil || CGRectIsEmpty(scrollView.bounds)) { + return; + } + + CGFloat zoomValue = 1.0; + if (self.scalesPageToFit) { + CGFloat contentWidth = scrollView.contentSize.width; + CGFloat boundsWidth = CGRectGetWidth(scrollView.bounds); + if (contentWidth > 1.0 && boundsWidth > 1.0) { + zoomValue = MIN(1.0, MAX(0.25, boundsWidth / contentWidth)); + } + } + + SEL pageZoomSelector = NSSelectorFromString(@"setPageZoom:"); + if ([self.runtimeWebView respondsToSelector:pageZoomSelector]) { + ((void (*)(id, SEL, double))objc_msgSend)(self.runtimeWebView, pageZoomSelector, zoomValue); + return; + } + + NSString *script = zoomValue == 1.0 + ? @"document.documentElement.style.zoom=''; document.body.style.zoom='';" + : [NSString stringWithFormat:@"document.documentElement.style.zoom='%0.4f'; document.body.style.zoom='%0.4f';", zoomValue, zoomValue]; + [self stringByEvaluatingJavaScriptFromString:script]; +} + +- (void)webView:(id)webView didStartProvisionalNavigation:(id)navigation { + self.loading = YES; + if ([self.delegate respondsToSelector:@selector(webViewDidStartLoad:)]) { + [self.delegate webViewDidStartLoad:self]; + } +} + +- (void)webView:(id)webView didFinishNavigation:(id)navigation { + self.loading = NO; + self.lastTitle = [self title]; + self.lastRequest = [self request]; + [self installYouTubeRequestCaptureHook]; + [self applyPageScalingIfNeeded]; + if ([self.delegate respondsToSelector:@selector(webViewDidFinishLoad:)]) { + [self.delegate webViewDidFinishLoad:self]; + } +} + +- (void)webView:(id)webView didFailNavigation:(id)navigation withError:(NSError *)error { + self.loading = NO; + if ([self.delegate respondsToSelector:@selector(webView:didFailLoadWithError:)]) { + [self.delegate webView:self didFailLoadWithError:error]; + } +} + +- (void)webView:(id)webView didFailProvisionalNavigation:(id)navigation withError:(NSError *)error { + self.loading = NO; + if ([self.delegate respondsToSelector:@selector(webView:didFailLoadWithError:)]) { + [self.delegate webView:self didFailLoadWithError:error]; + } +} + +- (void)webViewWebContentProcessDidTerminate:(id)webView { + self.loading = NO; +} + +- (NSURLRequest *)requestFromNavigationAction:(id)navigationAction { + if (navigationAction == nil) { + return nil; + } + SEL requestSelector = NSSelectorFromString(@"request"); + if (![navigationAction respondsToSelector:requestSelector]) { + return nil; + } + return ((id (*)(id, SEL))objc_msgSend)(navigationAction, requestSelector); +} + +- (NSInteger)navigationTypeFromNavigationAction:(id)navigationAction { + if (navigationAction == nil) { + return 0; + } + SEL navigationTypeSelector = NSSelectorFromString(@"navigationType"); + if (![navigationAction respondsToSelector:navigationTypeSelector]) { + return 0; + } + return ((NSInteger (*)(id, SEL))objc_msgSend)(navigationAction, navigationTypeSelector); +} + +- (void)webView:(id)webView decidePolicyForNavigationAction:(id)navigationAction decisionHandler:(void (^)(NSInteger policy))decisionHandler { + NSURLRequest *request = [self requestFromNavigationAction:navigationAction]; + NSInteger navigationType = [self navigationTypeFromNavigationAction:navigationAction]; + BOOL isMainFrameRequest = YES; + + SEL targetFrameSelector = NSSelectorFromString(@"targetFrame"); + if ([navigationAction respondsToSelector:targetFrameSelector]) { + id targetFrame = ((id (*)(id, SEL))objc_msgSend)(navigationAction, targetFrameSelector); + SEL mainFrameSelector = NSSelectorFromString(@"isMainFrame"); + if (targetFrame != nil && [targetFrame respondsToSelector:mainFrameSelector]) { + isMainFrameRequest = ((BOOL (*)(id, SEL))objc_msgSend)(targetFrame, mainFrameSelector); + } + } + + BOOL shouldAllow = YES; + if ([self.delegate respondsToSelector:@selector(webView:shouldStartLoadWithRequest:navigationType:)]) { + shouldAllow = [self.delegate webView:self shouldStartLoadWithRequest:request navigationType:navigationType]; + } + + if (shouldAllow && isMainFrameRequest && request != nil) { + self.lastRequest = request; + } + + if (decisionHandler != nil) { + decisionHandler(shouldAllow ? 1 : 0); + } +} + +- (id)webView:(id)webView +createWebViewWithConfiguration:(id)configuration +forNavigationAction:(id)navigationAction +windowFeatures:(id)windowFeatures { + (void)webView; + (void)configuration; + (void)windowFeatures; + + NSURLRequest *request = [self requestFromNavigationAction:navigationAction]; + NSInteger navigationType = [self navigationTypeFromNavigationAction:navigationAction]; + + BOOL delegateHandlesNewTabRequests = [self.delegate respondsToSelector:@selector(webView:shouldCreateNewTabWithRequest:navigationType:)]; + BOOL handledInTab = NO; + if (delegateHandlesNewTabRequests) { + handledInTab = [self.delegate webView:self shouldCreateNewTabWithRequest:request navigationType:navigationType]; + } + + if (!delegateHandlesNewTabRequests && !handledInTab && request != nil) { + BOOL shouldAllow = YES; + if ([self.delegate respondsToSelector:@selector(webView:shouldStartLoadWithRequest:navigationType:)]) { + shouldAllow = [self.delegate webView:self shouldStartLoadWithRequest:request navigationType:navigationType]; + } + if (shouldAllow) { + [self loadRequest:request]; + } + } + + return nil; +} + ++ (id)defaultWebsiteDataStore { + BrowserEnsureWebKitRuntimeLoaded(); + Class dataStoreClass = NSClassFromString(kBrowserWebsiteDataStoreClassName); + SEL selector = NSSelectorFromString(@"defaultDataStore"); + if (dataStoreClass == Nil || ![dataStoreClass respondsToSelector:selector]) { + return nil; + } + return ((id (*)(id, SEL))objc_msgSend)((id)dataStoreClass, selector); +} + ++ (id)defaultCookieStore { + id dataStore = [self defaultWebsiteDataStore]; + SEL selector = NSSelectorFromString(@"httpCookieStore"); + if (dataStore == nil || ![dataStore respondsToSelector:selector]) { + return nil; + } + return ((id (*)(id, SEL))objc_msgSend)(dataStore, selector); +} + ++ (NSArray *)allCookies { + id cookieStore = [self defaultCookieStore]; + SEL selector = NSSelectorFromString(@"getAllCookies:"); + if (cookieStore == nil || ![cookieStore respondsToSelector:selector]) { + return NSHTTPCookieStorage.sharedHTTPCookieStorage.cookies ?: @[]; + } + + __block NSArray *cookies = nil; + __block BOOL finished = NO; + ((void (*)(id, SEL, id))objc_msgSend)(cookieStore, selector, ^(NSArray *fetchedCookies) { + cookies = fetchedCookies; + finished = YES; + }); + BrowserPumpRunLoopUntil(&finished); + return cookies ?: @[]; +} + ++ (NSData *)cookieDataRepresentation { + NSArray *cookies = [self allCookies]; + NSError *error = nil; + NSData *cookieData = [NSKeyedArchiver archivedDataWithRootObject:cookies requiringSecureCoding:NO error:&error]; + return error == nil ? cookieData : nil; +} + ++ (void)restoreCookiesFromData:(NSData *)cookieData { + if (cookieData.length == 0) { + return; + } + + NSError *error = nil; + NSSet *allowedClasses = [NSSet setWithObjects:[NSArray class], [NSHTTPCookie class], nil]; + NSArray *cookies = [NSKeyedUnarchiver unarchivedObjectOfClasses:allowedClasses fromData:cookieData error:&error]; + if (![cookies isKindOfClass:[NSArray class]]) { + return; + } + + id cookieStore = [self defaultCookieStore]; + SEL selector = NSSelectorFromString(@"setCookie:completionHandler:"); + if (cookieStore == nil || ![cookieStore respondsToSelector:selector]) { + for (NSHTTPCookie *cookie in cookies) { + [NSHTTPCookieStorage.sharedHTTPCookieStorage setCookie:cookie]; + } + return; + } + + __block NSInteger remainingCount = cookies.count; + __block BOOL finished = cookies.count == 0; + for (NSHTTPCookie *cookie in cookies) { + ((void (*)(id, SEL, id, id))objc_msgSend)(cookieStore, selector, cookie, ^{ + remainingCount -= 1; + finished = remainingCount == 0; + }); + } + BrowserPumpRunLoopUntil(&finished); +} + ++ (NSSet *)allWebsiteDataTypes { + Class dataStoreClass = NSClassFromString(kBrowserWebsiteDataStoreClassName); + SEL selector = NSSelectorFromString(@"allWebsiteDataTypes"); + if (dataStoreClass == Nil || ![dataStoreClass respondsToSelector:selector]) { + return [NSSet set]; + } + return ((id (*)(id, SEL))objc_msgSend)((id)dataStoreClass, selector); +} + ++ (void)removeWebsiteDataTypes:(NSSet *)websiteDataTypes completion:(void (^)(void))completion { + id dataStore = [self defaultWebsiteDataStore]; + SEL selector = NSSelectorFromString(@"removeDataOfTypes:modifiedSince:completionHandler:"); + if (dataStore == nil || ![dataStore respondsToSelector:selector]) { + if (completion != nil) { + completion(); + } + return; + } + + NSDate *beginningOfTime = [NSDate dateWithTimeIntervalSince1970:0]; + ((void (*)(id, SEL, id, id, id))objc_msgSend)(dataStore, selector, websiteDataTypes, beginningOfTime, ^{ + if (completion != nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(); + }); + } + }); +} + ++ (void)clearCachedDataWithCompletion:(void (^)(void))completion { + [[NSURLCache sharedURLCache] removeAllCachedResponses]; + + NSMutableSet *websiteDataTypes = [[self allWebsiteDataTypes] mutableCopy]; + for (NSString *dataType in websiteDataTypes.allObjects) { + if ([dataType.lowercaseString containsString:@"cookie"]) { + [websiteDataTypes removeObject:dataType]; + } + } + + [self removeWebsiteDataTypes:websiteDataTypes completion:completion]; +} + ++ (void)clearCookiesWithCompletion:(void (^)(void))completion { + id cookieStore = [self defaultCookieStore]; + SEL getAllCookiesSelector = NSSelectorFromString(@"getAllCookies:"); + SEL deleteCookieSelector = NSSelectorFromString(@"deleteCookie:completionHandler:"); + if (cookieStore == nil || ![cookieStore respondsToSelector:getAllCookiesSelector] || ![cookieStore respondsToSelector:deleteCookieSelector]) { + NSHTTPCookieStorage *storage = NSHTTPCookieStorage.sharedHTTPCookieStorage; + for (NSHTTPCookie *cookie in storage.cookies) { + [storage deleteCookie:cookie]; + } + if (completion != nil) { + completion(); + } + return; + } + + ((void (*)(id, SEL, id))objc_msgSend)(cookieStore, getAllCookiesSelector, ^(NSArray *cookies) { + if (cookies.count == 0) { + if (completion != nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(); + }); + } + return; + } + + __block NSInteger remainingCount = cookies.count; + for (NSHTTPCookie *cookie in cookies) { + ((void (*)(id, SEL, id, id))objc_msgSend)(cookieStore, deleteCookieSelector, cookie, ^{ + remainingCount -= 1; + if (remainingCount == 0 && completion != nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(); + }); + } + }); + } + }); +} + ++ (void)resetWebsiteDataWithCompletion:(void (^)(void))completion { + [[NSURLCache sharedURLCache] removeAllCachedResponses]; + [self removeWebsiteDataTypes:[self allWebsiteDataTypes] completion:completion]; +} + +@end diff --git a/_Project/Browser/BrowserYouTubeExtractor.h b/_Project/Browser/BrowserYouTubeExtractor.h new file mode 100644 index 0000000..3679460 --- /dev/null +++ b/_Project/Browser/BrowserYouTubeExtractor.h @@ -0,0 +1,45 @@ +#import + +@class BrowserWebView; + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSString * const BrowserYouTubeExtractorErrorDomain; + +typedef NS_ENUM(NSInteger, BrowserYouTubeExtractorErrorCode) { + BrowserYouTubeExtractorErrorCodeUnsupportedURL = 1, + BrowserYouTubeExtractorErrorCodeMissingVideoID = 2, + BrowserYouTubeExtractorErrorCodeMissingPageConfig = 3, + BrowserYouTubeExtractorErrorCodeNetworkFailure = 4, + BrowserYouTubeExtractorErrorCodeInvalidResponse = 5, + BrowserYouTubeExtractorErrorCodeNoPlayableURL = 6, +}; + +@interface BrowserYouTubeExtractionResult : NSObject + +@property (nonatomic, strong, readonly) NSURL *playbackURL; +@property (nonatomic, copy, readonly) NSString *title; +@property (nonatomic, copy, readonly) NSString *sourceDescription; +@property (nonatomic, copy, readonly) NSDictionary *requestHeaders; + +- (instancetype)initWithPlaybackURL:(NSURL *)playbackURL + title:(nullable NSString *)title + sourceDescription:(nullable NSString *)sourceDescription + requestHeaders:(nullable NSDictionary *)requestHeaders NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +@interface BrowserYouTubeExtractor : NSObject + +- (BOOL)canExtractFromPageURL:(nullable NSURL *)pageURL; + +- (void)extractPlaybackInfoFromPageURL:(NSURL *)pageURL + webView:(BrowserWebView *)webView + completion:(void (^)(BrowserYouTubeExtractionResult * _Nullable result, + NSError * _Nullable error))completion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/_Project/Browser/BrowserYouTubeExtractor.m b/_Project/Browser/BrowserYouTubeExtractor.m new file mode 100644 index 0000000..ce583d4 --- /dev/null +++ b/_Project/Browser/BrowserYouTubeExtractor.m @@ -0,0 +1,931 @@ +#import "BrowserYouTubeExtractor.h" + +#import "BrowserWebView.h" + +NSString * const BrowserYouTubeExtractorErrorDomain = @"BrowserYouTubeExtractorErrorDomain"; + +static NSString * const kBrowserYouTubeSafariUserAgent = @"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15"; +static NSString * const kBrowserYouTubeFallbackClientVersion = @"2.20260114.08.00"; +static NSString * const kBrowserYouTubeExtractorLogPrefix = @"[YouTubeExtractor]"; +static NSString * const kBrowserYouTubeIOSUserAgent = @"com.google.ios.youtube/19.09.3 (iPhone16,2; U; CPU iOS 17_4_1 like Mac OS X;)"; +static NSString * const kBrowserYouTubeIOSClientVersion = @"19.09.3"; +static NSString * const kBrowserYouTubeMWEBClientVersion = @"2.20260303.00.00"; +static NSString * const kBrowserYouTubeTVUserAgent = @"Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version"; +static NSString * const kBrowserYouTubeTVClientVersion = @"7.20210204"; +static NSString * const kBrowserYouTubeTVEmbeddedClientVersion = @"2.0"; + +@interface BrowserYouTubeExtractionResult () + +@property (nonatomic, strong, readwrite) NSURL *playbackURL; +@property (nonatomic, copy, readwrite) NSString *title; +@property (nonatomic, copy, readwrite) NSString *sourceDescription; +@property (nonatomic, copy, readwrite) NSDictionary *requestHeaders; + +@end + +@implementation BrowserYouTubeExtractionResult + +- (instancetype)initWithPlaybackURL:(NSURL *)playbackURL + title:(NSString *)title + sourceDescription:(NSString *)sourceDescription + requestHeaders:(NSDictionary *)requestHeaders { + self = [super init]; + if (self) { + _playbackURL = playbackURL; + _title = [title copy] ?: @""; + _sourceDescription = [sourceDescription copy] ?: @""; + _requestHeaders = [requestHeaders copy] ?: @{}; + } + return self; +} + +@end + +@interface BrowserYouTubeExtractor () + +@property (nonatomic, strong) NSURLSession *session; + +@end + +@implementation BrowserYouTubeExtractor + +- (void)log:(NSString *)format, ... NS_FORMAT_FUNCTION(1,2) { + va_list arguments; + va_start(arguments, format); + NSString *message = [[NSString alloc] initWithFormat:format arguments:arguments]; + va_end(arguments); + NSLog(@"%@ %@", kBrowserYouTubeExtractorLogPrefix, message); +} + +- (instancetype)init { + self = [super init]; + if (self) { + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; + configuration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + _session = [NSURLSession sessionWithConfiguration:configuration]; + } + return self; +} + +- (BOOL)canExtractFromPageURL:(NSURL *)pageURL { + NSString *host = pageURL.host.lowercaseString; + if (host.length == 0) { + return NO; + } + return [host containsString:@"youtube.com"] || [host isEqualToString:@"youtu.be"] || [host hasSuffix:@".youtube.com"]; +} + +- (NSError *)errorWithCode:(BrowserYouTubeExtractorErrorCode)code description:(NSString *)description { + return [NSError errorWithDomain:BrowserYouTubeExtractorErrorDomain + code:code + userInfo:@{NSLocalizedDescriptionKey: description ?: @"Unknown YouTube extractor error."}]; +} + +- (NSString *)videoIDFromPageURL:(NSURL *)pageURL { + if (pageURL == nil) { + return nil; + } + + NSString *host = pageURL.host.lowercaseString ?: @""; + if ([host isEqualToString:@"youtu.be"]) { + NSString *path = pageURL.path ?: @""; + NSString *videoID = [path stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"/"]]; + return videoID.length > 0 ? videoID : nil; + } + + NSURLComponents *components = [NSURLComponents componentsWithURL:pageURL resolvingAgainstBaseURL:NO]; + for (NSURLQueryItem *queryItem in components.queryItems ?: @[]) { + if ([queryItem.name isEqualToString:@"v"] && queryItem.value.length > 0) { + return queryItem.value; + } + } + + NSArray *pathComponents = [pageURL.pathComponents filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSString *value, __unused NSDictionary *bindings) { + return ![value isEqualToString:@"/"] && value.length > 0; + }]]; + if (pathComponents.count >= 2) { + NSString *prefix = pathComponents[0]; + if ([prefix isEqualToString:@"shorts"] || [prefix isEqualToString:@"live"] || [prefix isEqualToString:@"embed"] || [prefix isEqualToString:@"v"]) { + return pathComponents[1]; + } + } + + return nil; +} + +- (NSDictionary *)JSONObjectFromJavaScriptString:(NSString *)string { + if (string.length == 0) { + return nil; + } + + NSData *data = [string dataUsingEncoding:NSUTF8StringEncoding]; + if (data == nil) { + return nil; + } + + id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if (![object isKindOfClass:[NSDictionary class]]) { + return nil; + } + return object; +} + +- (NSDictionary *)pageConfigurationFromWebView:(BrowserWebView *)webView { + NSString *script = @"(function(){" + "var cfg=(window.ytcfg&&window.ytcfg.data_)||{};" + "var response=window.ytInitialPlayerResponse||null;" + "if (!response && window.ytplayer && window.ytplayer.config && window.ytplayer.config.args && window.ytplayer.config.args.player_response) {" + "try { response=JSON.parse(window.ytplayer.config.args.player_response); } catch (error) {}" + "}" + "return JSON.stringify({" + "apiKey: String(cfg.INNERTUBE_API_KEY || '')," + "clientVersion: String(cfg.INNERTUBE_CLIENT_VERSION || '')," + "visitorData: String(cfg.VISITOR_DATA || '')," + "poToken: String(((window.__browserYouTubeIntegrity||{}).poToken) || (cfg.PO_TOKEN || '') || ((cfg.SERVICE_INTEGRITY_DIMENSIONS||{}).poToken || (cfg.SERVICE_INTEGRITY_DIMENSIONS||{}).po_token || '') || ((((cfg.WEB_PLAYER_CONTEXT_CONFIGS||{}).WEB_PLAYER_CONTEXT_CONFIG_ID_KEVLAR_WATCH||{}).serviceIntegrityDimensions||{}).poToken || '') || '')," + "requestClientName: String(((window.__browserYouTubeIntegrity||{}).requestClientName) || '')," + "requestClientVersion: String(((window.__browserYouTubeIntegrity||{}).requestClientVersion) || '')," + "firstPlayerRequestURL: String(((window.__browserYouTubeIntegrity||{}).firstPlayerRequestURL) || '')," + "firstPlayerRequestBody: String(((window.__browserYouTubeIntegrity||{}).firstPlayerRequestBody) || '')," + "firstPlayerRequestHeaders: String(((window.__browserYouTubeIntegrity||{}).firstPlayerRequestHeaders) || '')," + "firstPlayerRequestTransport: String(((window.__browserYouTubeIntegrity||{}).firstPlayerRequestTransport) || '')," + "lastPlayerRequestURL: String(((window.__browserYouTubeIntegrity||{}).lastPlayerRequestURL) || '')," + "lastPlayerRequestBody: String(((window.__browserYouTubeIntegrity||{}).lastPlayerRequestBody) || '')," + "lastPlayerRequestHeaders: String(((window.__browserYouTubeIntegrity||{}).lastPlayerRequestHeaders) || '')," + "lastPlayerRequestTransport: String(((window.__browserYouTubeIntegrity||{}).lastPlayerRequestTransport) || '')," + "sts: Number(cfg.STS || 0)," + "hl: String(cfg.HL || 'en')," + "gl: String(cfg.GL || 'US')," + "pageTitle: String((response && response.videoDetails && response.videoDetails.title) || document.title || '')," + "pageHlsManifestUrl: String((response && response.streamingData && response.streamingData.hlsManifestUrl) || '')" + "});" + "})()"; + NSString *resultString = [webView stringByEvaluatingJavaScriptFromString:script]; + NSDictionary *configuration = [self JSONObjectFromJavaScriptString:resultString]; + NSString *firstBody = [configuration[@"firstPlayerRequestBody"] isKindOfClass:[NSString class]] ? configuration[@"firstPlayerRequestBody"] : @""; + NSString *lastBody = [configuration[@"lastPlayerRequestBody"] isKindOfClass:[NSString class]] ? configuration[@"lastPlayerRequestBody"] : @""; + NSString *firstHeaders = [configuration[@"firstPlayerRequestHeaders"] isKindOfClass:[NSString class]] ? configuration[@"firstPlayerRequestHeaders"] : @""; + NSString *lastHeaders = [configuration[@"lastPlayerRequestHeaders"] isKindOfClass:[NSString class]] ? configuration[@"lastPlayerRequestHeaders"] : @""; + [self log:@"page config apiKey=%@ clientVersion=%@ requestClient=%@/%@ sts=%@ visitorData=%@ poToken=%@ firstRequest=%@/%@ headers=%@ serviceIntegrity=%@ lastRequest=%@/%@ headers=%@ serviceIntegrity=%@ pageHLS=%@ title=%@", + [configuration[@"apiKey"] length] > 0 ? @"yes" : @"no", + configuration[@"clientVersion"] ?: @"", + configuration[@"requestClientName"] ?: @"", + configuration[@"requestClientVersion"] ?: @"", + configuration[@"sts"] ?: @0, + [configuration[@"visitorData"] length] > 0 ? @"yes" : @"no", + [configuration[@"poToken"] length] > 0 ? @"yes" : @"no", + configuration[@"firstPlayerRequestTransport"] ?: @"", + [configuration[@"firstPlayerRequestURL"] length] > 0 ? @"yes" : @"no", + firstHeaders.length > 0 ? @"yes" : @"no", + [firstBody containsString:@"serviceIntegrityDimensions"] ? @"yes" : @"no", + configuration[@"lastPlayerRequestTransport"] ?: @"", + [configuration[@"lastPlayerRequestURL"] length] > 0 ? @"yes" : @"no", + lastHeaders.length > 0 ? @"yes" : @"no", + [lastBody containsString:@"serviceIntegrityDimensions"] ? @"yes" : @"no", + configuration[@"pageHlsManifestUrl"] ?: @"", + configuration[@"pageTitle"] ?: @""]; + if (firstBody.length > 0) { + [self log:@"first player request body=%@", firstBody]; + } + if (firstHeaders.length > 0) { + [self log:@"first player request headers=%@", firstHeaders]; + } + if (lastBody.length > 0 && ![lastBody isEqualToString:firstBody]) { + [self log:@"last player request body=%@", lastBody]; + } + if (lastHeaders.length > 0 && ![lastHeaders isEqualToString:firstHeaders]) { + [self log:@"last player request headers=%@", lastHeaders]; + } + return configuration; +} + +- (NSURL *)URLFromPotentialString:(NSString *)potentialURLString { + if (![potentialURLString isKindOfClass:[NSString class]] || potentialURLString.length == 0) { + return nil; + } + NSURL *URL = [NSURL URLWithString:potentialURLString]; + NSString *scheme = URL.scheme.lowercaseString; + if (URL == nil || !([scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"])) { + return nil; + } + return URL; +} + +- (NSDictionary *)playbackRequestHeadersForPageURL:(NSURL *)pageURL { + NSString *originHost = pageURL.host.length > 0 ? pageURL.host : @"www.youtube.com"; + NSString *originScheme = pageURL.scheme.length > 0 ? pageURL.scheme : @"https"; + return @{ + @"User-Agent": kBrowserYouTubeSafariUserAgent, + @"Referer": pageURL.absoluteString ?: @"https://www.youtube.com/watch", + @"Origin": [NSString stringWithFormat:@"%@://%@", originScheme, originHost], + @"Accept": @"*/*", + @"Accept-Language": @"en-US,en;q=0.9", + }; +} + +- (NSArray *)clientProfilesForPageConfiguration:(NSDictionary *)pageConfiguration { + NSString *webClientVersion = [pageConfiguration[@"clientVersion"] isKindOfClass:[NSString class]] && [pageConfiguration[@"clientVersion"] length] > 0 + ? pageConfiguration[@"clientVersion"] + : kBrowserYouTubeFallbackClientVersion; + NSString *hl = [pageConfiguration[@"hl"] isKindOfClass:[NSString class]] && [pageConfiguration[@"hl"] length] > 0 ? pageConfiguration[@"hl"] : @"en"; + NSString *gl = [pageConfiguration[@"gl"] isKindOfClass:[NSString class]] && [pageConfiguration[@"gl"] length] > 0 ? pageConfiguration[@"gl"] : @"US"; + + return @[ + @{ + @"label": @"web", + @"clientName": @"WEB", + @"clientVersion": webClientVersion, + @"clientHeaderName": @"1", + @"userAgent": kBrowserYouTubeSafariUserAgent, + @"hl": hl, + @"gl": gl, + @"sendOrigin": @YES, + @"sendReferer": @YES, + }, + @{ + @"label": @"mweb", + @"clientName": @"MWEB", + @"clientVersion": kBrowserYouTubeMWEBClientVersion, + @"clientHeaderName": @"2", + @"userAgent": kBrowserYouTubeSafariUserAgent, + @"hl": hl, + @"gl": gl, + @"sendOrigin": @YES, + @"sendReferer": @YES, + }, + @{ + @"label": @"web_safari", + @"clientName": @"WEB", + @"clientVersion": webClientVersion, + @"clientHeaderName": @"1", + @"userAgent": kBrowserYouTubeSafariUserAgent, + @"hl": hl, + @"gl": gl, + @"sendOrigin": @YES, + @"sendReferer": @YES, + }, + @{ + @"label": @"ios", + @"clientName": @"IOS", + @"clientVersion": kBrowserYouTubeIOSClientVersion, + @"clientHeaderName": @"5", + @"userAgent": kBrowserYouTubeIOSUserAgent, + @"hl": hl, + @"gl": gl, + @"osName": @"iPhone", + @"osVersion": @"17.4.1.21E236", + @"deviceModel": @"iPhone16,2", + @"sendOrigin": @NO, + @"sendReferer": @NO, + }, + @{ + @"label": @"tv", + @"clientName": @"TVHTML5", + @"clientVersion": kBrowserYouTubeTVClientVersion, + @"clientHeaderName": @"7", + @"userAgent": kBrowserYouTubeTVUserAgent, + @"hl": hl, + @"gl": gl, + @"sendOrigin": @NO, + @"sendReferer": @NO, + }, + @{ + @"label": @"tv_embedded", + @"clientName": @"TVHTML5_SIMPLY_EMBEDDED_PLAYER", + @"clientVersion": kBrowserYouTubeTVEmbeddedClientVersion, + @"clientHeaderName": @"85", + @"userAgent": kBrowserYouTubeTVUserAgent, + @"hl": hl, + @"gl": gl, + @"sendOrigin": @NO, + @"sendReferer": @NO, + }, + ]; +} + +- (NSDictionary *)capturedPlayerRequestBodyFromPageConfiguration:(NSDictionary *)pageConfiguration { + NSString *bodyString = [pageConfiguration[@"firstPlayerRequestBody"] isKindOfClass:[NSString class]] ? pageConfiguration[@"firstPlayerRequestBody"] : @""; + NSDictionary *body = [self JSONObjectFromJavaScriptString:bodyString]; + return [body isKindOfClass:[NSDictionary class]] ? body : nil; +} + +- (NSDictionary *)capturedPlayerRequestHeadersFromPageConfiguration:(NSDictionary *)pageConfiguration { + NSString *headersString = [pageConfiguration[@"firstPlayerRequestHeaders"] isKindOfClass:[NSString class]] ? pageConfiguration[@"firstPlayerRequestHeaders"] : @""; + NSDictionary *headers = [self JSONObjectFromJavaScriptString:headersString]; + if (![headers isKindOfClass:[NSDictionary class]]) { + return nil; + } + NSMutableDictionary *normalizedHeaders = [NSMutableDictionary dictionary]; + [headers enumerateKeysAndObjectsUsingBlock:^(id key, id obj, __unused BOOL *stop) { + if ([key isKindOfClass:[NSString class]]) { + normalizedHeaders[(NSString *)key] = [obj isKindOfClass:[NSString class]] ? obj : [obj description]; + } + }]; + return normalizedHeaders; +} + +- (BOOL)cookie:(NSHTTPCookie *)cookie matchesHost:(NSString *)host { + if (cookie == nil || host.length == 0) { + return NO; + } + + NSString *cookieDomain = cookie.domain.lowercaseString ?: @""; + NSString *lowercaseHost = host.lowercaseString; + if (cookieDomain.length == 0) { + return NO; + } + + if ([cookieDomain hasPrefix:@"."]) { + cookieDomain = [cookieDomain substringFromIndex:1]; + } + + return [lowercaseHost isEqualToString:cookieDomain] || [lowercaseHost hasSuffix:[@"." stringByAppendingString:cookieDomain]]; +} + +- (NSArray *)cookiesForPlaybackURL:(NSURL *)playbackURL pageURL:(NSURL *)pageURL { + NSMutableArray *matchingCookies = [NSMutableArray array]; + NSMutableSet *seenCookieKeys = [NSMutableSet set]; + NSArray *allCookies = [BrowserWebView allCookies]; + NSString *pageHost = pageURL.host.lowercaseString ?: @""; + NSString *playbackHost = playbackURL.host.lowercaseString ?: @""; + + for (NSHTTPCookie *cookie in allCookies) { + BOOL matches = [self cookie:cookie matchesHost:pageHost] || + [self cookie:cookie matchesHost:playbackHost] || + [self cookie:cookie matchesHost:@"youtube.com"] || + [self cookie:cookie matchesHost:@"googlevideo.com"]; + if (!matches) { + continue; + } + + NSString *cookieKey = [NSString stringWithFormat:@"%@|%@|%@", cookie.domain ?: @"", cookie.path ?: @"", cookie.name ?: @""]; + if ([seenCookieKeys containsObject:cookieKey]) { + continue; + } + [seenCookieKeys addObject:cookieKey]; + [matchingCookies addObject:cookie]; + } + + return matchingCookies; +} + +- (void)validatePlaybackResult:(BrowserYouTubeExtractionResult *)result + pageURL:(NSURL *)pageURL + completion:(void (^)(BrowserYouTubeExtractionResult * _Nullable result, + NSError * _Nullable error))completion { + if (result.playbackURL == nil) { + completion(nil, [self errorWithCode:BrowserYouTubeExtractorErrorCodeNoPlayableURL description:@"No playback URL was available to validate."]); + return; + } + + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:result.playbackURL]; + request.HTTPMethod = @"GET"; + request.timeoutInterval = 20.0; + [result.requestHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, __unused BOOL *stop) { + if (value.length > 0) { + [request setValue:value forHTTPHeaderField:key]; + } + }]; + + NSArray *cookies = [self cookiesForPlaybackURL:result.playbackURL pageURL:pageURL]; + NSString *cookieHeader = nil; + if (cookies.count > 0) { + NSDictionary *cookieHeaders = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies]; + cookieHeader = cookieHeaders[@"Cookie"]; + if (cookieHeader.length > 0) { + [request setValue:cookieHeader forHTTPHeaderField:@"Cookie"]; + } + } + + [self log:@"preflight playback url=%@ source=%@ headers=%@ cookies=%lu", + result.playbackURL.absoluteString ?: @"", + result.sourceDescription ?: @"", + result.requestHeaders ?: @{}, + (unsigned long)cookies.count]; + + NSURLSessionDataTask *task = [self.session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error != nil) { + [self log:@"preflight network error %@", error]; + dispatch_async(dispatch_get_main_queue(), ^{ + completion(nil, [self errorWithCode:BrowserYouTubeExtractorErrorCodeNetworkFailure description:error.localizedDescription]); + }); + return; + } + + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + NSString *contentType = [httpResponse valueForHTTPHeaderField:@"Content-Type"] ?: @""; + NSString *bodyPreview = data.length > 0 ? [[NSString alloc] initWithData:[data subdataWithRange:NSMakeRange(0, MIN((NSUInteger)200, data.length))] encoding:NSUTF8StringEncoding] : @""; + [self log:@"preflight response status=%ld contentType=%@ bytes=%lu preview=%@", + (long)httpResponse.statusCode, + contentType, + (unsigned long)data.length, + bodyPreview ?: @""]; + + if (![httpResponse isKindOfClass:[NSHTTPURLResponse class]] || httpResponse.statusCode < 200 || httpResponse.statusCode >= 300) { + NSString *description = httpResponse.statusCode == 403 + ? @"YouTube returned HTTP 403 for the extracted playback URL before AVPlayer even tried to play it. This usually means the URL still needs a different client context or a PO token." + : @"The extracted playback URL could not be fetched successfully."; + dispatch_async(dispatch_get_main_queue(), ^{ + completion(nil, [self errorWithCode:BrowserYouTubeExtractorErrorCodeInvalidResponse description:description]); + }); + return; + } + + NSURL *manifestURL = result.playbackURL; + NSString *manifestString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @""; + NSMutableArray *nonCommentEntries = [NSMutableArray array]; + [manifestString enumerateLinesUsingBlock:^(NSString *line, __unused BOOL *stop) { + NSString *trimmedLine = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trimmedLine.length > 0 && ![trimmedLine hasPrefix:@"#"]) { + [nonCommentEntries addObject:trimmedLine]; + } + }]; + + NSString *variantEntry = nonCommentEntries.firstObject; + if (variantEntry.length == 0) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(result, nil); + }); + return; + } + + NSURL *variantURL = [NSURL URLWithString:variantEntry relativeToURL:manifestURL].absoluteURL; + if (variantURL == nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(result, nil); + }); + return; + } + + NSMutableURLRequest *variantRequest = [NSMutableURLRequest requestWithURL:variantURL]; + variantRequest.HTTPMethod = @"GET"; + variantRequest.timeoutInterval = 20.0; + [result.requestHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, __unused BOOL *stop) { + if (value.length > 0) { + [variantRequest setValue:value forHTTPHeaderField:key]; + } + }]; + if (cookieHeader.length > 0) { + [variantRequest setValue:cookieHeader forHTTPHeaderField:@"Cookie"]; + } + + NSURLSessionDataTask *variantTask = [self.session dataTaskWithRequest:variantRequest completionHandler:^(NSData *variantData, NSURLResponse *variantResponse, NSError *variantError) { + if (variantError != nil) { + [self log:@"variant preflight error %@", variantError]; + dispatch_async(dispatch_get_main_queue(), ^{ + completion(nil, [self errorWithCode:BrowserYouTubeExtractorErrorCodeNetworkFailure description:variantError.localizedDescription]); + }); + return; + } + + NSHTTPURLResponse *variantHTTPResponse = (NSHTTPURLResponse *)variantResponse; + NSString *variantString = [[NSString alloc] initWithData:variantData encoding:NSUTF8StringEncoding] ?: @""; + NSMutableArray *segmentEntries = [NSMutableArray array]; + [variantString enumerateLinesUsingBlock:^(NSString *line, __unused BOOL *stop) { + NSString *trimmedLine = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trimmedLine.length > 0 && ![trimmedLine hasPrefix:@"#"]) { + [segmentEntries addObject:trimmedLine]; + } + }]; + + NSString *segmentEntry = segmentEntries.firstObject; + NSURL *segmentURL = [NSURL URLWithString:segmentEntry relativeToURL:variantURL].absoluteURL; + [self log:@"variant preflight status=%ld firstSegment=%@", + (long)variantHTTPResponse.statusCode, + segmentURL.absoluteString ?: @""]; + if (segmentURL == nil || variantHTTPResponse.statusCode < 200 || variantHTTPResponse.statusCode >= 300) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(nil, [self errorWithCode:BrowserYouTubeExtractorErrorCodeInvalidResponse description:@"YouTube HLS variant playlist could not be validated."]); + }); + return; + } + + NSMutableURLRequest *segmentRequest = [NSMutableURLRequest requestWithURL:segmentURL]; + segmentRequest.HTTPMethod = @"GET"; + segmentRequest.timeoutInterval = 20.0; + [segmentRequest setValue:@"bytes=0-2047" forHTTPHeaderField:@"Range"]; + [result.requestHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, __unused BOOL *stop) { + if (value.length > 0) { + NSString *host = segmentURL.host.lowercaseString ?: @""; + if ([host containsString:@"googlevideo.com"] && [key caseInsensitiveCompare:@"Origin"] == NSOrderedSame) { + return; + } + [segmentRequest setValue:value forHTTPHeaderField:key]; + } + }]; + + NSArray *segmentCookies = [self cookiesForPlaybackURL:segmentURL pageURL:pageURL]; + NSDictionary *segmentCookieHeaders = [NSHTTPCookie requestHeaderFieldsWithCookies:segmentCookies]; + NSString *segmentCookieHeader = segmentCookieHeaders[@"Cookie"]; + if (segmentCookieHeader.length > 0) { + [segmentRequest setValue:segmentCookieHeader forHTTPHeaderField:@"Cookie"]; + } + + NSURLSessionDataTask *segmentTask = [self.session dataTaskWithRequest:segmentRequest completionHandler:^(NSData *segmentData, NSURLResponse *segmentResponse, NSError *segmentError) { + if (segmentError != nil) { + [self log:@"segment preflight error %@", segmentError]; + dispatch_async(dispatch_get_main_queue(), ^{ + completion(nil, [self errorWithCode:BrowserYouTubeExtractorErrorCodeNetworkFailure description:segmentError.localizedDescription]); + }); + return; + } + + NSHTTPURLResponse *segmentHTTPResponse = (NSHTTPURLResponse *)segmentResponse; + NSString *segmentType = [segmentHTTPResponse valueForHTTPHeaderField:@"Content-Type"] ?: @""; + [self log:@"segment preflight status=%ld contentType=%@ bytes=%lu url=%@", + (long)segmentHTTPResponse.statusCode, + segmentType, + (unsigned long)segmentData.length, + segmentURL.absoluteString ?: @""]; + + if (segmentHTTPResponse.statusCode < 200 || segmentHTTPResponse.statusCode >= 299) { + NSString *description = segmentHTTPResponse.statusCode == 403 + ? @"YouTube allowed the manifest but rejected the first media segment for this client path." + : @"YouTube media segment validation failed."; + dispatch_async(dispatch_get_main_queue(), ^{ + completion(nil, [self errorWithCode:BrowserYouTubeExtractorErrorCodeInvalidResponse description:description]); + }); + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + completion(result, nil); + }); + }]; + [segmentTask resume]; + }]; + [variantTask resume]; + }]; + [task resume]; +} + +- (BrowserYouTubeExtractionResult *)resultFromFormats:(NSArray *)formats title:(NSString *)title source:(NSString *)sourcePrefix { + if (![formats isKindOfClass:[NSArray class]]) { + [self log:@"no candidate formats for source=%@", sourcePrefix]; + return nil; + } + + NSDictionary *bestFormat = nil; + NSInteger bestHeight = -1; + NSInteger bestBitrate = -1; + for (id entry in formats) { + if (![entry isKindOfClass:[NSDictionary class]]) { + continue; + } + NSDictionary *format = (NSDictionary *)entry; + NSURL *url = [self URLFromPotentialString:format[@"url"]]; + if (url == nil) { + if ([format[@"signatureCipher"] isKindOfClass:[NSString class]] || [format[@"cipher"] isKindOfClass:[NSString class]]) { + [self log:@"skipping ciphered format id=%@ quality=%@ because signature decipher is not implemented yet", + format[@"itag"] ?: format[@"format_id"] ?: @"", + format[@"qualityLabel"] ?: @""]; + } + continue; + } + + NSString *mimeType = [format[@"mimeType"] isKindOfClass:[NSString class]] ? format[@"mimeType"] : @""; + if (![mimeType containsString:@"video/mp4"] && mimeType.length > 0) { + continue; + } + + NSInteger height = [format[@"height"] respondsToSelector:@selector(integerValue)] ? [format[@"height"] integerValue] : 0; + NSInteger bitrate = [format[@"bitrate"] respondsToSelector:@selector(integerValue)] ? [format[@"bitrate"] integerValue] : 0; + if (bestFormat == nil || height > bestHeight || (height == bestHeight && bitrate > bestBitrate)) { + bestFormat = format; + bestHeight = height; + bestBitrate = bitrate; + } + } + + if (bestFormat == nil) { + [self log:@"no playable muxed mp4 format found for source=%@", sourcePrefix]; + return nil; + } + + NSURL *playbackURL = [self URLFromPotentialString:bestFormat[@"url"]]; + if (playbackURL == nil) { + return nil; + } + + NSString *qualityLabel = [bestFormat[@"qualityLabel"] isKindOfClass:[NSString class]] ? bestFormat[@"qualityLabel"] : @""; + NSString *source = qualityLabel.length > 0 ? [NSString stringWithFormat:@"%@ %@", sourcePrefix, qualityLabel] : sourcePrefix; + [self log:@"selected format source=%@ itag=%@ quality=%@ mime=%@ url=%@", + source, + bestFormat[@"itag"] ?: bestFormat[@"format_id"] ?: @"", + qualityLabel, + bestFormat[@"mimeType"] ?: @"", + playbackURL.absoluteString ?: @""]; + return [[BrowserYouTubeExtractionResult alloc] initWithPlaybackURL:playbackURL + title:title + sourceDescription:source + requestHeaders:@{ + @"User-Agent": kBrowserYouTubeSafariUserAgent, + @"Accept": @"*/*", + @"Accept-Language": @"en-US,en;q=0.9", + }]; +} + +- (BrowserYouTubeExtractionResult *)resultFromPlayerResponse:(NSDictionary *)playerResponse + fallbackTitle:(NSString *)fallbackTitle + pageURL:(NSURL *)pageURL + sourceLabel:(NSString *)sourceLabel + requestHeaders:(NSDictionary *)requestHeaders { + if (![playerResponse isKindOfClass:[NSDictionary class]]) { + [self log:@"player response was not a dictionary"]; + return nil; + } + + NSDictionary *playabilityStatus = [playerResponse[@"playabilityStatus"] isKindOfClass:[NSDictionary class]] ? playerResponse[@"playabilityStatus"] : nil; + NSString *status = [playabilityStatus[@"status"] isKindOfClass:[NSString class]] ? playabilityStatus[@"status"] : @""; + NSString *reason = [playabilityStatus[@"reason"] isKindOfClass:[NSString class]] ? playabilityStatus[@"reason"] : @""; + if (status.length > 0) { + [self log:@"player response playabilityStatus=%@ reason=%@", status, reason]; + } + + NSDictionary *videoDetails = [playerResponse[@"videoDetails"] isKindOfClass:[NSDictionary class]] ? playerResponse[@"videoDetails"] : nil; + NSString *title = [videoDetails[@"title"] isKindOfClass:[NSString class]] ? videoDetails[@"title"] : fallbackTitle; + NSDictionary *streamingData = [playerResponse[@"streamingData"] isKindOfClass:[NSDictionary class]] ? playerResponse[@"streamingData"] : nil; + if (![streamingData isKindOfClass:[NSDictionary class]]) { + [self log:@"player response missing streamingData"]; + return nil; + } + + NSURL *hlsManifestURL = [self URLFromPotentialString:streamingData[@"hlsManifestUrl"]]; + if (hlsManifestURL != nil) { + [self log:@"selected hls manifest url=%@", hlsManifestURL.absoluteString ?: @""]; + return [[BrowserYouTubeExtractionResult alloc] initWithPlaybackURL:hlsManifestURL + title:title + sourceDescription:[NSString stringWithFormat:@"youtube %@ hls", sourceLabel ?: @"unknown"] + requestHeaders:requestHeaders ?: [self playbackRequestHeadersForPageURL:pageURL]]; + } + + BrowserYouTubeExtractionResult *formatResult = [self resultFromFormats:streamingData[@"formats"] + title:title + source:@"youtube muxed"]; + if (formatResult != nil) { + return formatResult; + } + + [self log:@"player response had streamingData but no playable hls or muxed format"]; + return nil; +} + +- (NSDictionary *)playerRequestBodyForVideoID:(NSString *)videoID pageConfiguration:(NSDictionary *)pageConfiguration clientProfile:(NSDictionary *)clientProfile { + NSDictionary *capturedBody = [self capturedPlayerRequestBodyFromPageConfiguration:pageConfiguration]; + NSString *clientVersion = [clientProfile[@"clientVersion"] isKindOfClass:[NSString class]] && [clientProfile[@"clientVersion"] length] > 0 + ? clientProfile[@"clientVersion"] + : kBrowserYouTubeFallbackClientVersion; + NSString *hl = [clientProfile[@"hl"] isKindOfClass:[NSString class]] && [clientProfile[@"hl"] length] > 0 ? clientProfile[@"hl"] : @"en"; + NSString *gl = [clientProfile[@"gl"] isKindOfClass:[NSString class]] && [clientProfile[@"gl"] length] > 0 ? clientProfile[@"gl"] : @"US"; + NSNumber *sts = [pageConfiguration[@"sts"] respondsToSelector:@selector(integerValue)] ? @([pageConfiguration[@"sts"] integerValue]) : nil; + NSString *poToken = [pageConfiguration[@"poToken"] isKindOfClass:[NSString class]] ? pageConfiguration[@"poToken"] : @""; + + NSMutableDictionary *client = [@{ + @"clientName": clientProfile[@"clientName"] ?: @"WEB", + @"clientVersion": clientVersion, + @"hl": hl, + @"gl": gl, + @"userAgent": [clientProfile[@"userAgent"] isKindOfClass:[NSString class]] ? clientProfile[@"userAgent"] : kBrowserYouTubeSafariUserAgent, + } mutableCopy]; + if ([clientProfile[@"osName"] isKindOfClass:[NSString class]]) { + client[@"osName"] = clientProfile[@"osName"]; + } + if ([clientProfile[@"osVersion"] isKindOfClass:[NSString class]]) { + client[@"osVersion"] = clientProfile[@"osVersion"]; + } + if ([clientProfile[@"deviceModel"] isKindOfClass:[NSString class]]) { + client[@"deviceModel"] = clientProfile[@"deviceModel"]; + } + + NSMutableDictionary *body = nil; + if ([capturedBody isKindOfClass:[NSDictionary class]] && [clientProfile[@"label"] isEqualToString:@"web"]) { + body = [capturedBody mutableCopy]; + NSMutableDictionary *context = [[capturedBody[@"context"] isKindOfClass:[NSDictionary class]] ? capturedBody[@"context"] : @{} mutableCopy]; + context[@"client"] = client; + body[@"context"] = context; + body[@"videoId"] = videoID; + } else { + body = [@{ + @"videoId": videoID, + @"contentCheckOk": @YES, + @"racyCheckOk": @YES, + @"context": @{ + @"client": client, + }, + } mutableCopy]; + } + + if (body[@"contentCheckOk"] == nil) { + body[@"contentCheckOk"] = @YES; + } + if (body[@"racyCheckOk"] == nil) { + body[@"racyCheckOk"] = @YES; + } + + if (sts.integerValue > 0) { + body[@"playbackContext"] = @{ + @"contentPlaybackContext": @{ + @"signatureTimestamp": sts, + }, + }; + } + + if (poToken.length > 0) { + body[@"serviceIntegrityDimensions"] = @{ + @"poToken": poToken, + }; + } + + return body; +} + +- (NSDictionary *)requestHeadersForPageURL:(NSURL *)pageURL clientProfile:(NSDictionary *)clientProfile visitorData:(NSString *)visitorData { + NSMutableDictionary *headers = [NSMutableDictionary dictionary]; + NSString *userAgent = [clientProfile[@"userAgent"] isKindOfClass:[NSString class]] ? clientProfile[@"userAgent"] : kBrowserYouTubeSafariUserAgent; + if (userAgent.length > 0) { + headers[@"User-Agent"] = userAgent; + } + if ([clientProfile[@"sendOrigin"] boolValue]) { + headers[@"Origin"] = @"https://www.youtube.com"; + } + if ([clientProfile[@"sendReferer"] boolValue]) { + headers[@"Referer"] = pageURL.absoluteString ?: @"https://www.youtube.com/"; + } + headers[@"Accept"] = @"*/*"; + headers[@"Accept-Language"] = @"en-US,en;q=0.9"; + if (visitorData.length > 0) { + headers[@"X-Goog-Visitor-Id"] = visitorData; + } + return headers; +} + +- (NSMutableURLRequest *)playerRequestForVideoID:(NSString *)videoID pageConfiguration:(NSDictionary *)pageConfiguration clientProfile:(NSDictionary *)clientProfile { + NSString *apiKey = [pageConfiguration[@"apiKey"] isKindOfClass:[NSString class]] ? pageConfiguration[@"apiKey"] : @""; + if (apiKey.length == 0) { + return nil; + } + + NSURLComponents *components = [NSURLComponents componentsWithString:@"https://www.youtube.com/youtubei/v1/player"]; + components.queryItems = @[ + [NSURLQueryItem queryItemWithName:@"key" value:apiKey], + [NSURLQueryItem queryItemWithName:@"prettyPrint" value:@"false"], + ]; + + NSURL *URL = components.URL; + if (URL == nil) { + return nil; + } + + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:URL]; + request.HTTPMethod = @"POST"; + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + NSString *clientVersion = [clientProfile[@"clientVersion"] isKindOfClass:[NSString class]] && [clientProfile[@"clientVersion"] length] > 0 + ? clientProfile[@"clientVersion"] + : kBrowserYouTubeFallbackClientVersion; + NSMutableDictionary *requestHeaders = [[self requestHeadersForPageURL:[NSURL URLWithString:@"https://www.youtube.com/"] clientProfile:clientProfile visitorData:[pageConfiguration[@"visitorData"] isKindOfClass:[NSString class]] ? pageConfiguration[@"visitorData"] : @""] mutableCopy]; + NSDictionary *capturedHeaders = [self capturedPlayerRequestHeadersFromPageConfiguration:pageConfiguration]; + if ([clientProfile[@"label"] isEqualToString:@"web"] && capturedHeaders.count > 0) { + [capturedHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, __unused BOOL *stop) { + if (value.length > 0) { + requestHeaders[key] = value; + } + }]; + } + [requestHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, __unused BOOL *stop) { + if (value.length > 0) { + [request setValue:value forHTTPHeaderField:key]; + } + }]; + + NSString *clientHeaderName = [clientProfile[@"clientHeaderName"] isKindOfClass:[NSString class]] ? clientProfile[@"clientHeaderName"] : @"1"; + [request setValue:clientHeaderName forHTTPHeaderField:@"X-YouTube-Client-Name"]; + [request setValue:clientVersion forHTTPHeaderField:@"X-YouTube-Client-Version"]; + + NSDictionary *requestBody = [self playerRequestBodyForVideoID:videoID pageConfiguration:pageConfiguration clientProfile:clientProfile]; + NSData *bodyData = [NSJSONSerialization dataWithJSONObject:requestBody options:0 error:nil]; + request.HTTPBody = bodyData; + [self log:@"issuing youtubei player request client=%@ videoID=%@ apiKey=%@ clientVersion=%@ body=%@", + clientProfile[@"label"] ?: @"unknown", + videoID, + apiKey.length > 0 ? @"yes" : @"no", + clientVersion, + requestBody]; + return request; +} + +- (void)attemptPlayerRequestForVideoID:(NSString *)videoID + pageConfiguration:(NSDictionary *)pageConfiguration + pageURL:(NSURL *)pageURL + pageTitle:(NSString *)pageTitle + clientIndex:(NSUInteger)clientIndex + completion:(void (^)(BrowserYouTubeExtractionResult * _Nullable result, NSError * _Nullable error))completion { + NSArray *clientProfiles = [self clientProfilesForPageConfiguration:pageConfiguration]; + if (clientIndex >= clientProfiles.count) { + completion(nil, [self errorWithCode:BrowserYouTubeExtractorErrorCodeNoPlayableURL description:@"No YouTube client profile produced a native-playable stream URL."]); + return; + } + + NSDictionary *clientProfile = clientProfiles[clientIndex]; + NSMutableURLRequest *request = [self playerRequestForVideoID:videoID pageConfiguration:pageConfiguration clientProfile:clientProfile]; + if (request == nil) { + [self attemptPlayerRequestForVideoID:videoID pageConfiguration:pageConfiguration pageURL:pageURL pageTitle:pageTitle clientIndex:clientIndex + 1 completion:completion]; + return; + } + + NSURLSessionDataTask *task = [self.session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error != nil) { + [self log:@"client=%@ network error %@", clientProfile[@"label"] ?: @"unknown", error]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self attemptPlayerRequestForVideoID:videoID pageConfiguration:pageConfiguration pageURL:pageURL pageTitle:pageTitle clientIndex:clientIndex + 1 completion:completion]; + }); + return; + } + + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + if (![httpResponse isKindOfClass:[NSHTTPURLResponse class]] || httpResponse.statusCode < 200 || httpResponse.statusCode >= 300) { + [self log:@"client=%@ unexpected HTTP response status=%ld", + clientProfile[@"label"] ?: @"unknown", + (long)httpResponse.statusCode]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self attemptPlayerRequestForVideoID:videoID pageConfiguration:pageConfiguration pageURL:pageURL pageTitle:pageTitle clientIndex:clientIndex + 1 completion:completion]; + }); + return; + } + + id responseObject = data.length > 0 ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; + [self log:@"received player response client=%@ status=%ld bytes=%lu", + clientProfile[@"label"] ?: @"unknown", + (long)httpResponse.statusCode, + (unsigned long)data.length]; + + NSDictionary *playbackHeaders = [self requestHeadersForPageURL:pageURL + clientProfile:clientProfile + visitorData:[pageConfiguration[@"visitorData"] isKindOfClass:[NSString class]] ? pageConfiguration[@"visitorData"] : @""]; + BrowserYouTubeExtractionResult *result = [self resultFromPlayerResponse:responseObject + fallbackTitle:pageTitle + pageURL:pageURL + sourceLabel:clientProfile[@"label"] + requestHeaders:playbackHeaders]; + if (result == nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self attemptPlayerRequestForVideoID:videoID pageConfiguration:pageConfiguration pageURL:pageURL pageTitle:pageTitle clientIndex:clientIndex + 1 completion:completion]; + }); + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + [self validatePlaybackResult:result pageURL:pageURL completion:^(BrowserYouTubeExtractionResult *validatedResult, NSError *validationError) { + if (validatedResult != nil) { + completion(validatedResult, nil); + return; + } + + [self log:@"client=%@ validation failed %@", clientProfile[@"label"] ?: @"unknown", validationError.localizedDescription ?: @""]; + [self attemptPlayerRequestForVideoID:videoID pageConfiguration:pageConfiguration pageURL:pageURL pageTitle:pageTitle clientIndex:clientIndex + 1 completion:completion]; + }]; + }); + }]; + [task resume]; +} + +- (void)extractPlaybackInfoFromPageURL:(NSURL *)pageURL + webView:(BrowserWebView *)webView + completion:(void (^)(BrowserYouTubeExtractionResult *result, NSError *error))completion { + if (![self canExtractFromPageURL:pageURL]) { + completion(nil, [self errorWithCode:BrowserYouTubeExtractorErrorCodeUnsupportedURL description:@"This page is not a YouTube page."]); + return; + } + + NSString *videoID = [self videoIDFromPageURL:pageURL]; + if (videoID.length == 0) { + completion(nil, [self errorWithCode:BrowserYouTubeExtractorErrorCodeMissingVideoID description:@"Could not determine the YouTube video ID from the page URL."]); + return; + } + [self log:@"starting extraction pageURL=%@ videoID=%@", pageURL.absoluteString ?: @"", videoID]; + + NSDictionary *pageConfiguration = [self pageConfigurationFromWebView:webView]; + if (![pageConfiguration isKindOfClass:[NSDictionary class]]) { + completion(nil, [self errorWithCode:BrowserYouTubeExtractorErrorCodeMissingPageConfig description:@"Could not read YouTube configuration from the current page."]); + return; + } + + NSString *pageTitle = [pageConfiguration[@"pageTitle"] isKindOfClass:[NSString class]] ? pageConfiguration[@"pageTitle"] : @""; + NSURL *pageHLSURL = [self URLFromPotentialString:pageConfiguration[@"pageHlsManifestUrl"]]; + if (pageHLSURL != nil) { + [self log:@"using page-provided hls manifest url=%@", pageHLSURL.absoluteString ?: @""]; + BrowserYouTubeExtractionResult *result = [[BrowserYouTubeExtractionResult alloc] initWithPlaybackURL:pageHLSURL + title:pageTitle + sourceDescription:@"youtube page hls" + requestHeaders:[self playbackRequestHeadersForPageURL:pageURL]]; + [self validatePlaybackResult:result pageURL:pageURL completion:completion]; + return; + } + + [self attemptPlayerRequestForVideoID:videoID + pageConfiguration:pageConfiguration + pageURL:pageURL + pageTitle:pageTitle + clientIndex:0 + completion:completion]; +} + +@end diff --git a/Browser/Info.plist b/_Project/Browser/Info.plist similarity index 82% rename from Browser/Info.plist rename to _Project/Browser/Info.plist index 644a5a8..6347fc7 100644 --- a/Browser/Info.plist +++ b/_Project/Browser/Info.plist @@ -4,6 +4,8 @@ CFBundleDevelopmentRegion en + CFBundleDisplayName + tvOS Browser CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,20 +17,22 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleSignature ???? CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS - UIMainStoryboardFile - Main NSAppTransportSecurity NSAllowsArbitraryLoads + UIMainStoryboardFile + Main UIRequiredDeviceCapabilities arm64 diff --git a/_Project/Browser/UIApplication+BrowserSelectPressForwarding.m b/_Project/Browser/UIApplication+BrowserSelectPressForwarding.m new file mode 100644 index 0000000..a37ccd2 --- /dev/null +++ b/_Project/Browser/UIApplication+BrowserSelectPressForwarding.m @@ -0,0 +1,265 @@ +#import +#import +#import +#import + +NSString * const BrowserGlobalSelectPressEndedNotification = @"BrowserGlobalSelectPressEndedNotification"; +static BOOL sBrowserNativeScrubTracking = NO; +static CGFloat sBrowserNativePendingScrubPixels = 0.0; +static CGPoint sBrowserNativeLastTouchLocation = {0, 0}; +static CFTimeInterval sBrowserNativeLastArrowPressTimestamp = 0.0; +static UIPressType sBrowserNativeLastArrowPressType = (UIPressType)-1; +static CGFloat const kBrowserNativeScrubPixelStep = 18.0; +static CFTimeInterval const kBrowserNativeArrowDoubleTapInterval = 0.35; + +static NSString *BrowserPressTypeString(UIPressType type) { + switch (type) { + case UIPressTypeMenu: return @"Menu"; + case UIPressTypePlayPause: return @"PlayPause"; + case UIPressTypeSelect: return @"Select"; + case UIPressTypeUpArrow: return @"Up"; + case UIPressTypeDownArrow: return @"Down"; + case UIPressTypeLeftArrow: return @"Left"; + case UIPressTypeRightArrow: return @"Right"; + default: return [NSString stringWithFormat:@"Type-%ld", (long)type]; + } +} + +static NSString *BrowserPressPhaseString(UIPressPhase phase) { + switch (phase) { + case UIPressPhaseBegan: return @"Began"; + case UIPressPhaseChanged: return @"Changed"; + case UIPressPhaseStationary: return @"Stationary"; + case UIPressPhaseEnded: return @"Ended"; + case UIPressPhaseCancelled: return @"Cancelled"; + default: return [NSString stringWithFormat:@"Phase-%ld", (long)phase]; + } +} + +static UIViewController *BrowserFindViewControllerOfClass(UIViewController *viewController, Class targetClass) { + if (viewController == nil || targetClass == Nil) { + return nil; + } + + if ([viewController isKindOfClass:targetClass]) { + return viewController; + } + + if (viewController.presentedViewController != nil) { + UIViewController *match = BrowserFindViewControllerOfClass(viewController.presentedViewController, targetClass); + if (match != nil) { + return match; + } + } + + for (UIViewController *childViewController in viewController.childViewControllers) { + UIViewController *match = BrowserFindViewControllerOfClass(childViewController, targetClass); + if (match != nil) { + return match; + } + } + + if ([viewController isKindOfClass:[UINavigationController class]]) { + UINavigationController *navigationController = (UINavigationController *)viewController; + UIViewController *match = BrowserFindViewControllerOfClass(navigationController.visibleViewController, targetClass); + if (match != nil) { + return match; + } + } + + if ([viewController isKindOfClass:[UITabBarController class]]) { + UITabBarController *tabBarController = (UITabBarController *)viewController; + UIViewController *match = BrowserFindViewControllerOfClass(tabBarController.selectedViewController, targetClass); + if (match != nil) { + return match; + } + } + + return nil; +} + +static UIViewController *BrowserFindPresentedNativeVideoPlayerViewController(UIApplication *application, Class nativeVideoPlayerClass) { + for (UIWindow *window in application.windows) { + if (window.hidden || window.rootViewController == nil) { + continue; + } + + UIViewController *match = BrowserFindViewControllerOfClass(window.rootViewController, nativeVideoPlayerClass); + if (match != nil) { + return match; + } + } + return nil; +} + +@interface UIApplication (BrowserSelectPressForwarding) + +- (void)browser_sendEvent:(UIEvent *)event; + +@end + +@implementation UIApplication (BrowserSelectPressForwarding) + ++ (void)load { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Method originalMethod = class_getInstanceMethod(self, @selector(sendEvent:)); + Method replacementMethod = class_getInstanceMethod(self, @selector(browser_sendEvent:)); + if (originalMethod != NULL && replacementMethod != NULL) { + method_exchangeImplementations(originalMethod, replacementMethod); + } + }); +} + +- (void)browser_sendEvent:(UIEvent *)event { + Class nativeVideoPlayerClass = NSClassFromString(@"BrowserNativeVideoPlayerViewController"); + UIViewController *nativeVideoPlayerViewController = BrowserFindPresentedNativeVideoPlayerViewController(self, nativeVideoPlayerClass); + + if (event.type == UIEventTypeTouches) { + SEL allTouchesSelector = NSSelectorFromString(@"allTouches"); + if ([event respondsToSelector:allTouchesSelector]) { + NSSet *touches = ((id (*)(id, SEL))objc_msgSend)(event, allTouchesSelector); + for (UITouch *touch in touches) { + if (touch.type != UITouchTypeIndirect) { + continue; + } + + CGPoint location = [touch locationInView:nil]; + if (touch.phase == UITouchPhaseBegan) { + sBrowserNativeScrubTracking = (nativeVideoPlayerClass != Nil && nativeVideoPlayerViewController != nil); + sBrowserNativePendingScrubPixels = 0.0; + sBrowserNativeLastTouchLocation = location; + continue; + } + + if (!sBrowserNativeScrubTracking || nativeVideoPlayerViewController == nil) { + continue; + } + + if (touch.phase == UITouchPhaseMoved) { + CGFloat deltaX = location.x - sBrowserNativeLastTouchLocation.x; + sBrowserNativeLastTouchLocation = location; + sBrowserNativePendingScrubPixels += deltaX; + + SEL scrubSelector = NSSelectorFromString(@"scrubByHorizontalDelta:"); + if ([nativeVideoPlayerViewController respondsToSelector:scrubSelector]) { + while (fabs(sBrowserNativePendingScrubPixels) >= kBrowserNativeScrubPixelStep) { + CGFloat step = sBrowserNativePendingScrubPixels > 0.0 ? kBrowserNativeScrubPixelStep : -kBrowserNativeScrubPixelStep; + ((void (*)(id, SEL, CGFloat))objc_msgSend)(nativeVideoPlayerViewController, scrubSelector, step); + sBrowserNativePendingScrubPixels -= step; + NSLog(@"[InputTrace][App] scrub step delta=%.2f", step); + } + } + } + + if (touch.phase == UITouchPhaseEnded || touch.phase == UITouchPhaseCancelled) { + sBrowserNativeScrubTracking = NO; + sBrowserNativePendingScrubPixels = 0.0; + } + } + } + + [self browser_sendEvent:event]; + return; + } + + if (event.type != UIEventTypePresses) { + [self browser_sendEvent:event]; + return; + } + + SEL allPressesSelector = NSSelectorFromString(@"allPresses"); + if (![event respondsToSelector:allPressesSelector]) { + [self browser_sendEvent:event]; + return; + } + + NSSet *presses = ((id (*)(id, SEL))objc_msgSend)(event, allPressesSelector); + for (UIPress *press in presses) { + nativeVideoPlayerViewController = BrowserFindPresentedNativeVideoPlayerViewController(self, nativeVideoPlayerClass); + if (press.type == UIPressTypeMenu || press.type == UIPressTypePlayPause || press.type == UIPressTypeSelect) { + NSLog(@"[InputTrace][App] press=%@ phase=%@ top=%@", + BrowserPressTypeString(press.type), + BrowserPressPhaseString(press.phase), + nativeVideoPlayerViewController == nil ? @"(nil)" : NSStringFromClass([nativeVideoPlayerViewController class])); + } + + if (press.type == UIPressTypeMenu && press.phase == UIPressPhaseBegan) { + if (nativeVideoPlayerClass != Nil && nativeVideoPlayerViewController != nil) { + NSLog(@"[InputTrace][App] swallow Menu for native player"); + dispatch_async(dispatch_get_main_queue(), ^{ + [nativeVideoPlayerViewController dismissViewControllerAnimated:YES completion:nil]; + }); + return; + } + } + + if (press.type == UIPressTypePlayPause && press.phase == UIPressPhaseEnded) { + if (nativeVideoPlayerClass != Nil && nativeVideoPlayerViewController != nil) { + SEL togglePlaybackSelector = NSSelectorFromString(@"togglePlayback"); + if ([nativeVideoPlayerViewController respondsToSelector:togglePlaybackSelector]) { + NSLog(@"[InputTrace][App] swallow PlayPause for native player"); + dispatch_async(dispatch_get_main_queue(), ^{ + ((void (*)(id, SEL))objc_msgSend)(nativeVideoPlayerViewController, togglePlaybackSelector); + }); + return; + } + } + } + + if (press.type == UIPressTypeSelect && press.phase == UIPressPhaseEnded) { + if (nativeVideoPlayerClass != Nil && nativeVideoPlayerViewController != nil) { + SEL togglePlaybackSelector = NSSelectorFromString(@"togglePlayback"); + if ([nativeVideoPlayerViewController respondsToSelector:togglePlaybackSelector]) { + NSLog(@"[InputTrace][App] swallow Select for native player"); + dispatch_async(dispatch_get_main_queue(), ^{ + ((void (*)(id, SEL))objc_msgSend)(nativeVideoPlayerViewController, togglePlaybackSelector); + }); + return; + } + } + } + + if ((press.type == UIPressTypeLeftArrow || press.type == UIPressTypeRightArrow) && press.phase == UIPressPhaseEnded) { + if (nativeVideoPlayerClass != Nil && nativeVideoPlayerViewController != nil) { + SEL skipSelector = NSSelectorFromString(@"skipByInterval:"); + if ([nativeVideoPlayerViewController respondsToSelector:skipSelector]) { + CFTimeInterval now = CACurrentMediaTime(); + BOOL isDoubleTap = (sBrowserNativeLastArrowPressType == press.type) && + ((now - sBrowserNativeLastArrowPressTimestamp) <= kBrowserNativeArrowDoubleTapInterval); + sBrowserNativeLastArrowPressType = press.type; + sBrowserNativeLastArrowPressTimestamp = now; + + if (isDoubleTap) { + NSTimeInterval delta = (press.type == UIPressTypeRightArrow) ? 5.0 : -5.0; + NSLog(@"[InputTrace][App] swallow %@ double tap for native player (delta=%0.1f)", + BrowserPressTypeString(press.type), delta); + dispatch_async(dispatch_get_main_queue(), ^{ + ((void (*)(id, SEL, NSTimeInterval))objc_msgSend)(nativeVideoPlayerViewController, skipSelector, delta); + }); + sBrowserNativeLastArrowPressType = (UIPressType)-1; + sBrowserNativeLastArrowPressTimestamp = 0.0; + } else { + NSLog(@"[InputTrace][App] swallow %@ single tap for native player (waiting for double tap)", + BrowserPressTypeString(press.type)); + } + return; + } + } + } + } + + [self browser_sendEvent:event]; + + for (UIPress *press in presses) { + if (press.type == UIPressTypeSelect && press.phase == UIPressPhaseEnded) { + NSLog(@"[InputTrace][App] post BrowserGlobalSelectPressEndedNotification"); + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:BrowserGlobalSelectPressEndedNotification object:nil]; + }); + break; + } + } +} + +@end diff --git a/_Project/Browser/ViewController.h b/_Project/Browser/ViewController.h new file mode 100644 index 0000000..08cb9b1 --- /dev/null +++ b/_Project/Browser/ViewController.h @@ -0,0 +1,20 @@ +// +// ViewController.h +// Browser +// +// Created by Steven Troughton-Smith on 20/09/2015. +// Improved by Jip van Akker on 14/10/2015 through 10/01/2019 +// + +#import +#import + +#import "BrowserWebView.h" +#import "BrowserTopBarView.h" + +@interface ViewController : GCEventViewController + +@property (nonatomic, retain) IBOutlet BrowserTopBarView *topMenuView; +@property (nonatomic, retain) IBOutlet UIView *browserContainerView; + +@end diff --git a/_Project/Browser/ViewController.m b/_Project/Browser/ViewController.m new file mode 100644 index 0000000..9bdb1a0 --- /dev/null +++ b/_Project/Browser/ViewController.m @@ -0,0 +1,847 @@ +// +// ViewController.m +// Browser +// +// Created by Steven Troughton-Smith on 20/09/2015. +// Improved by Jip van Akker on 14/10/2015 through 10/01/2019 +// + +#import "BrowserMenuCoordinator.h" +#import "BrowserDOMInteractionService.h" +#import "BrowserNavigationService.h" +#import "BrowserPageActionCoordinator.h" +#import "BrowserPreferencesStore.h" +#import "BrowserRemoteInputController.h" +#import "BrowserSessionStore.h" +#import "BrowserTabViewModel.h" +#import "BrowserTabCoordinator.h" +#import "BrowserTabOverviewController.h" +#import "BrowserVideoPlaybackCoordinator.h" +#import "BrowserViewModel.h" +#import "ViewController.h" + +static NSString * const kBrowserGlobalSelectPressEndedNotification = @"BrowserGlobalSelectPressEndedNotification"; + +static UIColor *kTextColor(void) { + if (@available(tvOS 13, *)) { + return UIColor.labelColor; + } else { + return UIColor.blackColor; + } +} + +@interface ViewController () + +@property (nonatomic) BrowserDOMInteractionService *domInteractionService; +@property (nonatomic) BrowserMenuCoordinator *menuCoordinator; +@property (nonatomic) BrowserNavigationService *navigationService; +@property (nonatomic) BrowserPageActionCoordinator *pageActionCoordinator; +@property (nonatomic) BrowserPreferencesStore *preferencesStore; +@property (nonatomic) BrowserRemoteInputController *remoteInputController; +@property (nonatomic) BrowserSessionStore *sessionStore; +@property (nonatomic) BrowserTabCoordinator *tabCoordinator; +@property (nonatomic) BrowserTabOverviewController *tabOverviewController; +@property (nonatomic) BrowserVideoPlaybackCoordinator *videoPlaybackCoordinator; +@property (nonatomic) BrowserViewModel *viewModel; +@property (nonatomic) BOOL displayedHintsOnLaunch; +@property (nonatomic) BOOL scrollViewAllowBounces; +@property (nonatomic, getter=isTopBarFocusActive) BOOL topBarFocusActive; + +@end + +@implementation ViewController + +#pragma mark - Lifecycle + +- (void)viewDidLoad { + [super viewDidLoad]; + self.definesPresentationContext = YES; + self.scrollViewAllowBounces = YES; + + self.preferencesStore = [BrowserPreferencesStore new]; + [self.preferencesStore ensureUserAgentConsistency]; + + self.viewModel = [BrowserViewModel new]; + self.viewModel.topNavigationBarVisible = self.preferencesStore.topNavigationBarVisible; + self.viewModel.textFontSize = self.preferencesStore.textFontSize; + self.viewModel.fullscreenVideoPlaybackEnabled = self.preferencesStore.fullscreenVideoPlaybackEnabled; + + self.domInteractionService = [BrowserDOMInteractionService new]; + self.navigationService = [[BrowserNavigationService alloc] initWithPreferencesStore:self.preferencesStore]; + self.sessionStore = [BrowserSessionStore new]; + self.menuCoordinator = [[BrowserMenuCoordinator alloc] initWithHost:self preferencesStore:self.preferencesStore]; + self.remoteInputController = [[BrowserRemoteInputController alloc] initWithHost:self rootView:self.view]; + [self.view addSubview:self.remoteInputController.cursorView]; + self.videoPlaybackCoordinator = [[BrowserVideoPlaybackCoordinator alloc] initWithHost:self + domInteractionService:self.domInteractionService]; + self.tabCoordinator = [[BrowserTabCoordinator alloc] initWithHost:self + viewModel:self.viewModel + preferencesStore:self.preferencesStore + navigationService:self.navigationService + sessionStore:self.sessionStore + browserContainerView:self.browserContainerView + rootView:self.view + topMenuView:self.topMenuView + cursorView:self.remoteInputController.cursorView + manualScrollPanRecognizer:self.remoteInputController.manualScrollPanRecognizer + webViewDelegate:self + scrollViewAllowBounces:self.scrollViewAllowBounces]; + self.tabOverviewController = [[BrowserTabOverviewController alloc] initWithHost:self + viewModel:self.viewModel + rootView:self.view + topMenuView:self.topMenuView + cursorView:self.remoteInputController.cursorView]; + self.pageActionCoordinator = [[BrowserPageActionCoordinator alloc] initWithHost:self + domInteractionService:self.domInteractionService + navigationService:self.navigationService + videoPlaybackCoordinator:self.videoPlaybackCoordinator]; + + self.topMenuView.delegate = self; + self.topMenuView.loadingSpinner.hidesWhenStopped = YES; + self.remoteInputController.cursorView.hidden = NO; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleApplicationWillResignActive:) + name:UIApplicationWillResignActiveNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleApplicationDidEnterBackground:) + name:UIApplicationDidEnterBackgroundNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleApplicationWillTerminate:) + name:UIApplicationWillTerminateNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleGlobalSelectPressEndedNotification:) + name:kBrowserGlobalSelectPressEndedNotification + object:nil]; + + [self.tabCoordinator restoreInitialStateOrCreateFirstTab]; +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + [self.tabCoordinator webViewDidAppear]; + if (!self.preferencesStore.dontShowHintsOnLaunch && !self.displayedHintsOnLaunch) { + [self showHintsAlert]; + } + self.displayedHintsOnLaunch = YES; +} + +- (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +#pragma mark - Notifications + +- (void)handleApplicationWillResignActive:(NSNotification *)notification { + (void)notification; + [self.tabCoordinator persistSession]; +} + +- (void)handleApplicationDidEnterBackground:(NSNotification *)notification { + (void)notification; + [self.tabCoordinator persistSession]; +} + +- (void)handleApplicationWillTerminate:(NSNotification *)notification { + (void)notification; + [self.tabCoordinator persistSession]; +} + +- (void)handleGlobalSelectPressEndedNotification:(NSNotification *)notification { + (void)notification; + [self.remoteInputController handleGlobalSelectPressEndedNotification]; +} + +#pragma mark - Helpers + +- (BrowserWebView *)webview { + return self.tabCoordinator.activeWebView; +} + +- (CGPoint)browserDOMPointForCursor { + return [self.domInteractionService DOMPointForCursorOrigin:self.remoteInputController.cursorView.frame.origin + inView:self.view + webView:self.webview]; +} + +- (void)loadHomePage { + [self.tabCoordinator loadHomePage]; +} + +- (void)showAdvancedMenu { + [self deactivateTopBarFocusMode]; + [self.menuCoordinator showAdvancedMenu]; +} + +- (BOOL)canActivateTopBarFocusMode { + return self.presentedViewController == nil && + !self.tabOverviewController.visible && + self.viewModel.topNavigationBarVisible && + !self.topMenuView.hidden; +} + +- (void)activateTopBarFocusMode { + if (![self canActivateTopBarFocusMode]) { + return; + } + if (self.topBarFocusActive) { + return; + } + + self.topBarFocusActive = YES; + [self.topMenuView setFocusModeActive:YES]; + [self.remoteInputController refreshInteractionState]; + [self setNeedsFocusUpdate]; + [self updateFocusIfNeeded]; +} + +- (void)deactivateTopBarFocusMode { + if (!self.topBarFocusActive) { + return; + } + + self.topBarFocusActive = NO; + [self.topMenuView setFocusModeActive:NO]; + [self.remoteInputController refreshInteractionState]; + [self setNeedsFocusUpdate]; + [self updateFocusIfNeeded]; +} + +- (void)performTopBarAction:(BrowserTopBarAction)action { + [self deactivateTopBarFocusMode]; + + switch (action) { + case BrowserTopBarActionBack: + if (self.webview.canGoBack) { + [self.webview goBack]; + } + break; + case BrowserTopBarActionRefresh: + [self.webview reload]; + break; + case BrowserTopBarActionForward: + if (self.webview.canGoForward) { + [self.webview goForward]; + } + break; + case BrowserTopBarActionHome: + [self loadHomePage]; + break; + case BrowserTopBarActionTabs: + [self browserShowTabOverview]; + break; + case BrowserTopBarActionURL: + [self showInputURLorSearchGoogle]; + break; + case BrowserTopBarActionFullscreen: + if (self.viewModel.topNavigationBarVisible) { + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Hide Top Navigation bar?" + message:@"You can still open the side menu by double-tapping the Play/Pause button." + preferredStyle:UIAlertControllerStyleAlert]; + [alertController addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [alertController addAction:[UIAlertAction actionWithTitle:@"Hide Bar" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction *action) { + [self browserHideTopNav]; + }]]; + [self browserPresentViewController:alertController]; + } else { + [self browserShowTopNav]; + } + break; + case BrowserTopBarActionMenu: + [self showAdvancedMenu]; + break; + } +} + +- (void)updateTextFontSize { + if (self.webview == nil) { + return; + } + + NSString *jsString = [[NSString alloc] initWithFormat: + @"(function(){" + "var value='%lu%%';" + "var multiplier=%lu/100;" + "if (document.documentElement && document.documentElement.style) {" + "document.documentElement.style.setProperty('-webkit-text-size-adjust', value, 'important');" + "document.documentElement.style.setProperty('text-size-adjust', value, 'important');" + "}" + "if (document.body && document.body.style) {" + "document.body.style.setProperty('-webkit-text-size-adjust', value, 'important');" + "document.body.style.setProperty('text-size-adjust', value, 'important');" + "}" + "if (!document.body || !window.getComputedStyle) { return value; }" + "var elements = document.querySelectorAll('body, body *');" + "for (var i = 0; i < elements.length; i++) {" + "var element = elements[i];" + "if (!element || !element.tagName) { continue; }" + "var tagName = element.tagName.toLowerCase();" + "if (tagName === 'script' || tagName === 'style' || tagName === 'noscript') { continue; }" + "var originalSize = element.getAttribute('data-browser-original-font-size');" + "if (!originalSize) {" + "var computedSize = window.getComputedStyle(element).fontSize || '';" + "if (computedSize.indexOf('px') == -1) { continue; }" + "var parsedSize = parseFloat(computedSize);" + "if (!isFinite(parsedSize) || parsedSize <= 0) { continue; }" + "originalSize = String(parsedSize);" + "element.setAttribute('data-browser-original-font-size', originalSize);" + "}" + "var baseSize = parseFloat(originalSize);" + "if (!isFinite(baseSize) || baseSize <= 0) { continue; }" + "element.style.setProperty('font-size', (baseSize * multiplier) + 'px', 'important');" + "}" + "return value;" + "})()", + (unsigned long)self.viewModel.textFontSize, + (unsigned long)self.viewModel.textFontSize]; + [self.webview stringByEvaluatingJavaScriptFromString:jsString]; +} + +- (void)showInputURLorSearchGoogle { + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Enter URL or Search Terms" + message:@"" + preferredStyle:UIAlertControllerStyleAlert]; + + [alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) { + textField.keyboardType = UIKeyboardTypeURL; + textField.placeholder = @"Enter URL or Search Terms"; + textField.textColor = kTextColor(); + [textField setReturnKeyType:UIReturnKeyDone]; + }]; + + __weak typeof(self) weakSelf = self; + [alertController addAction:[UIAlertAction actionWithTitle:@"Search Google" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + UITextField *textField = alertController.textFields.firstObject; + NSURLRequest *searchRequest = [weakSelf.navigationService googleSearchRequestForQuery:textField.text]; + if (searchRequest != nil) { + [weakSelf.webview loadRequest:searchRequest]; + } else { + [weakSelf requestURLorSearchInput]; + } + }]]; + [alertController addAction:[UIAlertAction actionWithTitle:@"Go To Website" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + UITextField *textField = alertController.textFields.firstObject; + if (textField.text.length == 0) { + [weakSelf requestURLorSearchInput]; + return; + } + NSURLRequest *navigationRequest = [weakSelf.navigationService requestForEnteredAddressString:textField.text]; + if (navigationRequest != nil) { + [weakSelf.webview loadRequest:navigationRequest]; + } else { + [weakSelf requestURLorSearchInput]; + } + }]]; + [alertController addAction:[UIAlertAction actionWithTitle:nil style:UIAlertActionStyleCancel handler:nil]]; + [self presentViewController:alertController animated:YES completion:nil]; + + UITextField *textField = alertController.textFields.firstObject; + if (self.webview.request == nil || self.webview.request.URL.absoluteString.length > 0) { + [textField becomeFirstResponder]; + } +} + +- (void)requestURLorSearchInput { + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Quick Menu" + message:@"" + preferredStyle:UIAlertControllerStyleAlert]; + + if (self.webview.canGoForward) { + [alertController addAction:[UIAlertAction actionWithTitle:@"Go Forward" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + [self.webview goForward]; + }]]; + } + + [alertController addAction:[UIAlertAction actionWithTitle:@"Input URL or Search with Google" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + [self showInputURLorSearchGoogle]; + }]]; + + if (self.webview.request != nil && self.webview.request.URL.absoluteString.length > 0) { + [alertController addAction:[UIAlertAction actionWithTitle:@"Reload Page" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + self.tabCoordinator.previousURL = @""; + [self.webview reload]; + }]]; + } + + [alertController addAction:[UIAlertAction actionWithTitle:nil style:UIAlertActionStyleCancel handler:nil]]; + [self presentViewController:alertController animated:YES completion:nil]; +} + +- (void)showHintsAlert { + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Usage Guide" + message:@"Double press the touch area to switch between cursor & scroll mode.\nPress the touch area while in cursor mode to click.\nSingle tap to Menu button to Go Back, or Exit on root page.\nSingle tap the Play/Pause button to: Go Forward, Enter URL or Reload Page.\nDouble tap the Play/Pause to show the Advanced Menu with more options.\nUse the tabs icon in the top bar to open the tab overview." + preferredStyle:UIAlertControllerStyleAlert]; + + __weak typeof(self) weakSelf = self; + if (self.preferencesStore.dontShowHintsOnLaunch) { + [alertController addAction:[UIAlertAction actionWithTitle:@"Always Show On Launch" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction *action) { + weakSelf.preferencesStore.dontShowHintsOnLaunch = NO; + }]]; + } else { + [alertController addAction:[UIAlertAction actionWithTitle:@"Don't Show This Again" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction *action) { + weakSelf.preferencesStore.dontShowHintsOnLaunch = YES; + }]]; + } + [alertController addAction:[UIAlertAction actionWithTitle:@"Dismiss" + style:UIAlertActionStyleCancel + handler:nil]]; + [self presentViewController:alertController animated:YES completion:nil]; +} + +- (void)browserHandlePrimaryAction { + if (!self.remoteInputController.cursorModeEnabled || self.webview == nil) { + return; + } + + CGPoint point = [self.view convertPoint:self.remoteInputController.cursorView.frame.origin toView:self.webview]; + if (point.y < 0) { + [self activateTopBarFocusMode]; + return; + } + + CGPoint domPoint = [self browserDOMPointForCursor]; + [self.pageActionCoordinator handlePageSelectionAtDOMPoint:domPoint webView:self.webview]; +} + +- (NSArray> *)preferredFocusEnvironments { + if (self.topBarFocusActive) { + UIView *preferredFocusItem = [self.topMenuView preferredFocusItem]; + if (preferredFocusItem != nil) { + return @[preferredFocusItem]; + } + } + return [super preferredFocusEnvironments]; +} + +#pragma mark - BrowserTopBarViewDelegate + +- (void)browserTopBarView:(__unused BrowserTopBarView *)topBarView didTriggerAction:(BrowserTopBarAction)action { + [self performTopBarAction:action]; +} + +#pragma mark - BrowserMenuCoordinatorHost + +- (BrowserWebView *)browserWebView { + return self.webview; +} + +- (NSString *)browserPreviousURL { + return self.tabCoordinator.previousURL; +} + +- (void)setBrowserPreviousURL:(NSString *)browserPreviousURL { + self.tabCoordinator.previousURL = browserPreviousURL ?: @""; +} + +- (NSUInteger)browserTextFontSize { + return self.viewModel.textFontSize; +} + +- (void)setBrowserTextFontSize:(NSUInteger)browserTextFontSize { + self.viewModel.textFontSize = browserTextFontSize; + self.preferencesStore.textFontSize = self.viewModel.textFontSize; +} + +- (BOOL)browserTopMenuShowing { + return self.viewModel.topNavigationBarVisible; +} + +- (BOOL)browserFullscreenVideoPlaybackEnabled { + return self.viewModel.fullscreenVideoPlaybackEnabled; +} + +- (void)setBrowserFullscreenVideoPlaybackEnabled:(BOOL)browserFullscreenVideoPlaybackEnabled { + self.viewModel.fullscreenVideoPlaybackEnabled = browserFullscreenVideoPlaybackEnabled; + self.preferencesStore.fullscreenVideoPlaybackEnabled = browserFullscreenVideoPlaybackEnabled; +} + +- (void)browserPresentViewController:(UIViewController *)viewController { + [self deactivateTopBarFocusMode]; + [self presentViewController:viewController animated:YES completion:nil]; +} + +- (void)browserLoadHomePage { + [self loadHomePage]; +} + +- (void)browserShowHints { + [self showHintsAlert]; +} + +- (void)browserShowTabOverview { + [self deactivateTopBarFocusMode]; + [self.tabCoordinator prepareTabOverviewThumbnails]; + [self.tabOverviewController show]; +} + +- (void)browserCreateNewTabLoadingHomePage:(BOOL)loadHomePage { + [self.tabCoordinator createNewTabLoadingHomePage:loadHomePage]; +} + +- (void)browserHideTopNav { + [self deactivateTopBarFocusMode]; + self.viewModel.topNavigationBarVisible = NO; + self.preferencesStore.topNavigationBarVisible = NO; + [self.tabCoordinator setTopNavigationVisible:NO]; +} + +- (void)browserShowTopNav { + self.viewModel.topNavigationBarVisible = YES; + self.preferencesStore.topNavigationBarVisible = YES; + [self.tabCoordinator setTopNavigationVisible:YES]; +} + +- (void)browserUpdateTextFontSize { + [self updateTextFontSize]; +} + +- (void)browserCaptureSnapshotForCurrentTab { + [self.tabCoordinator captureSnapshotForCurrentTab]; +} + +- (void)browserRecreateActiveWebViewPreservingCurrentURL { + [self.tabCoordinator recreateActiveWebViewPreservingCurrentURL]; +} + +- (void)browserBringCursorToFront { + [self.view bringSubviewToFront:self.remoteInputController.cursorView]; +} + +- (void)browserPlayVideoUnderCursorIfAvailable { + [self.videoPlaybackCoordinator playVideoUnderCursorIfAvailable]; +} + +#pragma mark - BrowserVideoPlaybackCoordinatorHost + +- (BOOL)browserIsCursorModeEnabled { + return self.remoteInputController.cursorModeEnabled; +} + +- (CGPoint)browserDOMCursorPoint { + return [self browserDOMPointForCursor]; +} + +- (UIViewController *)browserPresentedViewController { + return self.presentedViewController; +} + +- (NSString *)browserCurrentPageTitle { + return self.webview.title; +} + +#pragma mark - BrowserTabCoordinatorHost + +- (void)browserTabCoordinatorPresentViewController:(UIViewController *)viewController { + [self browserPresentViewController:viewController]; +} + +- (void)browserTabCoordinatorUpdateTextFontSize { + [self updateTextFontSize]; +} + +- (BOOL)browserTabCoordinatorIsCursorModeEnabled { + return self.remoteInputController.cursorModeEnabled; +} + +- (BOOL)browserTabCoordinatorIsTabOverviewVisible { + return self.tabOverviewController.visible; +} + +#pragma mark - BrowserTabOverviewControllerHost + +- (BOOL)browserTabOverviewControllerCursorModeEnabled { + return self.remoteInputController.cursorModeEnabled; +} + +- (void)browserTabOverviewControllerSetCursorModeEnabled:(BOOL)enabled { + [self.remoteInputController setCursorModeEnabled:enabled]; +} + +- (void)browserTabOverviewControllerPresentViewController:(UIViewController *)viewController { + [self presentViewController:viewController animated:YES completion:nil]; +} + +- (void)browserTabOverviewControllerCreateNewTabLoadingHomePage:(BOOL)loadHomePage { + [self.tabCoordinator createNewTabLoadingHomePage:loadHomePage]; +} + +- (void)browserTabOverviewControllerSwitchToTabAtIndex:(NSInteger)tabIndex { + [self.tabCoordinator switchToTabAtIndex:tabIndex]; +} + +- (void)browserTabOverviewControllerCloseTabAtIndex:(NSInteger)tabIndex { + [self.tabCoordinator closeTabAtIndex:tabIndex]; +} + +#pragma mark - BrowserPageActionCoordinatorHost + +- (void)browserPageActionCoordinatorPresentViewController:(UIViewController *)viewController { + [self browserPresentViewController:viewController]; +} + +- (BOOL)browserPageActionCoordinatorCreateNewTabWithRequest:(NSURLRequest *)request { + return [self.tabCoordinator createNewTabWithRequest:request]; +} + +#pragma mark - BrowserRemoteInputControllerHost + +- (UIScrollView *)browserRemoteInputControllerActiveScrollView { + return self.webview.scrollView; +} + +- (UIViewController *)browserRemoteInputControllerPresentedViewController { + return self.presentedViewController; +} + +- (BOOL)browserRemoteInputControllerTopBarFocusActive { + return self.topBarFocusActive; +} + +- (BOOL)browserRemoteInputControllerCanActivateTopBarFocus { + return [self canActivateTopBarFocusMode]; +} + +- (void)browserRemoteInputControllerActivateTopBarFocus { + [self activateTopBarFocusMode]; +} + +- (void)browserRemoteInputControllerDeactivateTopBarFocus { + [self deactivateTopBarFocusMode]; +} + +- (BOOL)browserRemoteInputControllerTabOverviewVisible { + return self.tabOverviewController.visible; +} + +- (BOOL)browserRemoteInputControllerTabOverviewContainsPoint:(CGPoint)point { + return [self.tabOverviewController containsPoint:point]; +} + +- (BOOL)browserRemoteInputControllerHandleTabOverviewSelectionAtPoint:(CGPoint)point { + return [self.tabOverviewController handleSelectionAtPoint:point]; +} + +- (void)browserRemoteInputControllerDismissTabOverview { + [self.tabOverviewController dismiss]; +} + +- (void)browserRemoteInputControllerHandleTabOverviewAlternateAction { + [self.tabOverviewController handleAlternateAction]; +} + +- (void)browserRemoteInputControllerHandlePrimaryAction { + [self browserHandlePrimaryAction]; +} + +- (void)browserRemoteInputControllerHandleMenuPress { + UIAlertController *alertController = (UIAlertController *)self.presentedViewController; + if (alertController != nil) { + [self.presentedViewController dismissViewControllerAnimated:YES completion:nil]; + } else if (self.webview.canGoBack) { + [self.webview goBack]; + } else { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Exit App?" + message:nil + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Exit" + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction *action) { + exit(EXIT_SUCCESS); + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"Dismiss" style:UIAlertActionStyleCancel handler:nil]]; + [self presentViewController:alert animated:YES completion:nil]; + } +} + +- (void)browserRemoteInputControllerHandlePlayPausePress { + UIAlertController *alertController = (UIAlertController *)self.presentedViewController; + if (alertController != nil) { + [self.presentedViewController dismissViewControllerAnimated:YES completion:nil]; + } else { + [self requestURLorSearchInput]; + } +} + +- (void)browserRemoteInputControllerHandleAdvancedMenuPress { + [self showAdvancedMenu]; +} + +- (NSString *)browserRemoteInputControllerHoverStateAtCursorPoint:(CGPoint)point { + if (self.webview.request == nil) { + return @"false"; + } + CGPoint webPoint = [self.view convertPoint:point toView:self.webview]; + if (webPoint.y < 0) { + return @"false"; + } + CGPoint domPoint = [self browserDOMPointForCursor]; + return [self.pageActionCoordinator hoverStateAtDOMPoint:domPoint webView:self.webview]; +} + +- (void)browserRemoteInputControllerSetWebInteractionEnabled:(BOOL)enabled { + self.webview.userInteractionEnabled = enabled; +} + +- (void)browserRemoteInputControllerPersistSession { + [self.tabCoordinator persistSession]; +} + +#pragma mark - BrowserWebViewDelegate + +- (BOOL)webView:(id)webView shouldCreateNewTabWithRequest:(NSURLRequest *)request navigationType:(NSInteger)navigationType { + (void)webView; + (void)navigationType; + return [self.tabCoordinator createNewTabWithRequest:request]; +} + +- (BOOL)webView:(id)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(NSInteger)navigationType { + (void)navigationType; + [self.tabCoordinator prepareTabForRequest:request webView:webView]; + return YES; +} + +- (void)webViewDidStartLoad:(id)webView { + [self.tabCoordinator webViewDidStartLoad:webView]; +} + +- (void)webViewDidFinishLoad:(id)webView { + [self.tabCoordinator webViewDidFinishLoad:webView]; + if (self.tabOverviewController.visible) { + BrowserTabViewModel *tab = [self.tabCoordinator tabForWebView:webView]; + NSInteger tabIndex = tab != nil ? [self.viewModel.tabs indexOfObject:tab] : NSNotFound; + dispatch_async(dispatch_get_main_queue(), ^{ + if (!self.tabOverviewController.visible) { + return; + } + if (tabIndex != NSNotFound) { + [self.tabOverviewController updateCardAtIndex:tabIndex]; + } else { + [self.tabOverviewController reload]; + } + }); + } +} + +- (void)webView:(id)webView didFailLoadWithError:(NSError *)error { + BrowserTabViewModel *tab = [self.tabCoordinator tabForWebView:webView]; + if (tab == nil) { + return; + } + + NSURL *failingURL = error.userInfo[NSURLErrorFailingURLErrorKey]; + NSURLRequest *currentRequest = [webView request]; + NSString *currentRequestURLString = currentRequest.URL.absoluteString ?: @""; + if (failingURL != nil && + currentRequestURLString.length > 0 && + ![failingURL.absoluteString isEqualToString:currentRequestURLString]) { + return; + } + + if (tab == self.tabCoordinator.activeTab) { + [self.topMenuView.loadingSpinner stopAnimating]; + } + if (tab != self.tabCoordinator.activeTab) { + return; + } + if ([self.navigationService shouldIgnoreLoadError:error]) { + return; + } + + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Could Not Load Webpage" + message:error.localizedDescription + preferredStyle:UIAlertControllerStyleAlert]; + __weak typeof(self) weakSelf = self; + if (tab.requestURL.length > 1) { + [alertController addAction:[UIAlertAction actionWithTitle:@"Google This Page" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + NSURLRequest *searchRequest = [weakSelf.navigationService googleSearchRequestForFailedRequestURLString:tab.requestURL]; + if (searchRequest != nil) { + [weakSelf.webview loadRequest:searchRequest]; + } + }]]; + } + if (self.webview.request != nil && self.webview.request.URL.absoluteString.length > 0) { + [alertController addAction:[UIAlertAction actionWithTitle:@"Reload Page" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + weakSelf.tabCoordinator.previousURL = @""; + [weakSelf.webview reload]; + }]]; + } else { + [alertController addAction:[UIAlertAction actionWithTitle:@"Enter a URL or Search" + style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *action) { + [weakSelf requestURLorSearchInput]; + }]]; + } + [alertController addAction:[UIAlertAction actionWithTitle:nil style:UIAlertActionStyleCancel handler:nil]]; + [self presentViewController:alertController animated:YES completion:nil]; +} + +#pragma mark - Presses / Touches + +- (void)pressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)event { + [self.remoteInputController handlePressesBegan:presses withEvent:event]; + [super pressesBegan:presses withEvent:event]; +} + +- (void)pressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event { + if ([self.remoteInputController handlePressesEnded:presses withEvent:event]) { + return; + } + [super pressesEnded:presses withEvent:event]; +} + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + if ([self.remoteInputController handleTouchesBegan:touches withEvent:event]) { + return; + } + [super touchesBegan:touches withEvent:event]; +} + +- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { + if ([self.remoteInputController handleTouchesMoved:touches withEvent:event]) { + return; + } + [super touchesMoved:touches withEvent:event]; +} + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { + (void)touches; + (void)event; + [self.remoteInputController handleTouchesEnded]; + [super touchesEnded:touches withEvent:event]; +} + +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { + (void)touches; + (void)event; + [self.remoteInputController handleTouchesEnded]; + [super touchesCancelled:touches withEvent:event]; +} + +@end diff --git a/_Project/Browser/WebAVPlayerViewController+FullscreenSubviewHack.m b/_Project/Browser/WebAVPlayerViewController+FullscreenSubviewHack.m new file mode 100644 index 0000000..0d6656d --- /dev/null +++ b/_Project/Browser/WebAVPlayerViewController+FullscreenSubviewHack.m @@ -0,0 +1,1150 @@ +#import +#import +#import +#import +#import + +static BOOL const kBrowserFullscreenHackEnabled = NO; + + +static void (*BrowserOriginalConfigurePlayerViewController)(id self, SEL _cmd, void *fullscreenInterface) = NULL; +static const ptrdiff_t kBrowserPlayerControllerHostOffset = 0x20; +static const ptrdiff_t kBrowserFullscreenInterfacePlayerLayerViewOffset = 0x58; +static const void *kBrowserFullscreenHackAssociatedViewsKey = &kBrowserFullscreenHackAssociatedViewsKey; +static BOOL const kBrowserFullscreenHackLoggingEnabled = YES; +static BOOL const kBrowserFullscreenHackMethodDumpEnabled = YES; + +#define BrowserFullscreenHackLog(fmt, ...) \ + do { \ + if (kBrowserFullscreenHackLoggingEnabled) { \ + NSLog((@"[FullscreenHack] " fmt), ##__VA_ARGS__); \ + } \ + } while (0) + +static BOOL BrowserFullscreenHackSelectorLooksInteresting(SEL selector) { + NSString *name = NSStringFromSelector(selector).lowercaseString; + NSArray *needles = @[ + @"player", + @"video", + @"display", + @"visible", + @"render", + @"attach", + @"ready", + @"layer", + @"controller" + ]; + + for (NSString *needle in needles) { + if ([name containsString:needle]) { + return YES; + } + } + + return NO; +} + +static void BrowserFullscreenHackDumpMethodsForClass(Class cls) { + if (!kBrowserFullscreenHackMethodDumpEnabled || cls == Nil) { + return; + } + + unsigned int methodCount = 0; + Method *methods = class_copyMethodList(cls, &methodCount); + NSMutableArray *interestingNames = [NSMutableArray array]; + for (unsigned int index = 0; index < methodCount; index++) { + SEL selector = method_getName(methods[index]); + if (!BrowserFullscreenHackSelectorLooksInteresting(selector)) { + continue; + } + [interestingNames addObject:NSStringFromSelector(selector)]; + } + free(methods); + + [interestingNames sortUsingSelector:@selector(localizedCaseInsensitiveCompare:)]; + BrowserFullscreenHackLog(@"method dump for %@: %@", NSStringFromClass(cls), interestingNames); +} + +static Method BrowserFullscreenHackInstanceMethod(id object, NSString *selectorName) { + if (object == nil) { + return NULL; + } + return class_getInstanceMethod([object class], NSSelectorFromString(selectorName)); +} + +static id BrowserFullscreenHackObjectForSelectorName(id object, NSString *selectorName) { + if (object == nil || selectorName.length == 0) { + return nil; + } + + SEL selector = NSSelectorFromString(selectorName); + if (![object respondsToSelector:selector]) { + return nil; + } + + return ((id (*)(id, SEL))objc_msgSend)(object, selector); +} + +static BOOL BrowserFullscreenHackBoolForSelectorName(id object, NSString *selectorName, BOOL *didRespond) { + if (didRespond != NULL) { + *didRespond = NO; + } + + if (object == nil || selectorName.length == 0) { + return NO; + } + + SEL selector = NSSelectorFromString(selectorName); + if (![object respondsToSelector:selector]) { + return NO; + } + + if (didRespond != NULL) { + *didRespond = YES; + } + return ((BOOL (*)(id, SEL))objc_msgSend)(object, selector); +} + +static CGRect BrowserFullscreenHackRectForSelectorName(id object, NSString *selectorName, BOOL *didRespond) { + if (didRespond != NULL) { + *didRespond = NO; + } + + if (object == nil || selectorName.length == 0) { + return CGRectZero; + } + + SEL selector = NSSelectorFromString(selectorName); + if (![object respondsToSelector:selector]) { + return CGRectZero; + } + + if (didRespond != NULL) { + *didRespond = YES; + } + return ((CGRect (*)(id, SEL))objc_msgSend)(object, selector); +} + +static BOOL BrowserViewIsDescendantOfView(UIView *view, UIView *ancestor) { + if (view == nil || ancestor == nil) { + return NO; + } + + for (UIView *current = view; current != nil; current = current.superview) { + if (current == ancestor) { + return YES; + } + } + + return NO; +} + +static void BrowserFullscreenHackDumpRelevantClassesOnce(void) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + BrowserFullscreenHackDumpMethodsForClass(objc_getClass("WebAVPlayerLayerView")); + BrowserFullscreenHackDumpMethodsForClass(objc_getClass("WebAVPlayerLayer")); + BrowserFullscreenHackDumpMethodsForClass(objc_getClass("__AVPlayerLayerView")); + }); +} + +@interface BrowserFullscreenPlayerLayerView : UIView + +@property (nonatomic, strong) id pixelBufferAttributes; +@property (nonatomic, strong) id playerController; +@property (nonatomic, assign) UIEdgeInsets legibleContentInsets; +@property (nonatomic, strong) UIView *embeddedVideoView; +@property (nonatomic, strong) AVPlayer *currentPlayer; +@property (nonatomic, strong) id currentPlayerControllerObject; +@property (nonatomic, assign) CGSize sourceVideoDimensions; +@property (nonatomic, strong) UIView *sourceWebPlayerLayerView; + +- (id)playerLayer; +- (void)transferVideoViewTo:(UIView *)view; +- (BOOL)avkit_isVisible; +- (UIWindow *)avkit_window; +- (CGRect)avkit_videoRectInWindow; + +@end + +@implementation BrowserFullscreenPlayerLayerView + ++ (Class)layerClass { + return [AVPlayerLayer class]; +} + +- (CGRect)browser_screenBoundsFallback { + UIWindow *window = self.window; + if (window.windowScene.screen.bounds.size.width > 0.0 && window.windowScene.screen.bounds.size.height > 0.0) { + return window.windowScene.screen.bounds; + } + + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + + UIWindowScene *windowScene = (UIWindowScene *)scene; + if (windowScene.screen.bounds.size.width > 0.0 && windowScene.screen.bounds.size.height > 0.0) { + return windowScene.screen.bounds; + } + } + + return CGRectZero; +} + +- (CGRect)browser_fallbackBounds { + if (self.superview.bounds.size.width > 0.0 && self.superview.bounds.size.height > 0.0) { + return self.superview.bounds; + } + + if (self.window.bounds.size.width > 0.0 && self.window.bounds.size.height > 0.0) { + return self.window.bounds; + } + + return [self browser_screenBoundsFallback]; +} + +- (CGRect)browser_effectiveBounds { + if (self.bounds.size.width > 0.0 && self.bounds.size.height > 0.0) { + return self.bounds; + } + + return [self browser_fallbackBounds]; +} + +- (AVPlayerLayer *)browser_playerLayer { + return (AVPlayerLayer *)self.layer; +} + +- (AVPlayerLayer *)browser_embeddedPlayerLayer { + SEL selector = NSSelectorFromString(@"playerLayer"); + if (self.embeddedVideoView != nil && [self.embeddedVideoView respondsToSelector:selector]) { + id layer = ((id (*)(id, SEL))objc_msgSend)(self.embeddedVideoView, selector); + if ([layer isKindOfClass:[AVPlayerLayer class]]) { + return layer; + } + } + + if ([self.embeddedVideoView.layer isKindOfClass:[AVPlayerLayer class]]) { + return (AVPlayerLayer *)self.embeddedVideoView.layer; + } + + return nil; +} + +- (void)browser_applyPlayer:(AVPlayer *)player toLayerObject:(id)layerObject { + if (player == nil || layerObject == nil) { + return; + } + + NSArray *selectors = @[@"setPlayer:", @"setAVPlayer:", @"setPlayerIfNeeded:"]; + for (NSString *selectorName in selectors) { + SEL selector = NSSelectorFromString(selectorName); + if (![layerObject respondsToSelector:selector]) { + continue; + } + + ((void (*)(id, SEL, id))objc_msgSend)(layerObject, selector, player); + BrowserFullscreenHackLog(@"applied AVPlayer %@ to layer object %@ via %@", player, layerObject, selectorName); + break; + } +} + +- (void)browser_applyPlayerControllerObject:(id)playerControllerObject toObject:(id)object { + if (playerControllerObject == nil || object == nil) { + return; + } + + NSArray *selectors = @[ + @"setPlayerController:", + @"setPlaybackController:", + @"setPlayerControllerIfNeeded:", + @"setVideoViewController:", + @"setAVPlayerController:" + ]; + for (NSString *selectorName in selectors) { + SEL selector = NSSelectorFromString(selectorName); + if (![object respondsToSelector:selector]) { + continue; + } + + ((void (*)(id, SEL, id))objc_msgSend)(object, selector, playerControllerObject); + BrowserFullscreenHackLog(@"applied player controller %@ to object %@ via %@", + playerControllerObject, + object, + selectorName); + break; + } +} + +- (void)browser_applyVideoView:(UIView *)videoView toObject:(id)object { + if (videoView == nil || object == nil) { + return; + } + + SEL selector = NSSelectorFromString(@"setVideoView:"); + if (![object respondsToSelector:selector]) { + return; + } + + ((void (*)(id, SEL, id))objc_msgSend)(object, selector, videoView); + BrowserFullscreenHackLog(@"applied video view %@ to object %@ via setVideoView:", videoView, object); +} + +- (void)browser_applyVideoSublayer:(CALayer *)videoSublayer toObject:(id)object { + if (videoSublayer == nil || object == nil) { + return; + } + + SEL selector = NSSelectorFromString(@"setVideoSublayer:"); + if (![object respondsToSelector:selector]) { + return; + } + + ((void (*)(id, SEL, id))objc_msgSend)(object, selector, videoSublayer); + BrowserFullscreenHackLog(@"applied video sublayer %@ to object %@ via setVideoSublayer:", videoSublayer, object); +} + +- (void)browser_applyReadyForDisplay:(BOOL)readyForDisplay toObject:(id)object { + if (object == nil) { + return; + } + + SEL selector = NSSelectorFromString(@"setReadyForDisplay:"); + if (![object respondsToSelector:selector]) { + return; + } + + ((void (*)(id, SEL, BOOL))objc_msgSend)(object, selector, readyForDisplay); + BrowserFullscreenHackLog(@"applied readyForDisplay=%@ to object %@ via setReadyForDisplay:", + readyForDisplay ? @"YES" : @"NO", + object); +} + +- (void)browser_applyVideoDimensions:(CGSize)videoDimensions toObject:(id)object { + if (object == nil || videoDimensions.width <= 0.0 || videoDimensions.height <= 0.0) { + return; + } + + SEL selector = NSSelectorFromString(@"setVideoDimensions:"); + if (![object respondsToSelector:selector]) { + return; + } + + Method method = BrowserFullscreenHackInstanceMethod(object, @"setVideoDimensions:"); + const char *typeEncoding = method != NULL ? method_getTypeEncoding(method) : NULL; + NSString *encodingString = typeEncoding != NULL ? [NSString stringWithUTF8String:typeEncoding] : @""; + if ([encodingString containsString:@"{CGSize"]) { + ((void (*)(id, SEL, CGSize))objc_msgSend)(object, selector, videoDimensions); + } else { + NSValue *dimensionsValue = [NSValue valueWithCGSize:videoDimensions]; + ((void (*)(id, SEL, id))objc_msgSend)(object, selector, dimensionsValue); + } + BrowserFullscreenHackLog(@"applied videoDimensions=%@ to object %@ via setVideoDimensions: encoding=%@", + NSStringFromCGSize(videoDimensions), + object, + encodingString); +} + +- (void)browser_applyVideoGravity:(NSString *)videoGravity toLayerObject:(id)layerObject { + if (videoGravity.length == 0 || layerObject == nil) { + return; + } + + NSArray *selectors = @[@"setVideoGravity:", @"setAVLayerVideoGravity:"]; + for (NSString *selectorName in selectors) { + SEL selector = NSSelectorFromString(selectorName); + if (![layerObject respondsToSelector:selector]) { + continue; + } + + ((void (*)(id, SEL, id))objc_msgSend)(layerObject, selector, videoGravity); + BrowserFullscreenHackLog(@"applied videoGravity %@ to layer object %@ via %@", videoGravity, layerObject, selectorName); + break; + } +} + +- (void)browser_applyBoolean:(BOOL)value selectorName:(NSString *)selectorName toObject:(id)object { + if (object == nil || selectorName.length == 0) { + return; + } + + SEL selector = NSSelectorFromString(selectorName); + if (![object respondsToSelector:selector]) { + return; + } + + ((void (*)(id, SEL, BOOL))objc_msgSend)(object, selector, value); + BrowserFullscreenHackLog(@"applied %@=%@ to object %@", + selectorName, + value ? @"YES" : @"NO", + object); +} + +- (void)browser_forceLayoutAndActivationOnDestinationView:(UIView *)view { + if (view == nil) { + return; + } + + [view setNeedsLayout]; + [view layoutIfNeeded]; + [view.layer setNeedsLayout]; + + SEL layoutSublayersSelector = NSSelectorFromString(@"layoutSublayers"); + if ([view.layer respondsToSelector:layoutSublayersSelector]) { + ((void (*)(id, SEL))objc_msgSend)(view.layer, layoutSublayersSelector); + BrowserFullscreenHackLog(@"forced layoutSublayers on %@", view.layer); + } + + SEL calculateTargetVideoFrameSelector = NSSelectorFromString(@"calculateTargetVideoFrame"); + if ([view.layer respondsToSelector:calculateTargetVideoFrameSelector]) { + ((void (*)(id, SEL))objc_msgSend)(view.layer, calculateTargetVideoFrameSelector); + BrowserFullscreenHackLog(@"forced calculateTargetVideoFrame on %@", view.layer); + } +} + +- (void)browser_forceDestinationGeometry:(UIView *)view preferredBounds:(CGRect)preferredBounds { + if (view == nil || preferredBounds.size.width <= 0.0 || preferredBounds.size.height <= 0.0) { + return; + } + + CGRect containerBounds = CGRectZero; + if (view.superview.bounds.size.width > 0.0 && view.superview.bounds.size.height > 0.0) { + containerBounds = view.superview.bounds; + } else if (view.window.bounds.size.width > 0.0 && view.window.bounds.size.height > 0.0) { + containerBounds = view.window.bounds; + } else { + containerBounds = preferredBounds; + } + + CGRect targetFrame = CGRectMake(0.0, 0.0, preferredBounds.size.width, preferredBounds.size.height); + if (containerBounds.size.width >= preferredBounds.size.width && + containerBounds.size.height >= preferredBounds.size.height) { + targetFrame.origin.x = floor((containerBounds.size.width - preferredBounds.size.width) / 2.0); + targetFrame.origin.y = floor((containerBounds.size.height - preferredBounds.size.height) / 2.0); + } + + view.bounds = CGRectMake(0.0, 0.0, preferredBounds.size.width, preferredBounds.size.height); + view.frame = targetFrame; + view.center = CGPointMake(CGRectGetMidX(targetFrame), CGRectGetMidY(targetFrame)); + + view.layer.bounds = view.bounds; + view.layer.frame = view.bounds; + view.layer.position = CGPointMake(CGRectGetMidX(view.bounds), CGRectGetMidY(view.bounds)); + [view setNeedsLayout]; + [view layoutIfNeeded]; + [view setNeedsDisplay]; + [view.layer setNeedsLayout]; + [view.layer setNeedsDisplay]; + + BrowserFullscreenHackLog(@"forced destination geometry on %@ frame=%@ bounds=%@", + view, + NSStringFromCGRect(view.frame), + NSStringFromCGRect(view.bounds)); +} + +- (void)browser_logDestinationState:(UIView *)view label:(NSString *)label { + if (view == nil) { + return; + } + + id destinationVideoView = BrowserFullscreenHackObjectForSelectorName(view, @"videoView"); + id destinationPlayerController = BrowserFullscreenHackObjectForSelectorName(view, @"playerController"); + id destinationLayerPlayerController = BrowserFullscreenHackObjectForSelectorName(view.layer, @"playerController"); + id destinationVideoSublayer = BrowserFullscreenHackObjectForSelectorName(view.layer, @"videoSublayer"); + BOOL didRespondReady = NO; + BOOL readyForDisplay = BrowserFullscreenHackBoolForSelectorName(view.layer, @"isReadyForDisplay", &didRespondReady); + BOOL didRespondVideoRect = NO; + CGRect videoRect = BrowserFullscreenHackRectForSelectorName(view.layer, @"videoRect", &didRespondVideoRect); + + BrowserFullscreenHackLog(@"destination state[%@] view=%@ frame=%@ bounds=%@ videoView=%@(%@) playerController=%@(%@) layerPlayerController=%@(%@) videoSublayer=%@(%@) ready=%@ videoRect=%@", + label, + view, + NSStringFromCGRect(view.frame), + NSStringFromCGRect(view.bounds), + destinationVideoView, + destinationVideoView == nil ? @"nil" : NSStringFromClass([destinationVideoView class]), + destinationPlayerController, + destinationPlayerController == nil ? @"nil" : NSStringFromClass([destinationPlayerController class]), + destinationLayerPlayerController, + destinationLayerPlayerController == nil ? @"nil" : NSStringFromClass([destinationLayerPlayerController class]), + destinationVideoSublayer, + destinationVideoSublayer == nil ? @"nil" : NSStringFromClass([destinationVideoSublayer class]), + didRespondReady ? (readyForDisplay ? @"YES" : @"NO") : @"n/a", + didRespondVideoRect ? NSStringFromCGRect(videoRect) : @"n/a"); +} + +- (void)browser_attemptSourceWebTransferToDestination:(UIView *)destinationView { + if (self.sourceWebPlayerLayerView == nil || destinationView == nil) { + return; + } + + SEL transferSelector = @selector(transferVideoViewTo:); + if (![self.sourceWebPlayerLayerView respondsToSelector:transferSelector]) { + return; + } + + BrowserFullscreenHackLog(@"attempting source WebAVPlayerLayerView transfer %@ -> %@", + self.sourceWebPlayerLayerView, + destinationView); + ((void (*)(id, SEL, id))objc_msgSend)(self.sourceWebPlayerLayerView, transferSelector, destinationView); +} + +- (BOOL)browser_embeddedVideoReadyForDisplay { + SEL selector = NSSelectorFromString(@"isReadyForDisplay"); + if (self.embeddedVideoView != nil && [self.embeddedVideoView respondsToSelector:selector]) { + BOOL ready = ((BOOL (*)(id, SEL))objc_msgSend)(self.embeddedVideoView, selector); + if (ready) { + return YES; + } + } + + AVPlayerLayer *embeddedLayer = [self browser_embeddedPlayerLayer]; + if (embeddedLayer.isReadyForDisplay) { + return YES; + } + + if (self.currentPlayer.currentItem.status == AVPlayerItemStatusReadyToPlay || + self.currentPlayer.timeControlStatus == AVPlayerTimeControlStatusPlaying || + self.currentPlayer.timeControlStatus == AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate) { + return YES; + } + + return ([self browser_embeddedVideoDimensions].width > 0.0 && + [self browser_embeddedVideoDimensions].height > 0.0 && + self.currentPlayer != nil); +} + +- (CGSize)browser_embeddedVideoDimensions { + SEL videoBoundsSelector = NSSelectorFromString(@"videoBounds"); + if (self.embeddedVideoView != nil && [self.embeddedVideoView respondsToSelector:videoBoundsSelector]) { + CGRect bounds = ((CGRect (*)(id, SEL))objc_msgSend)(self.embeddedVideoView, videoBoundsSelector); + if (bounds.size.width > 0.0 && bounds.size.height > 0.0) { + return bounds.size; + } + } + + AVPlayerLayer *embeddedLayer = [self browser_embeddedPlayerLayer]; + CGRect videoRect = embeddedLayer.videoRect; + if (videoRect.size.width > 0.0 && videoRect.size.height > 0.0) { + return videoRect.size; + } + + CGSize presentationSize = self.currentPlayer.currentItem.presentationSize; + if (presentationSize.width > 0.0 && presentationSize.height > 0.0) { + return presentationSize; + } + + if (self.sourceVideoDimensions.width > 0.0 && self.sourceVideoDimensions.height > 0.0) { + return self.sourceVideoDimensions; + } + + if (self.embeddedVideoView.frame.size.width > 0.0 && self.embeddedVideoView.frame.size.height > 0.0) { + return self.embeddedVideoView.frame.size; + } + + return self.embeddedVideoView.bounds.size; +} + +- (id)playerLayer { + AVPlayerLayer *embeddedLayer = [self browser_embeddedPlayerLayer]; + return embeddedLayer != nil ? embeddedLayer : [self browser_playerLayer]; +} + +- (NSString *)videoGravity { + AVPlayerLayer *playerLayer = [self browser_embeddedPlayerLayer] ?: [self browser_playerLayer]; + return playerLayer.videoGravity; +} + +- (void)setVideoGravity:(NSString *)videoGravity { + [self browser_playerLayer].videoGravity = videoGravity; + AVPlayerLayer *embeddedLayer = [self browser_embeddedPlayerLayer]; + if (embeddedLayer != nil) { + embeddedLayer.videoGravity = videoGravity; + } +} + +- (AVPlayer *)browser_extractPlayerFromObject:(id)object { + if ([object isKindOfClass:[AVPlayer class]]) { + BrowserFullscreenHackLog(@"player controller is AVPlayer directly: %@", object); + return object; + } + + NSArray *selectors = @[@"player", @"avPlayer", @"_player", @"currentPlayer"]; + for (NSString *selectorName in selectors) { + SEL selector = NSSelectorFromString(selectorName); + if (![object respondsToSelector:selector]) { + continue; + } + + id value = ((id (*)(id, SEL))objc_msgSend)(object, selector); + if ([value isKindOfClass:[AVPlayer class]]) { + BrowserFullscreenHackLog(@"found AVPlayer via selector %@ on %@ (%@)", selectorName, object, NSStringFromClass([object class])); + return value; + } + } + + BrowserFullscreenHackLog(@"no AVPlayer found on %@ (%@)", object, object == nil ? @"nil" : NSStringFromClass([object class])); + return nil; +} + +- (void)setPlayerController:(id)playerController { + _playerController = playerController; + self.currentPlayerControllerObject = playerController; + BrowserFullscreenHackLog(@"setPlayerController: %@ (%@)", playerController, playerController == nil ? @"nil" : NSStringFromClass([playerController class])); + AVPlayer *player = [self browser_extractPlayerFromObject:playerController]; + if (player != nil) { + self.currentPlayer = player; + [self browser_playerLayer].player = player; + AVPlayerLayer *embeddedLayer = [self browser_embeddedPlayerLayer]; + if (embeddedLayer != nil) { + embeddedLayer.player = player; + } + BrowserFullscreenHackLog(@"bound AVPlayer %@ to synthetic player layer", player); + } +} + +- (void)browser_configureFromExistingPlayerLayer:(AVPlayerLayer *)playerLayer { + if (playerLayer == nil) { + return; + } + + AVPlayerLayer *targetLayer = [self browser_playerLayer]; + targetLayer.player = playerLayer.player; + targetLayer.videoGravity = playerLayer.videoGravity; + BrowserFullscreenHackLog(@"copied existing AVPlayerLayer state from %@ to synthetic layer %@", playerLayer, targetLayer); +} + +- (void)browser_embedVideoView:(UIView *)videoView { + if (videoView == nil) { + return; + } + + _embeddedVideoView = videoView; + _embeddedVideoView.hidden = NO; + [self browser_applyBoolean:YES selectorName:@"setVideoScaled:" toObject:_embeddedVideoView]; + [self browser_applyPlayerControllerObject:self.currentPlayerControllerObject toObject:_embeddedVideoView]; + [self browser_applyVideoGravity:self.videoGravity toLayerObject:_embeddedVideoView]; + if (videoView.frame.size.width > 0.0 && videoView.frame.size.height > 0.0) { + self.sourceVideoDimensions = videoView.frame.size; + } else if (videoView.bounds.size.width > 0.0 && videoView.bounds.size.height > 0.0) { + self.sourceVideoDimensions = videoView.bounds.size; + } else if ([self browser_embeddedPlayerLayer].videoRect.size.width > 0.0 && + [self browser_embeddedPlayerLayer].videoRect.size.height > 0.0) { + self.sourceVideoDimensions = [self browser_embeddedPlayerLayer].videoRect.size; + } + + if (_embeddedVideoView.superview != self) { + [_embeddedVideoView removeFromSuperview]; + _embeddedVideoView.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_embeddedVideoView]; + [NSLayoutConstraint activateConstraints:@[ + [_embeddedVideoView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor], + [_embeddedVideoView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor], + [_embeddedVideoView.topAnchor constraintEqualToAnchor:self.topAnchor], + [_embeddedVideoView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor], + ]]; + } + + _embeddedVideoView.frame = [self browser_effectiveBounds]; + [self sendSubviewToBack:_embeddedVideoView]; + BrowserFullscreenHackLog(@"embedded source video view %@ (%@) into synthetic view", + videoView, + NSStringFromClass([videoView class])); +} + +- (void)setPixelBufferAttributes:(id)pixelBufferAttributes { + _pixelBufferAttributes = pixelBufferAttributes; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + CGRect effectiveBounds = [self browser_effectiveBounds]; + if (!CGRectEqualToRect(self.bounds, effectiveBounds)) { + self.frame = effectiveBounds; + } + self.embeddedVideoView.frame = effectiveBounds; +} + +- (void)transferVideoViewTo:(UIView *)view { + if (view == nil || view == self) { + return; + } + + SEL transferSelector = @selector(transferVideoViewTo:); + if (self.embeddedVideoView != nil && [self.embeddedVideoView respondsToSelector:transferSelector]) { + BrowserFullscreenHackLog(@"forwarding transferVideoViewTo: from synthetic view to embedded video view %@ (%@) -> %@ (%@)", + self.embeddedVideoView, + NSStringFromClass([self.embeddedVideoView class]), + view, + NSStringFromClass([view class])); + ((void (*)(id, SEL, id))objc_msgSend)(self.embeddedVideoView, transferSelector, view); + } + + [self browser_attemptSourceWebTransferToDestination:view]; + + CGRect targetBounds = view.bounds; + if (targetBounds.size.width <= 0.0 || targetBounds.size.height <= 0.0) { + targetBounds = view.window.bounds; + } + if (targetBounds.size.width <= 0.0 || targetBounds.size.height <= 0.0) { + targetBounds = [self browser_screenBoundsFallback]; + } + + self.frame = targetBounds; + self.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + + if (self.superview != view) { + [self removeFromSuperview]; + [view addSubview:self]; + } + + BOOL destinationAcceptsVideoView = [view respondsToSelector:NSSelectorFromString(@"setVideoView:")]; + if (destinationAcceptsVideoView && self.embeddedVideoView.superview == self) { + [self.embeddedVideoView removeFromSuperview]; + BrowserFullscreenHackLog(@"detached embedded video view from synthetic container before setVideoView:"); + } else if (!destinationAcceptsVideoView && self.embeddedVideoView != nil && self.embeddedVideoView.superview != view) { + [self.embeddedVideoView removeFromSuperview]; + self.embeddedVideoView.translatesAutoresizingMaskIntoConstraints = NO; + [view addSubview:self.embeddedVideoView]; + [NSLayoutConstraint activateConstraints:@[ + [self.embeddedVideoView.leadingAnchor constraintEqualToAnchor:view.leadingAnchor], + [self.embeddedVideoView.trailingAnchor constraintEqualToAnchor:view.trailingAnchor], + [self.embeddedVideoView.topAnchor constraintEqualToAnchor:view.topAnchor], + [self.embeddedVideoView.bottomAnchor constraintEqualToAnchor:view.bottomAnchor], + ]]; + self.embeddedVideoView.frame = self.embeddedVideoView.superview.bounds; + BrowserFullscreenHackLog(@"moved embedded video view into destination %@ (%@)", + view, + NSStringFromClass([view class])); + } + + id destinationVideoViewAfterSourceTransfer = BrowserFullscreenHackObjectForSelectorName(view, @"videoView"); + id destinationVideoSublayerAfterSourceTransfer = BrowserFullscreenHackObjectForSelectorName(view.layer, @"videoSublayer"); + BOOL destinationAlreadyHasTransferredVideo = (destinationVideoViewAfterSourceTransfer != nil || + destinationVideoSublayerAfterSourceTransfer != nil); + + [self browser_applyPlayer:self.currentPlayer toLayerObject:view]; + [self browser_applyPlayer:self.currentPlayer toLayerObject:view.layer]; + [self browser_applyPlayerControllerObject:self.currentPlayerControllerObject toObject:view]; + [self browser_applyPlayerControllerObject:self.currentPlayerControllerObject toObject:view.layer]; + [self browser_applyPlayerControllerObject:self.currentPlayerControllerObject toObject:self.embeddedVideoView]; + if (!destinationAlreadyHasTransferredVideo) { + [self browser_applyVideoView:self.embeddedVideoView toObject:view]; + [self browser_applyVideoSublayer:self.embeddedVideoView.layer toObject:view.layer]; + } else { + BrowserFullscreenHackLog(@"destination already has transferred WebKit video objects; skipping fallback videoView/videoSublayer setters"); + } + BOOL readyForDisplay = [self browser_embeddedVideoReadyForDisplay]; + CGSize videoDimensions = [self browser_embeddedVideoDimensions]; + if (!readyForDisplay && self.currentPlayer != nil && videoDimensions.width > 0.0 && videoDimensions.height > 0.0) { + readyForDisplay = YES; + } + [self browser_applyReadyForDisplay:readyForDisplay toObject:view.layer]; + [self browser_applyVideoDimensions:videoDimensions toObject:view.layer]; + [self browser_applyVideoGravity:self.videoGravity toLayerObject:view]; + [self browser_applyVideoGravity:self.videoGravity toLayerObject:view.layer]; + [self browser_forceDestinationGeometry:view preferredBounds:[self browser_effectiveBounds]]; + [self browser_forceLayoutAndActivationOnDestinationView:view]; + + if (!destinationAcceptsVideoView) { + self.embeddedVideoView.frame = [self browser_effectiveBounds]; + } + [self browser_logDestinationState:view label:@"initial"]; + BrowserFullscreenHackLog(@"transferVideoViewTo: %@ bounds=%@ effective=%@ ready=%@ dimensions=%@ destinationAcceptsVideoView=%@ sourceDimensions=%@", + view, + NSStringFromCGRect(view.bounds), + NSStringFromCGRect(self.embeddedVideoView.frame), + readyForDisplay ? @"YES" : @"NO", + NSStringFromCGSize(videoDimensions), + destinationAcceptsVideoView ? @"YES" : @"NO", + NSStringFromCGSize(self.sourceVideoDimensions)); + + __weak typeof(self) weakSelf = self; + __weak UIView *weakDestinationView = view; + NSArray *retryDelays = @[@0.05, @0.15, @0.35, @0.75]; + for (NSNumber *delay in retryDelays) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay.doubleValue * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + typeof(self) strongSelf = weakSelf; + UIView *strongDestinationView = weakDestinationView; + if (strongSelf == nil || strongDestinationView == nil) { + return; + } + + CGSize retryDimensions = [strongSelf browser_embeddedVideoDimensions]; + BOOL retryReady = [strongSelf browser_embeddedVideoReadyForDisplay]; + if (!retryReady && strongSelf.currentPlayer != nil && + retryDimensions.width > 0.0 && retryDimensions.height > 0.0) { + retryReady = YES; + } + + [strongSelf browser_applyPlayer:strongSelf.currentPlayer toLayerObject:strongDestinationView]; + [strongSelf browser_applyPlayer:strongSelf.currentPlayer toLayerObject:strongDestinationView.layer]; + [strongSelf browser_applyPlayerControllerObject:strongSelf.currentPlayerControllerObject toObject:strongDestinationView]; + [strongSelf browser_applyPlayerControllerObject:strongSelf.currentPlayerControllerObject toObject:strongDestinationView.layer]; + [strongSelf browser_applyPlayerControllerObject:strongSelf.currentPlayerControllerObject toObject:strongSelf.embeddedVideoView]; + [strongSelf browser_attemptSourceWebTransferToDestination:strongDestinationView]; + id retryDestinationVideoView = BrowserFullscreenHackObjectForSelectorName(strongDestinationView, @"videoView"); + id retryDestinationVideoSublayer = BrowserFullscreenHackObjectForSelectorName(strongDestinationView.layer, @"videoSublayer"); + if (retryDestinationVideoView == nil && retryDestinationVideoSublayer == nil) { + [strongSelf browser_applyVideoView:strongSelf.embeddedVideoView toObject:strongDestinationView]; + [strongSelf browser_applyVideoSublayer:strongSelf.embeddedVideoView.layer toObject:strongDestinationView.layer]; + } else { + BrowserFullscreenHackLog(@"retry found transferred WebKit video objects already present on destination"); + } + [strongSelf browser_applyReadyForDisplay:retryReady toObject:strongDestinationView.layer]; + [strongSelf browser_applyVideoDimensions:retryDimensions toObject:strongDestinationView.layer]; + [strongSelf browser_applyVideoGravity:strongSelf.videoGravity toLayerObject:strongDestinationView]; + [strongSelf browser_applyVideoGravity:strongSelf.videoGravity toLayerObject:strongDestinationView.layer]; + [strongSelf browser_applyBoolean:YES selectorName:@"setVideoScaled:" toObject:strongSelf.embeddedVideoView]; + [strongSelf browser_forceDestinationGeometry:strongDestinationView preferredBounds:[strongSelf browser_effectiveBounds]]; + [strongSelf browser_forceLayoutAndActivationOnDestinationView:strongDestinationView]; + [strongSelf browser_logDestinationState:strongDestinationView label:[NSString stringWithFormat:@"retry-%@", delay]]; + BrowserFullscreenHackLog(@"retry activation delay=%@ ready=%@ dimensions=%@ destination=%@", + delay, + retryReady ? @"YES" : @"NO", + NSStringFromCGSize(retryDimensions), + strongDestinationView); + }); + } +} + +- (BOOL)avkit_isVisible { + if (self.hidden || self.alpha <= 0.0) { + return NO; + } + + return self.window != nil || self.superview != nil; +} + +- (UIWindow *)avkit_window { + return self.window; +} + +- (CGRect)avkit_videoRectInWindow { + UIWindow *window = self.window; + if (window == nil) { + return CGRectZero; + } + + return [self convertRect:self.bounds toView:window]; +} + +@end + +static UIView *BrowserViewForObject(id object) { + if (object == nil || ![object respondsToSelector:@selector(view)]) { + return nil; + } + return ((id (*)(id, SEL))objc_msgSend)(object, @selector(view)); +} + +static void BrowserStoreRetainedHackView(id owner, UIView *view) { + if (owner == nil || view == nil) { + return; + } + + NSMutableArray *views = objc_getAssociatedObject(owner, kBrowserFullscreenHackAssociatedViewsKey); + if (views == nil) { + views = [NSMutableArray array]; + objc_setAssociatedObject(owner, kBrowserFullscreenHackAssociatedViewsKey, views, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + + [views addObject:view]; +} + +static UIView *BrowserCurrentPlayerLayerViewFromHost(id playerControllerHost) { + SEL selector = NSSelectorFromString(@"playerLayerView"); + if (playerControllerHost == nil || ![playerControllerHost respondsToSelector:selector]) { + return nil; + } + + id value = ((id (*)(id, SEL))objc_msgSend)(playerControllerHost, selector); + BrowserFullscreenHackLog(@"host playerLayerView lookup on %@ (%@) -> %@ (%@)", + playerControllerHost, + playerControllerHost == nil ? @"nil" : NSStringFromClass([playerControllerHost class]), + value, + value == nil ? @"nil" : NSStringFromClass([value class])); + return [value isKindOfClass:[UIView class]] ? value : nil; +} + +static BOOL BrowserViewLooksLikePlayerLayerView(UIView *view) { + if (view == nil) { + return NO; + } + + NSString *className = NSStringFromClass([view class]); + if ([className containsString:@"ContainerView"]) { + return NO; + } + + if ([view respondsToSelector:NSSelectorFromString(@"playerLayer")] || + [view respondsToSelector:NSSelectorFromString(@"setLegibleContentInsets:")] || + [className containsString:@"PlayerLayer"] || + [className containsString:@"Video"]) { + return YES; + } + + return NO; +} + +static UIView *BrowserFindPlayerLayerViewInHierarchy(UIView *view) { + for (UIView *subview in view.subviews) { + if (BrowserViewLooksLikePlayerLayerView(subview)) { + return subview; + } + + UIView *match = BrowserFindPlayerLayerViewInHierarchy(subview); + if (match != nil) { + return match; + } + } + + return nil; +} + +static UIView *BrowserFindVisibleInlineWebPlayerLayerViewInHierarchy(UIView *rootView, UIView *excludedRoot) { + for (UIView *subview in rootView.subviews) { + NSString *className = NSStringFromClass([subview class]); + BOOL isInlineWebPlayerLayerView = [className isEqualToString:@"WebAVPlayerLayerView"]; + BOOL isVisible = !subview.hidden && subview.alpha > 0.0; + BOOL hasGeometry = (subview.bounds.size.width > 0.0 && subview.bounds.size.height > 0.0) || + (subview.frame.size.width > 0.0 && subview.frame.size.height > 0.0); + BOOL excluded = BrowserViewIsDescendantOfView(subview, excludedRoot); + if (isInlineWebPlayerLayerView && isVisible && hasGeometry && !excluded) { + return subview; + } + + UIView *match = BrowserFindVisibleInlineWebPlayerLayerViewInHierarchy(subview, excludedRoot); + if (match != nil) { + return match; + } + } + + return nil; +} + +static UIView *BrowserFindVisibleInlineWebPlayerLayerView(UIView *excludedRoot) { + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + + UIWindowScene *windowScene = (UIWindowScene *)scene; + for (UIWindow *window in windowScene.windows) { + UIView *match = BrowserFindVisibleInlineWebPlayerLayerViewInHierarchy(window, excludedRoot); + if (match != nil) { + return match; + } + } + } + + return nil; +} + +static AVPlayerLayer *BrowserExtractPlayerLayerFromView(UIView *view) { + if (view == nil) { + return nil; + } + + if ([view.layer isKindOfClass:[AVPlayerLayer class]]) { + return (AVPlayerLayer *)view.layer; + } + + SEL selector = NSSelectorFromString(@"playerLayer"); + if ([view respondsToSelector:selector]) { + id layer = ((id (*)(id, SEL))objc_msgSend)(view, selector); + if ([layer isKindOfClass:[AVPlayerLayer class]]) { + return layer; + } + } + + return nil; +} + +static BOOL BrowserIsPotentialPlayerControllerHost(id object) { + if (object == nil) { + return NO; + } + + return [object respondsToSelector:@selector(view)] && + [object respondsToSelector:NSSelectorFromString(@"videoGravity")] && + [object respondsToSelector:NSSelectorFromString(@"playerLayerView")] && + [object respondsToSelector:NSSelectorFromString(@"setPlayerLayerView:")] && + [object respondsToSelector:NSSelectorFromString(@"pixelBufferAttributes")]; +} + +static id BrowserPlayerControllerHostFromKnownOffset(id fullscreenController) { + if (fullscreenController == nil) { + return nil; + } + + uint8_t *bytes = (uint8_t *)(__bridge void *)fullscreenController; + __unsafe_unretained id playerControllerHost = nil; + memcpy(&playerControllerHost, bytes + kBrowserPlayerControllerHostOffset, sizeof(playerControllerHost)); + return playerControllerHost; +} + +static UIView *BrowserPlayerLayerViewFromFullscreenInterface(void *fullscreenInterface) { + if (fullscreenInterface == NULL) { + return nil; + } + + __unsafe_unretained UIView *playerLayerView = nil; + memcpy(&playerLayerView, + ((uint8_t *)fullscreenInterface) + kBrowserFullscreenInterfacePlayerLayerViewOffset, + sizeof(playerLayerView)); + return playerLayerView; +} + +static void BrowserSetPlayerLayerViewOnFullscreenInterface(void *fullscreenInterface, UIView *playerLayerView) { + if (fullscreenInterface == NULL || playerLayerView == nil) { + return; + } + + __unsafe_unretained UIView *unretainedPlayerLayerView = playerLayerView; + memcpy(((uint8_t *)fullscreenInterface) + kBrowserFullscreenInterfacePlayerLayerViewOffset, + &unretainedPlayerLayerView, + sizeof(unretainedPlayerLayerView)); +} + +static id BrowserFindPlayerControllerHost(id fullscreenController) { + for (Class currentClass = [fullscreenController class]; + currentClass != Nil && currentClass != [NSObject class]; + currentClass = class_getSuperclass(currentClass)) { + unsigned int ivarCount = 0; + Ivar *ivars = class_copyIvarList(currentClass, &ivarCount); + for (unsigned int index = 0; index < ivarCount; index++) { + Ivar ivar = ivars[index]; + const char *typeEncoding = ivar_getTypeEncoding(ivar); + if (typeEncoding == NULL || typeEncoding[0] != '@') { + continue; + } + + id value = object_getIvar(fullscreenController, ivar); + if (BrowserIsPotentialPlayerControllerHost(value)) { + free(ivars); + return value; + } + } + free(ivars); + } + return nil; +} + +static void BrowserEnsureFullscreenContainerSubview(id fullscreenController) { + id playerControllerHost = BrowserPlayerControllerHostFromKnownOffset(fullscreenController); + if (!BrowserIsPotentialPlayerControllerHost(playerControllerHost)) { + playerControllerHost = BrowserFindPlayerControllerHost(fullscreenController); + } + + UIView *playerControllerView = BrowserViewForObject(playerControllerHost); + BrowserFullscreenHackLog(@"host view %@ for controller %@ (%@), subviews=%lu", + playerControllerView, + playerControllerHost, + playerControllerHost == nil ? @"nil" : NSStringFromClass([playerControllerHost class]), + (unsigned long)playerControllerView.subviews.count); + if (playerControllerView == nil || playerControllerView.subviews.count > 0) { + return; + } + + UIView *containerView = [[UIView alloc] initWithFrame:playerControllerView.bounds]; + containerView.backgroundColor = UIColor.clearColor; + containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [playerControllerView addSubview:containerView]; + BrowserStoreRetainedHackView(fullscreenController, containerView); +} + +static void BrowserEnsurePlayerLayerView(void *fullscreenInterface, id fullscreenController) { + if (BrowserPlayerLayerViewFromFullscreenInterface(fullscreenInterface) != nil) { + return; + } + + id playerControllerHost = BrowserPlayerControllerHostFromKnownOffset(fullscreenController); + if (!BrowserIsPotentialPlayerControllerHost(playerControllerHost)) { + playerControllerHost = BrowserFindPlayerControllerHost(fullscreenController); + } + + UIView *existingPlayerLayerView = BrowserCurrentPlayerLayerViewFromHost(playerControllerHost); + UIView *playerControllerView = BrowserViewForObject(playerControllerHost); + UIView *inlineWebPlayerLayerView = BrowserFindVisibleInlineWebPlayerLayerView(playerControllerView); + UIView *discoveredPlayerLayerView = BrowserFindPlayerLayerViewInHierarchy(playerControllerView); + + CGRect frame = playerControllerView != nil ? playerControllerView.bounds : CGRectZero; + BrowserFullscreenPlayerLayerView *playerLayerView = [[BrowserFullscreenPlayerLayerView alloc] initWithFrame:frame]; + playerLayerView.backgroundColor = UIColor.clearColor; + playerLayerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + + if (inlineWebPlayerLayerView != nil) { + playerLayerView.sourceWebPlayerLayerView = inlineWebPlayerLayerView; + BrowserFullscreenHackLog(@"using inline WebKit player layer view %@ (%@) as preferred source", + inlineWebPlayerLayerView, + NSStringFromClass([inlineWebPlayerLayerView class])); + } + + UIView *preferredEmbeddedVideoView = nil; + if (inlineWebPlayerLayerView != nil) { + id inlineVideoView = BrowserFullscreenHackObjectForSelectorName(inlineWebPlayerLayerView, @"videoView"); + if ([inlineVideoView isKindOfClass:[UIView class]]) { + preferredEmbeddedVideoView = inlineVideoView; + } + } + + UIView *sourcePlayerLayerView = preferredEmbeddedVideoView ?: existingPlayerLayerView ?: discoveredPlayerLayerView; + AVPlayerLayer *sourcePlayerLayer = BrowserExtractPlayerLayerFromView(sourcePlayerLayerView); + if (sourcePlayerLayer != nil) { + [playerLayerView browser_configureFromExistingPlayerLayer:sourcePlayerLayer]; + [playerLayerView browser_embedVideoView:sourcePlayerLayerView]; + BrowserFullscreenHackLog(@"using %@ playerLayerView %@ (%@) as source for synthetic layer", + preferredEmbeddedVideoView != nil ? @"inline WebKit source" : (existingPlayerLayerView != nil ? @"existing host" : @"discovered host"), + sourcePlayerLayerView, + NSStringFromClass([sourcePlayerLayerView class])); + } + + BrowserSetPlayerLayerViewOnFullscreenInterface(fullscreenInterface, playerLayerView); + BrowserStoreRetainedHackView(fullscreenController, playerLayerView); + BrowserFullscreenHackLog(@"using synthetic playerLayerView %@ with frame %@", + playerLayerView, + NSStringFromCGRect(frame)); +} + +static void BrowserConfigurePlayerViewControllerReplacement(id self, SEL _cmd, void *fullscreenInterface) { + BrowserFullscreenHackDumpRelevantClassesOnce(); + BrowserEnsurePlayerLayerView(fullscreenInterface, self); + BrowserEnsureFullscreenContainerSubview(self); + + if (BrowserOriginalConfigurePlayerViewController != NULL) { + BrowserOriginalConfigurePlayerViewController(self, _cmd, fullscreenInterface); + } +} + +@interface BrowserFullscreenSubviewHack : NSObject +@end + +@implementation BrowserFullscreenSubviewHack + ++ (void)load { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + if (!kBrowserFullscreenHackEnabled) { + return; + } + + Class playerViewControllerClass = objc_getClass("WebAVPlayerViewController"); + if (playerViewControllerClass == Nil) { + return; + } + + SEL selector = NSSelectorFromString(@"configurePlayerViewControllerWithFullscreenInterface:"); + Method method = class_getInstanceMethod(playerViewControllerClass, selector); + if (method == NULL) { + return; + } + + BrowserOriginalConfigurePlayerViewController = (void (*)(id, SEL, void *))method_getImplementation(method); + method_setImplementation(method, (IMP)BrowserConfigurePlayerViewControllerReplacement); + }); +} + +@end diff --git a/_Project/Browser/WebCoreNSURLSessionTaskTransactionMetrics+AddPrivacyStance.m b/_Project/Browser/WebCoreNSURLSessionTaskTransactionMetrics+AddPrivacyStance.m new file mode 100644 index 0000000..38ba5af --- /dev/null +++ b/_Project/Browser/WebCoreNSURLSessionTaskTransactionMetrics+AddPrivacyStance.m @@ -0,0 +1,35 @@ +#import +#import + +static NSInteger BrowserPrivacyStanceUnknown(__unused id self, __unused SEL _cmd) { + return 0; +} + +@interface BrowserPrivacyStanceShim : NSObject +@end + +@implementation BrowserPrivacyStanceShim + ++ (void)load { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Class metricsClass = objc_getClass("WebCoreNSURLSessionTaskTransactionMetrics"); + if (metricsClass == Nil) { + return; + } + + SEL privateSelector = NSSelectorFromString(@"_privacyStance"); + SEL publicSelector = NSSelectorFromString(@"privacyStance"); + const char *typeEncoding = "q@:"; + + if (class_getInstanceMethod(metricsClass, privateSelector) == NULL) { + class_addMethod(metricsClass, privateSelector, (IMP)BrowserPrivacyStanceUnknown, typeEncoding); + } + + if (class_getInstanceMethod(metricsClass, publicSelector) == NULL) { + class_addMethod(metricsClass, publicSelector, (IMP)BrowserPrivacyStanceUnknown, typeEncoding); + } + }); +} + +@end diff --git a/Browser/main.m b/_Project/Browser/main.m similarity index 81% rename from Browser/main.m rename to _Project/Browser/main.m index 152724c..99f79ab 100644 --- a/Browser/main.m +++ b/_Project/Browser/main.m @@ -3,7 +3,7 @@ // Browser // // Created by Steven Troughton-Smith on 20/09/2015. -// Copyright © 2015 High Caffeine Content. All rights reserved. +// Improved by Jip van Akker on 14/10/2015 through 10/01/2019 // #import diff --git a/readme_instruction_01.png b/readme_instruction_01.png new file mode 100644 index 0000000..8ae2330 Binary files /dev/null and b/readme_instruction_01.png differ diff --git a/readme_instruction_02.png b/readme_instruction_02.png new file mode 100644 index 0000000..d94c9df Binary files /dev/null and b/readme_instruction_02.png differ diff --git a/screen01.png b/screen01.png new file mode 100644 index 0000000..f257608 Binary files /dev/null and b/screen01.png differ diff --git a/screen02.png b/screen02.png new file mode 100644 index 0000000..218f47a Binary files /dev/null and b/screen02.png differ diff --git a/screen03.png b/screen03.png new file mode 100644 index 0000000..6028231 Binary files /dev/null and b/screen03.png differ