Rename to rossler\\attix
This commit is contained in:
25
App/AppDelegate.swift
Normal file
25
App/AppDelegate.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/21/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
|
||||
{
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: UISceneSession Lifecycle
|
||||
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration
|
||||
{
|
||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
}
|
||||
|
||||
40
App/Backend/ResourcePolicyManager.swift
Normal file
40
App/Backend/ResourcePolicyManager.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// ResourcePolicyManager.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/22/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class ResourcePolicyManager: NSObject, SBRResourceOriginPolicyDataSource
|
||||
{
|
||||
static let AllowedOriginsDefaultsKey = "allowedOrigins"
|
||||
static let EnabledOriginsDefaultsKey = "enabledOrigins"
|
||||
|
||||
private static func stringSetForKey(_ key: String) -> Set<String> {
|
||||
if let set = UserDefaults.standard.array(forKey: key) as? [String] {
|
||||
return Set<String>(set)
|
||||
}
|
||||
|
||||
return Set<String>()
|
||||
}
|
||||
|
||||
private static func saveStringSet(_ set: Set<String>, forKey key: String) {
|
||||
UserDefaults.standard.set(Array(set), forKey: key)
|
||||
}
|
||||
|
||||
private var allowedOriginSet: Set<String> = stringSetForKey(AllowedOriginsDefaultsKey) {
|
||||
didSet { Self.saveStringSet(allowedOriginSet, forKey: Self.AllowedOriginsDefaultsKey) }
|
||||
}
|
||||
|
||||
func allowedOriginsForScriptResources() -> Set<String> { allowedOriginSet }
|
||||
|
||||
func allowOriginToLoadScriptResources(_ origin: String) {
|
||||
allowedOriginSet.formUnion([ origin ])
|
||||
}
|
||||
|
||||
func disallowOriginToLoadScriptResources(_ origin: String) {
|
||||
allowedOriginSet.remove(origin)
|
||||
}
|
||||
}
|
||||
108
App/Browser View/BrowserView.swift
Normal file
108
App/Browser View/BrowserView.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// BrowserView.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/21/20.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
class BrowserView: UIView
|
||||
{
|
||||
let titlebarView = TitlebarView()
|
||||
|
||||
var toolbarView: ToolbarView? {
|
||||
didSet { addSubview(toolbarView!) }
|
||||
}
|
||||
|
||||
var webView: WKWebView? {
|
||||
didSet {
|
||||
oldValue?.removeFromSuperview()
|
||||
|
||||
if let toolbarView = toolbarView {
|
||||
insertSubview(webView!, belowSubview: toolbarView)
|
||||
} else {
|
||||
addSubview(webView!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var keyboardWillShowObserver: AnyCancellable?
|
||||
var keyboardWillHideObserver: AnyCancellable?
|
||||
var keyboardLayoutOffset: CGFloat = 0 { didSet { setNeedsLayout() } }
|
||||
|
||||
convenience init() {
|
||||
self.init(frame: .zero)
|
||||
|
||||
addSubview(titlebarView)
|
||||
|
||||
keyboardWillShowObserver = NotificationCenter.default.publisher(for: UIWindow.keyboardWillShowNotification).sink { [weak self] notification in
|
||||
self?.adjustOffsetForKeyboardNotification(userInfo: notification.userInfo!)
|
||||
}
|
||||
|
||||
keyboardWillHideObserver = NotificationCenter.default.publisher(for: UIWindow.keyboardWillHideNotification).sink { [weak self] notification in
|
||||
self?.adjustOffsetForKeyboardNotification(userInfo: notification.userInfo!)
|
||||
}
|
||||
}
|
||||
|
||||
private func adjustOffsetForKeyboardNotification(userInfo: [AnyHashable : Any]) {
|
||||
guard let keyboardEndFrame = userInfo[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
||||
guard let animationDuration = userInfo[UIWindow.keyboardAnimationDurationUserInfoKey] as? TimeInterval else { return }
|
||||
guard let animationCurve = userInfo[UIWindow.keyboardAnimationCurveUserInfoKey] as? Int else { return }
|
||||
|
||||
let animationOptions: UIView.AnimationOptions = { curve -> UIView.AnimationOptions in
|
||||
switch UIView.AnimationCurve(rawValue: curve) {
|
||||
case .easeIn: return .curveEaseIn
|
||||
case .easeOut: return .curveEaseOut
|
||||
case .easeInOut: return .curveEaseInOut
|
||||
default: return .init()
|
||||
}
|
||||
}(animationCurve)
|
||||
|
||||
keyboardLayoutOffset = bounds.height - keyboardEndFrame.minY
|
||||
UIView.animate(withDuration: animationDuration, delay: 0.0, options: animationOptions, animations: { self.layoutIfNeeded() }, completion: nil)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
var webViewContentInset = UIEdgeInsets()
|
||||
|
||||
bringSubviewToFront(titlebarView)
|
||||
|
||||
var titlebarHeight: CGFloat = 24.0
|
||||
titlebarHeight += safeAreaInsets.top
|
||||
titlebarView.frame = CGRect(origin: .zero, size: CGSize(width: bounds.width, height: titlebarHeight))
|
||||
webViewContentInset.top += titlebarView.frame.height
|
||||
|
||||
if let toolbarView = toolbarView {
|
||||
var toolbarSize = toolbarView.sizeThatFits(bounds.size)
|
||||
|
||||
// Compact: toolbar is at the bottom
|
||||
if traitCollection.horizontalSizeClass == .compact {
|
||||
var bottomOffset: CGFloat = 0.0
|
||||
if keyboardLayoutOffset == 0 {
|
||||
toolbarSize.height += safeAreaInsets.bottom
|
||||
} else if toolbarView.urlBar?.textField.isFirstResponder ?? false {
|
||||
bottomOffset = keyboardLayoutOffset
|
||||
}
|
||||
|
||||
toolbarView.bounds = CGRect(origin: .zero, size: toolbarSize)
|
||||
toolbarView.center = CGPoint(x: bounds.center.x, y: bounds.maxY - (toolbarView.bounds.height / 2) - bottomOffset)
|
||||
webViewContentInset.bottom += toolbarView.frame.height
|
||||
} else {
|
||||
// Regular: toolbar is at the top
|
||||
toolbarView.frame = CGRect(origin: CGPoint(x: 0.0, y: titlebarView.frame.maxY), size: toolbarSize)
|
||||
webViewContentInset.top += toolbarView.frame.height
|
||||
}
|
||||
}
|
||||
|
||||
// Fix web view content insets
|
||||
if let webView = webView {
|
||||
webView.scrollView.layer.masksToBounds = false // allow content to draw under titlebar/toolbar
|
||||
webView.frame = bounds.inset(by: webViewContentInset)
|
||||
}
|
||||
}
|
||||
}
|
||||
340
App/Browser View/BrowserViewController.swift
Normal file
340
App/Browser View/BrowserViewController.swift
Normal file
@@ -0,0 +1,340 @@
|
||||
//
|
||||
// BrowserViewController.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/21/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class BrowserViewController: UIViewController, WKNavigationDelegate,
|
||||
UITextFieldDelegate, ScriptPolicyViewControllerDelegate,
|
||||
UIPopoverPresentationControllerDelegate, TabDelegate, TabPickerViewControllerDelegate
|
||||
{
|
||||
let browserView = BrowserView()
|
||||
var tab: Tab { didSet { didChangeTab(tab) } }
|
||||
var webView: WKWebView { tab.webView }
|
||||
|
||||
private let tabController = TabController()
|
||||
private let toolbarController = ToolbarViewController()
|
||||
|
||||
private var policyManager: ResourcePolicyManager { tabController.policyManager }
|
||||
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
private var titleObservation: NSKeyValueObservation?
|
||||
private var loadingObservation: NSKeyValueObservation?
|
||||
private var backButtonObservation: NSKeyValueObservation?
|
||||
private var forwardButtonObservation: NSKeyValueObservation?
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
|
||||
|
||||
init() {
|
||||
self.tab = tabController.tabs.first!
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
addChild(toolbarController)
|
||||
didChangeTab(tab)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func loadView() {
|
||||
browserView.toolbarView = toolbarController.toolbarView
|
||||
|
||||
// Refresh button
|
||||
toolbarController.urlBar.refreshButton.addAction(UIAction(handler: { [unowned self] action in
|
||||
if self.webView.isLoading {
|
||||
self.webView.stopLoading()
|
||||
} else {
|
||||
self.webView.reload()
|
||||
}
|
||||
}), for: .touchUpInside)
|
||||
|
||||
// Back button
|
||||
toolbarController.backButton.addAction(UIAction(handler: { [unowned self] _ in
|
||||
self.webView.goBack()
|
||||
}), for: .touchUpInside)
|
||||
|
||||
// Forward button
|
||||
toolbarController.forwardButton.addAction(UIAction(handler: { [unowned self] _ in
|
||||
self.webView.goForward()
|
||||
}), for: .touchUpInside)
|
||||
|
||||
// Share button
|
||||
toolbarController.shareButton.addAction(UIAction(handler: { [unowned self, toolbarController] _ in
|
||||
if let url = self.webView.url {
|
||||
let itemProvider = NSItemProvider(item: url as NSURL, typeIdentifier: UTType.url.identifier)
|
||||
let config = UIActivityItemsConfiguration(itemProviders: [ itemProvider ])
|
||||
config.metadataProvider = { metadataKey in
|
||||
switch metadataKey {
|
||||
case .title: return self.webView.title
|
||||
case .messageBody: return self.webView.title
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
config.previewProvider = { index, intent, suggestedSize in
|
||||
NSItemProvider(item: self.tab.favicon, typeIdentifier: UTType.image.identifier)
|
||||
}
|
||||
|
||||
let activityController = UIActivityViewController(activityItemsConfiguration: config)
|
||||
activityController.popoverPresentationController?.sourceView = toolbarController.shareButton
|
||||
self.present(activityController, animated: true, completion: nil)
|
||||
}
|
||||
}), for: .touchUpInside)
|
||||
|
||||
// Script button
|
||||
toolbarController.scriptControllerIconView.addAction(UIAction(handler: { [unowned self] action in
|
||||
let hostOrigin = self.webView.url?.host ?? ""
|
||||
let loadedScripts = self.tab.allowedScriptOrigins.union(self.tab.blockedScriptOrigins)
|
||||
let scriptViewController = ScriptPolicyViewController(policyManager: self.policyManager,
|
||||
hostOrigin: hostOrigin,
|
||||
loadedScripts: loadedScripts,
|
||||
scriptsAllowedForTab: self.tab.javaScriptEnabled)
|
||||
scriptViewController.delegate = self
|
||||
|
||||
let navController = UINavigationController(rootViewController: scriptViewController)
|
||||
navController.modalPresentationStyle = .popover
|
||||
navController.popoverPresentationController?.sourceView = self.toolbarController.scriptControllerIconView
|
||||
navController.popoverPresentationController?.delegate = self
|
||||
|
||||
self.present(navController, animated: true, completion: nil)
|
||||
}), for: .touchUpInside)
|
||||
|
||||
// Dark mode button
|
||||
toolbarController.darkModeButton.addAction(UIAction(handler: { [unowned self] _ in
|
||||
self.tab.bridge.darkModeEnabled = !self.tab.bridge.darkModeEnabled
|
||||
self.toolbarController.darkModeEnabled = self.tab.bridge.darkModeEnabled
|
||||
}), for: .touchUpInside)
|
||||
|
||||
// Tabs button
|
||||
toolbarController.windowButton.addAction(UIAction(handler: { [unowned self] _ in
|
||||
let tabPickerController = TabPickerViewController(tabController: self.tabController)
|
||||
tabPickerController.delegate = self
|
||||
tabPickerController.selectedTab = self.tab
|
||||
|
||||
let navController = UINavigationController(rootViewController: tabPickerController)
|
||||
navController.modalPresentationStyle = .popover
|
||||
navController.popoverPresentationController?.sourceView = self.toolbarController.windowButton
|
||||
navController.popoverPresentationController?.delegate = self
|
||||
|
||||
self.present(navController, animated: true, completion: nil)
|
||||
}), for: .touchUpInside)
|
||||
|
||||
let newTabAction = UIAction { [unowned self] action in
|
||||
if let gestureRecognizer = action.sender as? UILongPressGestureRecognizer {
|
||||
if gestureRecognizer.state != .began { return }
|
||||
}
|
||||
|
||||
// Create new tab
|
||||
let newTab = tabController.createNewTab(url: nil)
|
||||
self.tab = newTab
|
||||
}
|
||||
|
||||
let gestureRecognizer = UILongPressGestureRecognizer(action: newTabAction)
|
||||
toolbarController.windowButton.addGestureRecognizer(gestureRecognizer)
|
||||
|
||||
// New tab button
|
||||
toolbarController.newTabButton.addAction(newTabAction, for: .touchUpInside)
|
||||
|
||||
// TextField delegate
|
||||
toolbarController.urlBar.textField.delegate = self
|
||||
|
||||
self.view = browserView
|
||||
}
|
||||
|
||||
private func updateLoadProgress(forWebView webView: WKWebView) {
|
||||
if webView.estimatedProgress == 1.0 {
|
||||
toolbarController.urlBar.loadProgress = .complete
|
||||
} else {
|
||||
toolbarController.urlBar.loadProgress = .loading(progress: webView.estimatedProgress)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTitleAndURL(forWebView webView: WKWebView) {
|
||||
browserView.titlebarView.titleLabelView.text = webView.title
|
||||
|
||||
if let urlString = webView.url?.absoluteString {
|
||||
toolbarController.urlBar.textField.text = urlString
|
||||
} else {
|
||||
toolbarController.urlBar.textField.text = ""
|
||||
}
|
||||
}
|
||||
|
||||
private func didChangeTab(_ tab: Tab) {
|
||||
tab.delegate = self
|
||||
|
||||
let webView = tab.webView
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.navigationDelegate = self
|
||||
|
||||
// Change webView
|
||||
browserView.webView = webView
|
||||
|
||||
// Load progress
|
||||
updateLoadProgress(forWebView: webView)
|
||||
loadingObservation = webView.observe(\.estimatedProgress) { [unowned self] (webView, observedChange) in
|
||||
self.updateLoadProgress(forWebView: webView)
|
||||
}
|
||||
|
||||
// Title observer
|
||||
updateTitleAndURL(forWebView: webView)
|
||||
titleObservation = webView.observe(\.title, changeHandler: { [unowned self] (webView, observedChange) in
|
||||
self.updateTitleAndURL(forWebView: webView)
|
||||
})
|
||||
|
||||
// Back/forward observer
|
||||
toolbarController.backButton.isEnabled = webView.canGoBack
|
||||
backButtonObservation = webView.observe(\.canGoBack, changeHandler: { [toolbarController] (webView, observedChange) in
|
||||
toolbarController.backButton.isEnabled = webView.canGoBack
|
||||
})
|
||||
|
||||
toolbarController.forwardButton.isEnabled = webView.canGoForward
|
||||
forwardButtonObservation = webView.observe(\.canGoForward, changeHandler: { [toolbarController] (webView, observedChange) in
|
||||
toolbarController.forwardButton.isEnabled = webView.canGoForward
|
||||
})
|
||||
|
||||
// Script blocker button
|
||||
updateScriptBlockerButton()
|
||||
|
||||
// Enforce dark mode setting
|
||||
tab.bridge.darkModeEnabled = toolbarController.darkModeEnabled
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
becomeFirstResponder()
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
// Not sure why this doesn't happen automatically...
|
||||
toolbarController.traitCollectionDidChange(previousTraitCollection)
|
||||
}
|
||||
|
||||
private func updateScriptBlockerButton() {
|
||||
var numBlockedScripts: Int = tab.blockedScriptOrigins.count
|
||||
if tab.url != nil, tab.javaScriptEnabled == false {
|
||||
// Because the page is blocked too, notify.
|
||||
numBlockedScripts += 1
|
||||
}
|
||||
|
||||
var scriptsAllowedForHost = false
|
||||
if let url = webView.url, let host = url.host, policyManager.allowedOriginsForScriptResources().contains(host) {
|
||||
scriptsAllowedForHost = true
|
||||
}
|
||||
|
||||
let iconView = toolbarController.scriptControllerIconView
|
||||
iconView.shieldsDown = tab.javaScriptEnabled
|
||||
iconView.someScriptsAllowed = scriptsAllowedForHost
|
||||
iconView.setBlockedScriptsNumber(numBlockedScripts)
|
||||
}
|
||||
|
||||
// MARK: UIPopoverPresentationControllerDelegate
|
||||
|
||||
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
|
||||
// Forces popovers to present on iPhone
|
||||
return .none
|
||||
}
|
||||
|
||||
// MARK: Navigation Delegate
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
// Reset tracking this
|
||||
tab.allowedScriptOrigins.removeAll()
|
||||
tab.blockedScriptOrigins.removeAll()
|
||||
updateScriptBlockerButton()
|
||||
|
||||
updateTitleAndURL(forWebView: webView)
|
||||
|
||||
// Start requesting favicon
|
||||
if let url = webView.url {
|
||||
tab.updateFaviconForURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
toolbarController.urlBar.loadProgress = .complete
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences, decisionHandler: @escaping (WKNavigationActionPolicy, WKWebpagePreferences) -> Void)
|
||||
{
|
||||
var allowJavaScript = tab.javaScriptEnabled
|
||||
if !allowJavaScript, let host = navigationAction.request.url?.host {
|
||||
// Check origin policy
|
||||
allowJavaScript = policyManager.allowedOriginsForScriptResources().contains(host)
|
||||
}
|
||||
|
||||
preferences.allowsContentJavaScript = allowJavaScript
|
||||
decisionHandler(.allow, preferences)
|
||||
}
|
||||
|
||||
// MARK: UITextField Delegate
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
if let text = textField.text?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) {
|
||||
|
||||
// Dumb rules for stuff that "looks like" a URL
|
||||
if !text.contains(" "),
|
||||
text.components(separatedBy: ".").count > 1,
|
||||
var url = URL(string: text)
|
||||
{
|
||||
if url.scheme == nil {
|
||||
let urlString = "https://\(text)"
|
||||
if let fixedURL = URL(string: urlString) {
|
||||
url = fixedURL
|
||||
}
|
||||
}
|
||||
|
||||
tab.beginLoadingURL(url)
|
||||
} else {
|
||||
// Assume google search
|
||||
let queryString = text.replacingOccurrences(of: " ", with: "+")
|
||||
let searchURL = URL(string: "https://google.com/search?q=\(queryString)&gbv=1")! // gbv=1: no JS
|
||||
tab.beginLoadingURL(searchURL)
|
||||
}
|
||||
|
||||
textField.resignFirstResponder()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: Tab Delegate
|
||||
|
||||
func didBlockScriptOrigin(_ origin: String, forTab: Tab) {
|
||||
updateScriptBlockerButton()
|
||||
}
|
||||
|
||||
// MARK: Tab Picker Delegate
|
||||
|
||||
func tabPicker(_ picker: TabPickerViewController, didSelectTab tab: Tab) {
|
||||
self.tab = tab
|
||||
picker.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func tabPicker(_ picker: TabPickerViewController, willCloseTab tab: Tab) {
|
||||
// If closed tab is current tab, pick another one.
|
||||
if tab == self.tab {
|
||||
if let nextTab = tabController.tabs.last(where: { $0 != tab }) {
|
||||
self.tab = nextTab
|
||||
}
|
||||
|
||||
picker.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Script Policy View Controller Delegate
|
||||
|
||||
func didChangeScriptPolicy() {
|
||||
tab.bridge.policyDataSourceDidChange()
|
||||
webView.reload()
|
||||
}
|
||||
|
||||
func setScriptsEnabledForTab(_ enabled: Bool) {
|
||||
tab.javaScriptEnabled = enabled
|
||||
toolbarController.scriptControllerIconView.shieldsDown = enabled
|
||||
}
|
||||
}
|
||||
65
App/Resources/darkmode.css
Normal file
65
App/Resources/darkmode.css
Normal file
@@ -0,0 +1,65 @@
|
||||
html, body {
|
||||
color: #555 !important;
|
||||
background: #ececec !important;
|
||||
}
|
||||
|
||||
html, iframe {
|
||||
filter: invert(100%) !important;
|
||||
-webkit-filter: invert(100%) !important;
|
||||
}
|
||||
|
||||
em,
|
||||
img,
|
||||
svg,
|
||||
form,
|
||||
image,
|
||||
video,
|
||||
audio,
|
||||
embed,
|
||||
object,
|
||||
button,
|
||||
canvas,
|
||||
figure:empty {
|
||||
opacity: 0.85;
|
||||
filter: invert(100%) !important;
|
||||
-webkit-filter: invert(100%) !important;
|
||||
}
|
||||
|
||||
form em,
|
||||
form img,
|
||||
form svg,
|
||||
form image,
|
||||
form video,
|
||||
form embed,
|
||||
form object,
|
||||
form button,
|
||||
form canvas,
|
||||
form figure:empty {
|
||||
filter: invert(0) !important;
|
||||
-webkit-filter: invert(0) !important;
|
||||
}
|
||||
|
||||
[style*='background:url']:not(html):not(body):not(input),
|
||||
[style*='background: url']:not(html):not(body):not(input),
|
||||
[style*='background-image']:not(html):not(body):not(input) {
|
||||
opacity: 0.8;
|
||||
filter: invert(100%) !important;
|
||||
-webkit-filter: invert(100%) !important;
|
||||
}
|
||||
|
||||
::-moz-scrollbar {background: #28292a !important}
|
||||
::-webkit-scrollbar {background: #28292a !important}
|
||||
::-moz-scrollbar-track {background: #343637 !important}
|
||||
::-webkit-scrollbar-track {background: #343637 !important}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #4d4e4f !important;
|
||||
border-left: 1px solid #343637 !important;
|
||||
border-right: 1px solid #343637 !important;
|
||||
}
|
||||
|
||||
::-moz-scrollbar-thumb {
|
||||
background: #4d4e4f !important;
|
||||
border-left: 1px solid #343637 !important;
|
||||
border-right: 1px solid #343637 !important;
|
||||
}
|
||||
28
App/SceneDelegate.swift
Normal file
28
App/SceneDelegate.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// SceneDelegate.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/21/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
let navigationController = UINavigationController()
|
||||
let browserViewController = BrowserViewController()
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)
|
||||
{
|
||||
guard let windowScene = (scene as? UIWindowScene) else { return }
|
||||
|
||||
navigationController.viewControllers = [ browserViewController ]
|
||||
navigationController.setNavigationBarHidden(true, animated: false)
|
||||
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
window.rootViewController = navigationController
|
||||
window.makeKeyAndVisible()
|
||||
self.window = window
|
||||
}
|
||||
}
|
||||
|
||||
70
App/Script Policy UI/ScriptControllerIconView.swift
Normal file
70
App/Script Policy UI/ScriptControllerIconView.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// ScriptControllerIconView.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/24/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ScriptControllerIconView: UIButton
|
||||
{
|
||||
public var shieldsDown: Bool = false {
|
||||
didSet { setNeedsLayout() }
|
||||
}
|
||||
|
||||
public var someScriptsAllowed: Bool = false {
|
||||
didSet { setNeedsLayout() }
|
||||
}
|
||||
|
||||
private let labelView = UILabel(frame: .zero)
|
||||
|
||||
private let shieldsDownImage = UIImage(systemName: "shield.slash")
|
||||
private let shieldsUpImage = UIImage(systemName: "shield")
|
||||
private let shieldsPartiallyUpImage = UIImage(systemName: "shield.lefthalf.fill")
|
||||
|
||||
convenience init() {
|
||||
self.init(frame: .zero)
|
||||
|
||||
addSubview(labelView)
|
||||
|
||||
imageView?.contentMode = .scaleAspectFit
|
||||
labelView.backgroundColor = .systemRed
|
||||
labelView.textAlignment = .center
|
||||
labelView.layer.cornerRadius = 4.0
|
||||
labelView.layer.masksToBounds = true
|
||||
labelView.font = .boldSystemFont(ofSize: 8)
|
||||
labelView.textColor = .white
|
||||
|
||||
setBlockedScriptsNumber(0)
|
||||
}
|
||||
|
||||
public func setBlockedScriptsNumber(_ num: Int) {
|
||||
if num > 0 {
|
||||
labelView.isHidden = false
|
||||
labelView.text = "\(num)"
|
||||
} else {
|
||||
labelView.isHidden = true
|
||||
}
|
||||
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
labelView.sizeToFit()
|
||||
labelView.center = CGPoint(x: bounds.center.x + 10, y: bounds.center.y + 10)
|
||||
labelView.bounds = labelView.bounds.insetBy(dx: -3.0, dy: -2.0)
|
||||
|
||||
if shieldsDown {
|
||||
setImage(shieldsDownImage, for: .normal)
|
||||
} else {
|
||||
if someScriptsAllowed {
|
||||
setImage(shieldsPartiallyUpImage, for: .normal)
|
||||
} else {
|
||||
setImage(shieldsUpImage, for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
App/Script Policy UI/ScriptPolicyControl.swift
Normal file
72
App/Script Policy UI/ScriptPolicyControl.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// ScriptPolicyControl.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/24/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ScriptPolicyControl: UIControl
|
||||
{
|
||||
enum PolicyStatus {
|
||||
case allowed
|
||||
case blocked
|
||||
}
|
||||
|
||||
var policyStatus: PolicyStatus = .blocked {
|
||||
didSet { setNeedsLayout() }
|
||||
}
|
||||
|
||||
private class PolicyButton: UIButton {
|
||||
override func imageRect(forContentRect contentRect: CGRect) -> CGRect {
|
||||
contentRect.insetBy(dx: 8.0, dy: 8.0)
|
||||
}
|
||||
}
|
||||
|
||||
private let allowButton = PolicyButton(frame: .zero)
|
||||
private let denyButton = PolicyButton(frame: .zero)
|
||||
|
||||
convenience init() {
|
||||
self.init(frame: .zero)
|
||||
|
||||
allowButton.addAction(UIAction(handler: { [unowned self] _ in
|
||||
self.policyStatus = .allowed
|
||||
self.sendActions(for: .valueChanged)
|
||||
}), for: .touchUpInside)
|
||||
allowButton.imageView?.contentMode = .scaleAspectFit
|
||||
addSubview(allowButton)
|
||||
|
||||
denyButton.addAction(UIAction(handler: { [unowned self] _ in
|
||||
self.policyStatus = .blocked
|
||||
self.sendActions(for: .valueChanged)
|
||||
}), for: .touchUpInside)
|
||||
denyButton.imageView?.contentMode = .scaleAspectFit
|
||||
addSubview(denyButton)
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: 100.0, height: UIView.noIntrinsicMetric)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
allowButton.frame = CGRect(origin: .zero, size: CGSize(width: bounds.width / 2, height: bounds.height))
|
||||
denyButton.frame = CGRect(origin: CGPoint(x: allowButton.frame.maxX, y: 0), size: allowButton.frame.size)
|
||||
|
||||
if policyStatus == .allowed {
|
||||
allowButton.tintColor = .blue
|
||||
allowButton.setImage(UIImage(systemName: "play.circle.fill"), for: .normal)
|
||||
|
||||
denyButton.tintColor = .darkGray
|
||||
denyButton.setImage(UIImage(systemName: "stop.circle"), for: .normal)
|
||||
} else {
|
||||
allowButton.tintColor = .darkGray
|
||||
allowButton.setImage(UIImage(systemName: "play.circle"), for: .normal)
|
||||
|
||||
denyButton.tintColor = .red
|
||||
denyButton.setImage(UIImage(systemName: "stop.circle.fill"), for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
220
App/Script Policy UI/ScriptPolicyViewController.swift
Normal file
220
App/Script Policy UI/ScriptPolicyViewController.swift
Normal file
@@ -0,0 +1,220 @@
|
||||
//
|
||||
// ScriptPolicyViewController.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/24/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol ScriptPolicyViewControllerDelegate: class {
|
||||
func didChangeScriptPolicy()
|
||||
func setScriptsEnabledForTab(_ enabled: Bool)
|
||||
}
|
||||
|
||||
class ScriptPolicyControlListCell: UICollectionViewListCell
|
||||
{
|
||||
var enabled: Bool = true {
|
||||
didSet {
|
||||
if enabled != oldValue {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
let policyControl = ScriptPolicyControl()
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(policyControl)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
|
||||
override func layoutSubviews() {
|
||||
let policyControlWidth = CGFloat(80.0)
|
||||
policyControl.frame = CGRect(x: bounds.maxX - policyControlWidth - layoutMargins.right, y: 0, width: policyControlWidth, height: bounds.height)
|
||||
bringSubviewToFront(policyControl)
|
||||
|
||||
super.layoutSubviews()
|
||||
|
||||
if enabled {
|
||||
contentView.alpha = 1.0
|
||||
policyControl.alpha = 1.0
|
||||
policyControl.isUserInteractionEnabled = true
|
||||
} else {
|
||||
contentView.alpha = 0.5
|
||||
policyControl.alpha = 0.5
|
||||
policyControl.isUserInteractionEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SwitchListCell: UICollectionViewListCell
|
||||
{
|
||||
let switchView = UISwitch(frame: .zero)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(switchView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
let switchWidth: CGFloat = switchView.sizeThatFits(bounds.size).width
|
||||
switchView.frame = CGRect(x: bounds.maxX - switchWidth - layoutMargins.right, y: 0, width: switchWidth, height: bounds.height)
|
||||
switchView.frame = switchView.frame.centeredY(inRect: bounds)
|
||||
contentView.frame = CGRect(origin: contentView.frame.origin, size: CGSize(width: switchView.frame.minX, height: contentView.frame.height))
|
||||
}
|
||||
}
|
||||
|
||||
class ScriptPolicyViewController: UIViewController, UICollectionViewDelegate
|
||||
{
|
||||
var collectionView: UICollectionView?
|
||||
var allowScriptsForTab = false
|
||||
weak var delegate: ScriptPolicyViewControllerDelegate? = nil
|
||||
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, String>?
|
||||
private var didChangeScriptPolicy = false
|
||||
|
||||
private enum Section: Int {
|
||||
case tabOptions
|
||||
case origins
|
||||
}
|
||||
|
||||
private static let enableScriptsForTabItem: String = "enableScriptsForTab"
|
||||
|
||||
convenience init(policyManager: ResourcePolicyManager, hostOrigin: String, loadedScripts: Set<String>, scriptsAllowedForTab: Bool) {
|
||||
self.init(nibName: nil, bundle: nil)
|
||||
allowScriptsForTab = scriptsAllowedForTab
|
||||
|
||||
let listConfig = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
let listLayout = UICollectionViewCompositionalLayout.list(using: listConfig)
|
||||
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: listLayout)
|
||||
|
||||
// Make sure host origin goes first in the list.
|
||||
let otherOriginScripts = loadedScripts.subtracting([ hostOrigin ])
|
||||
let originItems = [ hostOrigin ] + otherOriginScripts
|
||||
|
||||
let switchCellRegistry = UICollectionView.CellRegistration<SwitchListCell, String> { [unowned self] (listCell, indexPath, item) in
|
||||
var config = listCell.defaultContentConfiguration()
|
||||
if item == Self.enableScriptsForTabItem {
|
||||
config.text = "Allow for Tab"
|
||||
listCell.switchView.isOn = self.allowScriptsForTab
|
||||
listCell.switchView.addAction(.init(handler: { _ in
|
||||
let enabled = listCell.switchView.isOn
|
||||
|
||||
self.allowScriptsForTab = enabled
|
||||
self.didChangeScriptPolicy = true
|
||||
|
||||
if var snapshot = self.dataSource?.snapshot() {
|
||||
if enabled {
|
||||
// Hide script origins
|
||||
snapshot.deleteSections([ .origins ])
|
||||
} else {
|
||||
if !snapshot.sectionIdentifiers.contains(.origins) {
|
||||
snapshot.appendSections([ .origins ])
|
||||
}
|
||||
snapshot.appendItems(originItems, toSection: .origins)
|
||||
}
|
||||
|
||||
self.dataSource?.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
}), for: .valueChanged)
|
||||
}
|
||||
|
||||
listCell.contentConfiguration = config
|
||||
}
|
||||
|
||||
let scriptPolicyRegistry = UICollectionView.CellRegistration<ScriptPolicyControlListCell, String> { [unowned self] (listCell, indexPath, item) in
|
||||
var config = listCell.defaultContentConfiguration()
|
||||
config.text = item
|
||||
|
||||
listCell.contentConfiguration = config
|
||||
|
||||
if policyManager.allowedOriginsForScriptResources().contains(item) {
|
||||
listCell.policyControl.policyStatus = .allowed
|
||||
} else {
|
||||
listCell.policyControl.policyStatus = .blocked
|
||||
}
|
||||
|
||||
listCell.policyControl.addAction(UIAction(handler: { _ in
|
||||
let allowed: Bool = listCell.policyControl.policyStatus == .allowed
|
||||
|
||||
if allowed {
|
||||
policyManager.allowOriginToLoadScriptResources(item)
|
||||
} else {
|
||||
policyManager.disallowOriginToLoadScriptResources(item)
|
||||
}
|
||||
|
||||
if item == hostOrigin {
|
||||
if var snapshot = self.dataSource?.snapshot() {
|
||||
snapshot.reloadItems(Array(otherOriginScripts))
|
||||
self.dataSource?.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
}
|
||||
|
||||
self.didChangeScriptPolicy = true
|
||||
}), for: .valueChanged)
|
||||
|
||||
if item != hostOrigin {
|
||||
listCell.enabled = policyManager.allowedOriginsForScriptResources().contains(hostOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
let dataSource = UICollectionViewDiffableDataSource<Section, String>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
|
||||
if item == Self.enableScriptsForTabItem {
|
||||
return collectionView.dequeueConfiguredReusableCell(using: switchCellRegistry, for: indexPath, item: item)
|
||||
}
|
||||
|
||||
return collectionView.dequeueConfiguredReusableCell(using: scriptPolicyRegistry, for: indexPath, item: item)
|
||||
}
|
||||
|
||||
collectionView.dataSource = dataSource
|
||||
collectionView.delegate = self
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.appendSections([ .tabOptions ])
|
||||
snapshot.appendItems([ Self.enableScriptsForTabItem ], toSection: .tabOptions)
|
||||
|
||||
if !allowScriptsForTab {
|
||||
snapshot.appendSections([ .origins ])
|
||||
snapshot.appendItems(originItems, toSection: .origins)
|
||||
}
|
||||
|
||||
dataSource.apply(snapshot)
|
||||
|
||||
self.dataSource = dataSource
|
||||
self.collectionView = collectionView
|
||||
|
||||
title = "Script Origin Policy"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done, primaryAction: UIAction(handler: { [unowned self] action in
|
||||
if self.didChangeScriptPolicy {
|
||||
self.delegate?.didChangeScriptPolicy()
|
||||
self.delegate?.setScriptsEnabledForTab(self.allowScriptsForTab)
|
||||
}
|
||||
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
}), menu: nil)
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
self.view = collectionView
|
||||
}
|
||||
|
||||
// MARK: UICollectionViewDelegate
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
App/Supporting Files/Assets.xcassets/Contents.json
Normal file
6
App/Supporting Files/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
25
App/Supporting Files/Base.lproj/LaunchScreen.storyboard
Normal file
25
App/Supporting Files/Base.lproj/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
66
App/Supporting Files/Info.plist
Normal file
66
App/Supporting Files/Info.plist
Normal file
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>rössler\\attix</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
8
App/Supporting Files/SBrowser-Bridging-Header.h
Normal file
8
App/Supporting Files/SBrowser-Bridging-Header.h
Normal file
@@ -0,0 +1,8 @@
|
||||
//
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
|
||||
#import "SBRProcessBundleBridge.h"
|
||||
|
||||
// SPI
|
||||
#import <UIKit/UITextField_Private.h>
|
||||
10
App/Supporting Files/SBrowser.entitlements
Normal file
10
App/Supporting Files/SBrowser.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
111
App/Tabs/Tab.swift
Normal file
111
App/Tabs/Tab.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// Tab.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/29/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
protocol TabDelegate: class
|
||||
{
|
||||
func didBlockScriptOrigin(_ origin: String, forTab: Tab)
|
||||
}
|
||||
|
||||
class Tab: NSObject, SBRProcessBundleBridgeDelegate
|
||||
{
|
||||
public weak var delegate: TabDelegate?
|
||||
|
||||
public let homeURL: URL?
|
||||
public let bridge = SBRProcessBundleBridge()
|
||||
public var webView: WKWebView {
|
||||
if self.loadedWebView == nil {
|
||||
self.loadedWebView = bridge.webView
|
||||
|
||||
if let homeURL = homeURL {
|
||||
beginLoadingURL(homeURL)
|
||||
}
|
||||
}
|
||||
|
||||
return bridge.webView
|
||||
}
|
||||
public var policyManager: ResourcePolicyManager
|
||||
|
||||
private var loadedWebView: WKWebView? = nil
|
||||
public var title: String? { loadedWebView?.title }
|
||||
public var url: URL? { loadedWebView?.url ?? self.homeURL }
|
||||
|
||||
public var javaScriptEnabled: Bool = false {
|
||||
didSet { bridge.allowAllScripts = javaScriptEnabled }
|
||||
}
|
||||
|
||||
public var identifier = UUID()
|
||||
|
||||
public var favicon: UIImage?
|
||||
private var faviconHost: String?
|
||||
private var faviconRequest: AnyCancellable?
|
||||
|
||||
public var allowedScriptOrigins = Set<String>()
|
||||
public var blockedScriptOrigins = Set<String>()
|
||||
|
||||
private var titleObservation: NSKeyValueObservation?
|
||||
private var urlObservation: NSKeyValueObservation?
|
||||
|
||||
convenience init(policyManager: ResourcePolicyManager) {
|
||||
self.init(url: nil, policyManager: policyManager)
|
||||
}
|
||||
|
||||
convenience init(urlString: String, policyManager: ResourcePolicyManager) {
|
||||
self.init(url: URL(string: urlString), policyManager: policyManager)
|
||||
}
|
||||
|
||||
init(url: URL?, policyManager: ResourcePolicyManager) {
|
||||
self.homeURL = url
|
||||
self.policyManager = policyManager
|
||||
bridge.policyDataSource = policyManager
|
||||
|
||||
super.init()
|
||||
|
||||
bridge.delegate = self
|
||||
}
|
||||
|
||||
deinit {
|
||||
bridge.tearDown()
|
||||
}
|
||||
|
||||
func beginLoadingURL(_ url: URL) {
|
||||
let request = URLRequest(url: url)
|
||||
webView.load(request)
|
||||
}
|
||||
|
||||
// MARK: SBRProcessBundleBridgeDelegate
|
||||
|
||||
func webProcess(_ bridge: SBRProcessBundleBridge, didAllowScriptResourceFromOrigin origin: String) {
|
||||
print("Allowed script resource from origin: \(origin)")
|
||||
allowedScriptOrigins.formUnion([ origin ])
|
||||
}
|
||||
|
||||
func webProcess(_ bridge: SBRProcessBundleBridge, didBlockScriptResourceFromOrigin origin: String) {
|
||||
print("Blocked script resource from origin: \(origin)")
|
||||
blockedScriptOrigins.formUnion([ origin ])
|
||||
delegate?.didBlockScriptOrigin(origin, forTab: self)
|
||||
}
|
||||
|
||||
func updateFaviconForURL(_ url: URL) {
|
||||
if let faviconHost = faviconHost, url.host == faviconHost {} else {
|
||||
guard var faviconURLComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
|
||||
faviconURLComponents.path = "/favicon.ico"
|
||||
|
||||
let defaultImage = UIImage(systemName: "globe")
|
||||
guard let faviconURL = faviconURLComponents.url else { return }
|
||||
faviconRequest = URLSession.shared.dataTaskPublisher(for: faviconURL)
|
||||
.map { (data: Data, response: URLResponse) -> UIImage? in
|
||||
UIImage(data: data)
|
||||
}
|
||||
.replaceError(with: defaultImage)
|
||||
.replaceNil(with: defaultImage)
|
||||
.assign(to: \.favicon, on: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
40
App/Tabs/TabController.swift
Normal file
40
App/Tabs/TabController.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// TabController.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/30/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class TabController
|
||||
{
|
||||
var tabs: [Tab] = []
|
||||
var policyManager = ResourcePolicyManager()
|
||||
|
||||
init() {
|
||||
// TODO: load tabs from disk.
|
||||
_ = createNewTab(url: nil)
|
||||
}
|
||||
|
||||
func tab(forURL url: URL) -> Tab? {
|
||||
tabs.first { $0.url == url }
|
||||
}
|
||||
|
||||
func tab(forIdentifier identifier: UUID) -> Tab? {
|
||||
tabs.first { $0.identifier == identifier }
|
||||
}
|
||||
|
||||
func createNewTab(url: URL?) -> Tab {
|
||||
let tab = Tab(url: url, policyManager: policyManager)
|
||||
tabs.append(tab)
|
||||
|
||||
return tab
|
||||
}
|
||||
|
||||
func closeTab(_ tab: Tab) {
|
||||
if let index = tabs.firstIndex(of: tab) {
|
||||
tabs.remove(at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
129
App/Tabs/TabPickerViewController.swift
Normal file
129
App/Tabs/TabPickerViewController.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// TabPickerViewController.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/30/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol TabPickerViewControllerDelegate: class
|
||||
{
|
||||
func tabPicker(_ picker: TabPickerViewController, didSelectTab tab: Tab)
|
||||
func tabPicker(_ picker: TabPickerViewController, willCloseTab tab: Tab)
|
||||
}
|
||||
|
||||
class TabPickerViewController: UIViewController, UICollectionViewDelegate
|
||||
{
|
||||
let tabController: TabController!
|
||||
var selectedTab: Tab?
|
||||
|
||||
weak var delegate: TabPickerViewControllerDelegate?
|
||||
|
||||
typealias TabID = UUID
|
||||
|
||||
private var collectionView: UICollectionView?
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Int, TabID>?
|
||||
|
||||
init(tabController: TabController) {
|
||||
self.tabController = tabController
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.title = "Tabs"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
var listConfig = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
listConfig.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in
|
||||
if self.dataSource?.snapshot().numberOfItems ?? 0 <= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return UISwipeActionsConfiguration(actions: [ UIContextualAction(style: .destructive, title: "Close", handler: { [unowned self] (action, view, completionHandler) in
|
||||
if let item = self.dataSource?.itemIdentifier(for: indexPath), var snapshot = self.dataSource?.snapshot() {
|
||||
if let tab = self.tabController.tab(forIdentifier: item) {
|
||||
self.delegate?.tabPicker(self, willCloseTab: tab)
|
||||
|
||||
self.tabController.closeTab(tab)
|
||||
snapshot.deleteItems([ item ])
|
||||
self.dataSource?.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
}
|
||||
})])
|
||||
}
|
||||
|
||||
let listLayout = UICollectionViewCompositionalLayout.list(using: listConfig)
|
||||
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: listLayout)
|
||||
|
||||
let registry = UICollectionView.CellRegistration<UICollectionViewListCell, TabID> { [unowned self] (listCell, indexPath, item) in
|
||||
var config = listCell.defaultContentConfiguration()
|
||||
|
||||
if let tab = self.tabController.tab(forIdentifier: item) {
|
||||
if let title = tab.title, title.count > 0 {
|
||||
config.text = title
|
||||
config.secondaryText = tab.url?.absoluteString
|
||||
} else if let url = tab.url {
|
||||
config.text = url.absoluteString
|
||||
config.secondaryText = url.absoluteString
|
||||
} else {
|
||||
config.text = "New Tab"
|
||||
}
|
||||
|
||||
config.textProperties.numberOfLines = 1
|
||||
config.secondaryTextProperties.numberOfLines = 1
|
||||
|
||||
if let image = tab.favicon {
|
||||
config.image = image
|
||||
} else {
|
||||
config.image = UIImage(systemName: "safari")
|
||||
}
|
||||
|
||||
config.imageProperties.maximumSize = CGSize(width: 21.0, height: 21.0)
|
||||
config.imageProperties.cornerRadius = 3.0
|
||||
|
||||
if tab == self.selectedTab {
|
||||
listCell.accessories = [ .checkmark() ]
|
||||
} else {
|
||||
listCell.accessories = []
|
||||
}
|
||||
}
|
||||
|
||||
listCell.contentConfiguration = config
|
||||
}
|
||||
|
||||
let dataSource = UICollectionViewDiffableDataSource<Int, TabID>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
|
||||
return collectionView.dequeueConfiguredReusableCell(using: registry, for: indexPath, item: item)
|
||||
}
|
||||
|
||||
collectionView.dataSource = dataSource
|
||||
collectionView.delegate = self
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.appendSections([ 0 ])
|
||||
tabController.tabs.forEach { tab in
|
||||
snapshot.appendItems([ tab.identifier ])
|
||||
}
|
||||
|
||||
dataSource.apply(snapshot)
|
||||
|
||||
self.dataSource = dataSource
|
||||
self.collectionView = collectionView
|
||||
self.view = self.collectionView
|
||||
|
||||
let newTabButton = UIBarButtonItem(systemItem: .add, primaryAction: UIAction(handler: { [unowned self] _ in
|
||||
let newTab = self.tabController.createNewTab(url: nil)
|
||||
self.delegate?.tabPicker(self, didSelectTab: newTab)
|
||||
}), menu: nil)
|
||||
|
||||
navigationItem.rightBarButtonItem = newTabButton
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
let tab = tabController.tabs[indexPath.row]
|
||||
delegate?.tabPicker(self, didSelectTab: tab)
|
||||
}
|
||||
}
|
||||
62
App/Titlebar and URL Bar/TitlebarView.swift
Normal file
62
App/Titlebar and URL Bar/TitlebarView.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// TitlebarView.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/29/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class TitlebarView: UIView
|
||||
{
|
||||
public let titleLabelView = UILabel(frame: .zero)
|
||||
private let backgroundImageView = UIImageView(frame: .zero)
|
||||
|
||||
convenience init() {
|
||||
self.init(frame: .zero)
|
||||
addSubview(backgroundImageView)
|
||||
addSubview(titleLabelView)
|
||||
|
||||
titleLabelView.textColor = .white
|
||||
titleLabelView.layer.shadowColor = UIColor.black.cgColor
|
||||
titleLabelView.layer.shadowRadius = 0.0
|
||||
titleLabelView.layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
|
||||
titleLabelView.font = UIFont.boldSystemFont(ofSize: 12.0)
|
||||
|
||||
backgroundImageView.alpha = 0.98
|
||||
}
|
||||
|
||||
private func backgroundImageForSize(_ size: CGSize) -> UIImage? {
|
||||
var image: UIImage? = nil
|
||||
|
||||
UIGraphicsBeginImageContext(CGSize(width: size.width, height: 1.0))
|
||||
if let context = UIGraphicsGetCurrentContext() {
|
||||
let gradientColorsArray = [
|
||||
UIColor(red: 0.101, green: 0.176, blue: 0.415, alpha: 1.0).cgColor,
|
||||
UIColor(red: 0.153, green: 0.000, blue: 0.153, alpha: 1.0).cgColor
|
||||
]
|
||||
|
||||
if let gradient = CGGradient(colorsSpace: nil, colors: gradientColorsArray as CFArray, locations: nil) {
|
||||
context.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions())
|
||||
}
|
||||
|
||||
image = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext();
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
backgroundImageView.frame = bounds
|
||||
titleLabelView.frame = bounds.avoiding(verticalInsets: safeAreaInsets).insetBy(dx: 8.0 + layoutMargins.left, dy: 0.0)
|
||||
|
||||
if let image = backgroundImageView.image, image.size == bounds.size {
|
||||
// No op
|
||||
} else {
|
||||
backgroundImageView.image = backgroundImageForSize(bounds.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
242
App/Titlebar and URL Bar/ToolbarViewController.swift
Normal file
242
App/Titlebar and URL Bar/ToolbarViewController.swift
Normal file
@@ -0,0 +1,242 @@
|
||||
//
|
||||
// ToolbarViewController.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/23/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ToolbarButtonView: UIView
|
||||
{
|
||||
private var buttonPadding = CGFloat(24.0)
|
||||
private var buttonViews: [UIView] = []
|
||||
|
||||
public var numberOfButtonViews: Int { buttonViews.count }
|
||||
|
||||
func addButtonView(_ button: UIView) {
|
||||
buttonViews.append(button)
|
||||
addSubview(button)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func removeAllButtonViews() {
|
||||
buttonViews.forEach { $0.removeFromSuperview() }
|
||||
buttonViews.removeAll()
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||
let width: CGFloat = buttonViews.reduce(0.0) { (result, button) -> CGFloat in
|
||||
return result + button.sizeThatFits(size).width + buttonPadding
|
||||
}
|
||||
|
||||
return CGSize(width: width, height: size.height)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
var buttonRect = CGRect(origin: .zero, size: CGSize(width: 0, height: bounds.height))
|
||||
buttonRect.origin.x = layoutMargins.left
|
||||
|
||||
for button in buttonViews {
|
||||
let buttonSize = button.sizeThatFits(bounds.size)
|
||||
buttonRect.size = CGSize(width: buttonSize.width, height: bounds.height)
|
||||
button.frame = buttonRect
|
||||
|
||||
buttonRect.origin.x += buttonRect.width + buttonPadding
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarView: UIView
|
||||
{
|
||||
var urlBar: URLBar? { didSet { containerView.addSubview(urlBar!) } }
|
||||
|
||||
var cancelButtonVisible: Bool = false { didSet { layoutSubviews() } }
|
||||
|
||||
let containerView = UIView(frame: .zero)
|
||||
let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThickMaterial))
|
||||
let cancelButton = UIButton(type: .system)
|
||||
|
||||
let leadingButtonsView = ToolbarButtonView(frame: .zero)
|
||||
let trailingButtonsView = ToolbarButtonView(frame: .zero)
|
||||
|
||||
convenience init()
|
||||
{
|
||||
self.init(frame: .zero)
|
||||
addSubview(backgroundView)
|
||||
addSubview(containerView)
|
||||
|
||||
containerView.addSubview(leadingButtonsView)
|
||||
containerView.addSubview(trailingButtonsView)
|
||||
|
||||
cancelButton.setTitle("Cancel", for: .normal)
|
||||
containerView.addSubview(cancelButton)
|
||||
|
||||
layer.masksToBounds = false
|
||||
layer.shadowColor = UIColor.black.cgColor
|
||||
layer.shadowOpacity = 0.2
|
||||
layer.shadowOffset = CGSize(width: 0.0, height: 1.0)
|
||||
layer.shadowRadius = 1.5
|
||||
}
|
||||
|
||||
override func sizeThatFits(_ size: CGSize) -> CGSize
|
||||
{
|
||||
return CGSize(width: size.width, height: 44.0)
|
||||
}
|
||||
|
||||
override func layoutSubviews()
|
||||
{
|
||||
super.layoutSubviews()
|
||||
|
||||
backgroundView.frame = bounds
|
||||
|
||||
var containerBounds = bounds
|
||||
containerBounds.size.height -= safeAreaInsets.bottom
|
||||
containerView.frame = containerBounds
|
||||
containerView.frame = containerView.frame.insetBy(dx: 8.0, dy: 4.0)
|
||||
|
||||
// Cancel button
|
||||
let urlBarPadding: CGFloat = 8.0
|
||||
var cancelButtonSize = cancelButton.sizeThatFits(containerView.bounds.size)
|
||||
cancelButtonSize.width += (urlBarPadding * 2)
|
||||
cancelButton.frame = CGRect(origin: CGPoint(x: (containerView.bounds.maxX - cancelButtonSize.width), y: 0),
|
||||
size: CGSize(width: cancelButtonSize.width + urlBarPadding, height: containerView.bounds.height))
|
||||
|
||||
// Leading toolbar buttons
|
||||
if leadingButtonsView.numberOfButtonViews > 0 {
|
||||
let leadingContainerSize = leadingButtonsView.sizeThatFits(containerView.bounds.size)
|
||||
leadingButtonsView.frame = CGRect(origin: .zero, size: leadingContainerSize)
|
||||
} else {
|
||||
leadingButtonsView.frame = .zero
|
||||
}
|
||||
|
||||
// Trailing toolbar buttons
|
||||
let trailingContainerSize = trailingButtonsView.sizeThatFits(containerView.bounds.size)
|
||||
trailingButtonsView.frame = CGRect(origin: CGPoint(x: (containerView.bounds.maxX - trailingContainerSize.width) + urlBarPadding, y: 0), size: trailingContainerSize)
|
||||
|
||||
var avoidingSize: CGSize = .zero
|
||||
if cancelButtonVisible {
|
||||
cancelButton.alpha = 1.0
|
||||
trailingButtonsView.alpha = 0.0
|
||||
|
||||
avoidingSize = cancelButtonSize
|
||||
} else {
|
||||
cancelButton.alpha = 0.0
|
||||
trailingButtonsView.alpha = 1.0
|
||||
|
||||
avoidingSize = trailingContainerSize
|
||||
}
|
||||
|
||||
if let urlBar = urlBar {
|
||||
let origin = CGPoint(
|
||||
x: leadingButtonsView.frame.maxX,
|
||||
y: 0.0
|
||||
)
|
||||
|
||||
urlBar.frame = CGRect(
|
||||
origin: origin,
|
||||
size: CGSize(
|
||||
width: containerView.bounds.width - avoidingSize.width - origin.x,
|
||||
height: containerView.bounds.height
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarViewController: UIViewController
|
||||
{
|
||||
let urlBar = URLBar()
|
||||
let toolbarView = ToolbarView()
|
||||
let scriptControllerIconView = ScriptControllerIconView()
|
||||
let shareButton = UIButton(frame: .zero)
|
||||
let darkModeButton = UIButton(frame: .zero)
|
||||
let windowButton = UIButton(frame: .zero)
|
||||
let backButton = UIButton(frame: .zero)
|
||||
let forwardButton = UIButton(frame: .zero)
|
||||
let newTabButton = UIButton(frame: .zero)
|
||||
|
||||
var darkModeEnabled: Bool = false {
|
||||
didSet {
|
||||
if darkModeEnabled {
|
||||
darkModeButton.setImage(darkModeEnabledImage, for: .normal)
|
||||
} else {
|
||||
darkModeButton.setImage(darkModeDisabledImage, for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let darkModeDisabledImage = UIImage(systemName: "moon.circle")
|
||||
private let darkModeEnabledImage = UIImage(systemName: "moon.circle.fill")
|
||||
|
||||
init() {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
toolbarView.urlBar = urlBar
|
||||
|
||||
// Dark mode button
|
||||
darkModeButton.setImage(darkModeDisabledImage, for: .normal)
|
||||
|
||||
// Share button
|
||||
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
|
||||
|
||||
// Window button
|
||||
windowButton.setImage(UIImage(systemName: "rectangle.on.rectangle"), for: .normal)
|
||||
|
||||
// Back button
|
||||
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||||
|
||||
// Forward button
|
||||
forwardButton.setImage(UIImage(systemName: "chevron.right"), for: .normal)
|
||||
|
||||
// New tab button
|
||||
newTabButton.setImage(UIImage(systemName: "plus"), for: .normal)
|
||||
|
||||
let toolbarAnimationDuration: TimeInterval = 0.3
|
||||
urlBar.textField.addAction(.init(handler: { [traitCollection, toolbarView, urlBar] _ in
|
||||
if traitCollection.horizontalSizeClass == .compact {
|
||||
UIView.animate(withDuration: toolbarAnimationDuration) {
|
||||
toolbarView.cancelButtonVisible = urlBar.textField.isFirstResponder
|
||||
}
|
||||
}
|
||||
}), for: [ .editingDidBegin, .editingDidEnd ])
|
||||
|
||||
toolbarView.cancelButton.addAction(.init(handler: { [urlBar] action in
|
||||
urlBar.textField.resignFirstResponder()
|
||||
}), for: .touchUpInside)
|
||||
|
||||
traitCollectionDidChange(nil)
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
toolbarView.leadingButtonsView.removeAllButtonViews()
|
||||
toolbarView.trailingButtonsView.removeAllButtonViews()
|
||||
|
||||
// Setup toolbar based on trait collection
|
||||
if traitCollection.horizontalSizeClass == .compact {
|
||||
toolbarView.trailingButtonsView.addButtonView(darkModeButton)
|
||||
toolbarView.trailingButtonsView.addButtonView(scriptControllerIconView)
|
||||
toolbarView.trailingButtonsView.addButtonView(windowButton)
|
||||
} else {
|
||||
toolbarView.leadingButtonsView.addButtonView(backButton)
|
||||
toolbarView.leadingButtonsView.addButtonView(forwardButton)
|
||||
|
||||
toolbarView.trailingButtonsView.addButtonView(darkModeButton)
|
||||
toolbarView.trailingButtonsView.addButtonView(shareButton)
|
||||
toolbarView.trailingButtonsView.addButtonView(scriptControllerIconView)
|
||||
toolbarView.trailingButtonsView.addButtonView(newTabButton)
|
||||
toolbarView.trailingButtonsView.addButtonView(windowButton)
|
||||
}
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
self.view = toolbarView
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
163
App/Titlebar and URL Bar/URLBar.swift
Normal file
163
App/Titlebar and URL Bar/URLBar.swift
Normal file
@@ -0,0 +1,163 @@
|
||||
//
|
||||
// URLBar.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/23/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class URLBar: UIView
|
||||
{
|
||||
let textField = UITextField(frame: .zero)
|
||||
let refreshButton = UIButton(frame: .zero)
|
||||
|
||||
public enum LoadProgress {
|
||||
case complete
|
||||
case loading(progress: Double)
|
||||
}
|
||||
|
||||
public var loadProgress: LoadProgress = .complete {
|
||||
didSet { updateProgressIndicator() }
|
||||
}
|
||||
|
||||
private let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThickMaterial))
|
||||
private let progressIndicatorView = ProgressIndicatorView()
|
||||
private let fadeMaskView = UIImageView(frame: .zero)
|
||||
|
||||
private let refreshImage = UIImage(systemName: "arrow.clockwise")
|
||||
private let stopImage = UIImage(systemName: "xmark")
|
||||
|
||||
private let backgroundCornerRadius: CGFloat = 8
|
||||
|
||||
convenience init() {
|
||||
self.init(frame: .zero)
|
||||
|
||||
backgroundColor = .clear
|
||||
|
||||
backgroundView.layer.masksToBounds = true
|
||||
backgroundView.layer.cornerRadius = backgroundCornerRadius
|
||||
backgroundView.layer.borderWidth = 1
|
||||
backgroundView.layer.borderColor = UIColor.systemFill.cgColor
|
||||
backgroundView.isUserInteractionEnabled = false
|
||||
addSubview(backgroundView)
|
||||
|
||||
backgroundView.contentView.addSubview(progressIndicatorView)
|
||||
|
||||
textField.backgroundColor = .clear
|
||||
textField.textContentType = .URL
|
||||
textField.keyboardType = .webSearch
|
||||
textField.autocorrectionType = .no
|
||||
textField.autocapitalizationType = .none
|
||||
textField.font = .systemFont(ofSize: 14.0)
|
||||
textField.clearingBehavior = .clearOnInsertionAndShowSelectionTint
|
||||
textField.clearButtonMode = .whileEditing
|
||||
textField.addAction(UIAction(handler: { [unowned self] _ in
|
||||
// Mask view visibility is affected by editing state.
|
||||
self.layoutSubviews()
|
||||
}), for: [ .editingDidBegin, .editingDidEnd ])
|
||||
addSubview(textField)
|
||||
|
||||
textField.addAction(.init(handler: { [textField, refreshButton] _ in
|
||||
refreshButton.isHidden = textField.isFirstResponder
|
||||
}), for: [ .editingDidBegin, .editingDidEnd ])
|
||||
|
||||
refreshButton.tintColor = .secondaryLabel
|
||||
refreshButton.setImage(refreshImage, for: .normal)
|
||||
addSubview(refreshButton)
|
||||
}
|
||||
|
||||
private func updateProgressIndicator() {
|
||||
UIView.animate(withDuration: 0.4) {
|
||||
switch self.loadProgress {
|
||||
case .complete:
|
||||
self.refreshButton.setImage(self.refreshImage, for: .normal)
|
||||
self.progressIndicatorView.progress = 1.0
|
||||
UIView.animate(withDuration: 0.5, delay: 0.5, options: AnimationOptions()) {
|
||||
self.progressIndicatorView.alpha = 0.0
|
||||
} completion: { _ in
|
||||
// Reset back to zero
|
||||
self.progressIndicatorView.progress = 0.0
|
||||
}
|
||||
case .loading(let progress):
|
||||
self.refreshButton.setImage(self.stopImage, for: .normal)
|
||||
self.progressIndicatorView.progress = progress
|
||||
self.progressIndicatorView.alpha = 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
let preferredHeight = CGFloat(34)
|
||||
return CGSize(width: 1000.0, height: preferredHeight)
|
||||
}
|
||||
|
||||
private func fadeBackgroundImageForSize(_ size: CGSize) -> UIImage? {
|
||||
var image: UIImage? = nil
|
||||
|
||||
UIGraphicsBeginImageContext(CGSize(width: size.width, height: 1.0))
|
||||
if let context = UIGraphicsGetCurrentContext() {
|
||||
let gradientColorsArray = [
|
||||
UIColor(white: 1.0, alpha: 1.0).cgColor,
|
||||
UIColor(white: 1.0, alpha: 1.0).cgColor,
|
||||
UIColor(white: 1.0, alpha: 0.08).cgColor,
|
||||
UIColor(white: 1.0, alpha: 0.08).cgColor
|
||||
]
|
||||
|
||||
let locations: [CGFloat] = [
|
||||
0.0, 0.80, 0.90, 1.0
|
||||
]
|
||||
|
||||
if let gradient = CGGradient(colorsSpace: nil, colors: gradientColorsArray as CFArray, locations: locations) {
|
||||
context.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions())
|
||||
}
|
||||
|
||||
image = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext();
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
backgroundView.frame = bounds
|
||||
progressIndicatorView.frame = backgroundView.contentView.bounds
|
||||
textField.frame = bounds.insetBy(dx: 6.0, dy: 0)
|
||||
|
||||
fadeMaskView.frame = textField.bounds
|
||||
fadeMaskView.image = fadeBackgroundImageForSize(fadeMaskView.frame.size)
|
||||
if !textField.isFirstResponder {
|
||||
textField.mask = fadeMaskView
|
||||
} else {
|
||||
textField.mask = nil
|
||||
}
|
||||
|
||||
let refreshButtonSize = CGSize(width: textField.frame.height, height: textField.frame.height)
|
||||
refreshButton.frame = CGRect(origin: CGPoint(x: bounds.width - refreshButtonSize.width, y: 0), size: refreshButtonSize)
|
||||
}
|
||||
}
|
||||
|
||||
class ProgressIndicatorView: UIView
|
||||
{
|
||||
public var progress: Double = 0.0 {
|
||||
didSet { layoutSubviews() }
|
||||
}
|
||||
|
||||
private let progressFillView = UIView(frame: .zero)
|
||||
|
||||
convenience init() {
|
||||
self.init(frame: .zero)
|
||||
|
||||
progressFillView.backgroundColor = .systemBlue
|
||||
progressFillView.alpha = 0.3
|
||||
addSubview(progressFillView)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
let width = CGFloat(progress) * bounds.width
|
||||
progressFillView.frame = CGRect(origin: .zero, size: CGSize(width: width, height: bounds.height))
|
||||
}
|
||||
}
|
||||
32
App/Utilities/CGPoint+Utils.swift
Normal file
32
App/Utilities/CGPoint+Utils.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// CGPoint+Utils.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/23/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension CGRect
|
||||
{
|
||||
var center: CGPoint {
|
||||
get {
|
||||
return CGPoint(x: size.width / 2.0, y: size.height / 2.0)
|
||||
}
|
||||
}
|
||||
|
||||
public func avoiding(verticalInsets insets: UIEdgeInsets) -> CGRect {
|
||||
var rect = self
|
||||
rect.origin.y += insets.top
|
||||
rect.size.height -= insets.top
|
||||
|
||||
return rect
|
||||
}
|
||||
|
||||
public func centeredY(inRect: CGRect) -> CGRect {
|
||||
var rect = self
|
||||
rect.origin.y = CGRound((inRect.height - rect.height) / 2.0)
|
||||
|
||||
return rect
|
||||
}
|
||||
}
|
||||
24
App/Utilities/UIEdgeInsets+Layout.swift
Normal file
24
App/Utilities/UIEdgeInsets+Layout.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// UIEdgeInsets+Layout.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/23/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIEdgeInsets
|
||||
{
|
||||
var negative: UIEdgeInsets {
|
||||
get {
|
||||
return UIEdgeInsets(top: -top, left: -left, bottom: -bottom, right: -right)
|
||||
}
|
||||
}
|
||||
|
||||
func subtracting(_ other: UIEdgeInsets) -> UIEdgeInsets {
|
||||
return UIEdgeInsets(top: self.top - other.top,
|
||||
left: self.left - other.left,
|
||||
bottom: self.bottom - other.bottom,
|
||||
right: self.right - other.right)
|
||||
}
|
||||
}
|
||||
16
App/Utilities/UIGestureRecognizer+Actions.swift
Normal file
16
App/Utilities/UIGestureRecognizer+Actions.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// UIGestureRecognizer+Actions.swift
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/31/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIGestureRecognizer
|
||||
{
|
||||
convenience init(action: UIAction) {
|
||||
self.init(target: action, action: NSSelectorFromString("_performActionWithSender:"))
|
||||
objc_setAssociatedObject(self, "associatedUIAction", action, .OBJC_ASSOCIATION_RETAIN)
|
||||
}
|
||||
}
|
||||
40
App/Web Process Bundle Bridge/SBRProcessBundleBridge.h
Normal file
40
App/Web Process Bundle Bridge/SBRProcessBundleBridge.h
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// SBRProcessBundleBridge.h
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/22/20.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <WebKit/WebKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@protocol SBRResourceOriginPolicyDataSource <NSObject>
|
||||
|
||||
/// Returns a list of origins (e.g., "buzzert.net") for which we are allowed to load script resources from
|
||||
- (NSSet<NSString *> *)allowedOriginsForScriptResources;
|
||||
|
||||
@end
|
||||
|
||||
@class SBRProcessBundleBridge;
|
||||
@protocol SBRProcessBundleBridgeDelegate <NSObject>
|
||||
- (void)webProcess:(SBRProcessBundleBridge *)bridge didAllowScriptResourceFromOrigin:(NSString *)origin;
|
||||
- (void)webProcess:(SBRProcessBundleBridge *)bridge didBlockScriptResourceFromOrigin:(NSString *)origin;
|
||||
@end
|
||||
|
||||
@interface SBRProcessBundleBridge : NSObject
|
||||
|
||||
@property (nonatomic, readonly) WKWebView *webView;
|
||||
|
||||
@property (nonatomic, weak) id<SBRProcessBundleBridgeDelegate> delegate;
|
||||
@property (nonatomic, strong) id<SBRResourceOriginPolicyDataSource> policyDataSource;
|
||||
@property (nonatomic, assign) BOOL allowAllScripts; // default is NO
|
||||
@property (nonatomic, assign) BOOL darkModeEnabled;
|
||||
|
||||
- (void)policyDataSourceDidChange;
|
||||
- (void)tearDown;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
152
App/Web Process Bundle Bridge/SBRProcessBundleBridge.m
Normal file
152
App/Web Process Bundle Bridge/SBRProcessBundleBridge.m
Normal file
@@ -0,0 +1,152 @@
|
||||
//
|
||||
// SBRProcessBundleBridge.m
|
||||
// SBrowser
|
||||
//
|
||||
// Created by James Magahern on 7/22/20.
|
||||
//
|
||||
|
||||
#import "SBRProcessBundleBridge.h"
|
||||
|
||||
#import "SBRWebProcessDelegate.h"
|
||||
#import "SBRWebProcessProxy.h"
|
||||
|
||||
#import <WebKit/_WKRemoteObjectInterface.h>
|
||||
#import <WebKit/_WKRemoteObjectRegistry.h>
|
||||
#import <WebKit/_WKProcessPoolConfiguration.h>
|
||||
#import <WebKit/_WKUserStyleSheet.h>
|
||||
|
||||
#import <WebKit/WKProcessPoolPrivate.h>
|
||||
#import <WebKit/WKWebViewPrivate.h>
|
||||
#import <WebKit/WKWebViewConfigurationPrivate.h>
|
||||
#import <WebKit/WKUserContentControllerPrivate.h>
|
||||
|
||||
@interface SBRProcessBundleBridge () <SBRWebProcessDelegate>
|
||||
|
||||
@end
|
||||
|
||||
@implementation SBRProcessBundleBridge {
|
||||
WKWebView *_webView;
|
||||
WKWebViewConfiguration *_webViewConfiguration;
|
||||
WKProcessPool *_processPool;
|
||||
id<SBRWebProcessProxy> _webProcessProxy;
|
||||
|
||||
_WKUserStyleSheet *_darkModeStyleSheet;
|
||||
}
|
||||
|
||||
- (void)tearDown
|
||||
{
|
||||
[[_webView _remoteObjectRegistry] unregisterExportedObject:self interface:[self _webProcessDelegateInterface]];
|
||||
}
|
||||
|
||||
- (_WKRemoteObjectInterface *)_webProcessDelegateInterface
|
||||
{
|
||||
static dispatch_once_t onceToken;
|
||||
static _WKRemoteObjectInterface *interface = nil;
|
||||
dispatch_once(&onceToken, ^{
|
||||
interface = [_WKRemoteObjectInterface remoteObjectInterfaceWithProtocol:@protocol(SBRWebProcessDelegate)];
|
||||
});
|
||||
|
||||
return interface;
|
||||
}
|
||||
|
||||
- (_WKRemoteObjectInterface *)_webProcessProxyInterface
|
||||
{
|
||||
static dispatch_once_t onceToken;
|
||||
static _WKRemoteObjectInterface *interface = nil;
|
||||
dispatch_once(&onceToken, ^{
|
||||
interface = [_WKRemoteObjectInterface remoteObjectInterfaceWithProtocol:@protocol(SBRWebProcessProxy)];
|
||||
});
|
||||
|
||||
return interface;
|
||||
}
|
||||
|
||||
- (WKWebView *)webView
|
||||
{
|
||||
if (!_webView) {
|
||||
// Inject bundle
|
||||
_WKProcessPoolConfiguration *poolConfiguration = [[_WKProcessPoolConfiguration alloc] init];
|
||||
NSURL *bundleURL = [[[NSBundle mainBundle] builtInPlugInsURL] URLByAppendingPathComponent:@"SBrowserProcessBundle.bundle"];
|
||||
[poolConfiguration setInjectedBundleURL:bundleURL];
|
||||
|
||||
// Set up process pool
|
||||
_processPool = [[WKProcessPool alloc] _initWithConfiguration:poolConfiguration];
|
||||
|
||||
// Initialize allowed origins now
|
||||
NSArray<NSString *> *allowedOrigins = [[_policyDataSource allowedOriginsForScriptResources] allObjects];
|
||||
[_processPool _setObject:allowedOrigins forBundleParameter:SBRGetAllowedOriginsKey()];
|
||||
[_processPool _setObject:@(_allowAllScripts) forBundleParameter:SBRGetAllScriptsAllowedKey()];
|
||||
|
||||
WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
|
||||
configuration.processPool = _processPool;
|
||||
|
||||
// Instantiate web view
|
||||
WKWebView *webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:configuration];
|
||||
|
||||
// Configure proxy interface (interface to remote web process)
|
||||
_webProcessProxy = [[webView _remoteObjectRegistry] remoteObjectProxyWithInterface:[self _webProcessProxyInterface]];
|
||||
|
||||
// Configure delegate interface (registering us as the web process delegate for the remote process)
|
||||
[[webView _remoteObjectRegistry] registerExportedObject:self interface:[self _webProcessDelegateInterface]];
|
||||
|
||||
_webView = webView;
|
||||
_webViewConfiguration = configuration;
|
||||
}
|
||||
|
||||
return _webView;
|
||||
}
|
||||
|
||||
#pragma mark <SBRWebProcessDelegate>
|
||||
|
||||
- (void)webProcessDidConnect
|
||||
{
|
||||
NSLog(@"SBRProcessBundleBridge: did connect. Saying hello, syncing allowlist");
|
||||
[_webProcessProxy hello];
|
||||
[self policyDataSourceDidChange];
|
||||
}
|
||||
|
||||
- (void)webProcessDidAllowScriptWithOrigin:(NSString *)origin
|
||||
{
|
||||
[[self delegate] webProcess:self didAllowScriptResourceFromOrigin:origin];
|
||||
}
|
||||
|
||||
- (void)webProcessDidBlockScriptWithOrigin:(NSString *)origin
|
||||
{
|
||||
[[self delegate] webProcess:self didBlockScriptResourceFromOrigin:origin];
|
||||
}
|
||||
|
||||
#pragma mark Actions
|
||||
|
||||
- (void)policyDataSourceDidChange
|
||||
{
|
||||
NSArray<NSString *> *allowedOrigins = [[_policyDataSource allowedOriginsForScriptResources] allObjects];
|
||||
[_processPool _setObject:allowedOrigins forBundleParameter:SBRGetAllowedOriginsKey()];
|
||||
[_webProcessProxy syncAllowedResourceOrigins:allowedOrigins];
|
||||
}
|
||||
|
||||
- (void)setAllowAllScripts:(BOOL)allowAllScripts
|
||||
{
|
||||
_allowAllScripts = allowAllScripts;
|
||||
[_processPool _setObject:@(_allowAllScripts) forBundleParameter:SBRGetAllScriptsAllowedKey()];
|
||||
[_webProcessProxy setAllScriptsAllowed:allowAllScripts];
|
||||
}
|
||||
|
||||
- (void)setDarkModeEnabled:(BOOL)darkModeEnabled
|
||||
{
|
||||
_darkModeEnabled = darkModeEnabled;
|
||||
|
||||
WKUserContentController *userContentController = [_webViewConfiguration userContentController];
|
||||
|
||||
if (darkModeEnabled) {
|
||||
if (!_darkModeStyleSheet) {
|
||||
NSURL *styleSheetURL = [[NSBundle mainBundle] URLForResource:@"darkmode" withExtension:@"css"];
|
||||
NSString *styleSheetSource = [NSString stringWithContentsOfURL:styleSheetURL encoding:NSUTF8StringEncoding error:nil];
|
||||
_darkModeStyleSheet = [[_WKUserStyleSheet alloc] initWithSource:styleSheetSource forMainFrameOnly:NO];
|
||||
}
|
||||
|
||||
[userContentController _addUserStyleSheet:_darkModeStyleSheet];
|
||||
} else if (_darkModeStyleSheet) {
|
||||
[userContentController _removeUserStyleSheet:_darkModeStyleSheet];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
Reference in New Issue
Block a user