Remote tabs: finishing touches

This commit is contained in:
James Magahern
2022-08-05 18:55:19 -07:00
parent 61773b97db
commit e8c6111592
11 changed files with 273 additions and 62 deletions

View File

@@ -68,6 +68,9 @@ extension BrowserViewController: WKNavigationDelegate, WKUIDelegate
let title = webView.title ?? ""
BrowserHistory.shared.didNavigate(toURL: url, title: title)
}
// Publish Tabs
AttractorServer.shared.publishTabInfo(tabController.tabs.map { $0.tabInfo })
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences, decisionHandler: @escaping (WKNavigationActionPolicy, WKWebpagePreferences) -> Void)

View File

@@ -145,12 +145,12 @@ class BrowserViewController: UIViewController
tabPickerController.tabObserver = tabController.$tabs
.receive(on: RunLoop.main)
.sink(receiveValue: { (newTabs: [Tab]) in
tabPickerController.setTabIdentifiers(newTabs.map { $0.identifier }, forHost: TabPickerViewController.localHostIdentifier)
tabPickerController.setTabInfos(newTabs.map { $0.tabInfo }, forHost: TabPickerViewController.localHostIdentifier)
})
// Set localhost tabs
let tabIdentifiers = tabController.tabs.map { $0.identifier }
tabPickerController.setTabIdentifiers(tabIdentifiers, forHost: TabPickerViewController.localHostIdentifier)
let tabInfos = tabController.tabs.map { $0.tabInfo }
tabPickerController.setTabInfos(tabInfos, forHost: TabPickerViewController.localHostIdentifier)
tabPickerController.selectedTabHost = TabPickerViewController.localHostIdentifier
let remoteTabPickerController = TabPickerViewController()
@@ -160,6 +160,20 @@ class BrowserViewController: UIViewController
remoteTabPickerController.newTabButton.isEnabled = false
remoteTabPickerController.editButtonItem.isEnabled = false
// Fetch tabs now
AttractorServer.shared.getTabInfos { [weak remoteTabPickerController] result in
guard let picker = remoteTabPickerController else { return }
switch result {
case .success(let tabInfos):
tabInfos.forEach { (key: String, value: [TabInfo]) in
picker.setTabInfos(value, forHost: key)
}
case .failure(let error):
picker.displayedError = error
}
}
let tabBarController = UITabBarController(nibName: nil, bundle: nil)
tabBarController.viewControllers = [
UINavigationController(rootViewController: tabPickerController),
@@ -522,7 +536,7 @@ class BrowserViewController: UIViewController
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
var findActions: [Selector] = []
if #available(macCatalyst 16.0, *) {
if #available(macCatalyst 16.0, iOS 16.0, *) {
findActions = [
#selector(UIResponder.find(_:)),
#selector(UIResponder.findNext(_:)),
@@ -645,8 +659,15 @@ extension BrowserViewController: TabPickerViewControllerDelegate
return tab.tabInfo
}
func tabPicker(_ picker: TabPickerViewController, didSelectTabIdentifier tabIdentifier: UUID) {
guard let tab = tabController.tab(forIdentifier: tabIdentifier) else { return }
func tabPicker(_ picker: TabPickerViewController, didSelectTabInfo info: TabInfo, fromHost host: String) {
var tab: Tab?
if host == TabPickerViewController.localHostIdentifier {
tab = tabController.tab(forIdentifier: info.identifier)
} else if let urlString = info.urlString {
tab = tabController.createNewTab(url: URL(string: urlString))
}
guard let tab else { return }
self.tab = tab
picker.dismiss(animated: true, completion: nil)

View File

@@ -28,6 +28,30 @@ struct LabelContentConfiguration : UIContentConfiguration
}
}
struct TextFieldContentConfiguration : UIContentConfiguration
{
var text: String = ""
var placeholderText: String? = nil
var textChanged: ((String) -> Void)
func makeContentView() -> UIView & UIContentView {
let textField = UITextField(frame: .zero)
textField.borderStyle = .roundedRect
textField.autocorrectionType = .no
textField.autocapitalizationType = .none
return GenericContentView<UITextField, TextFieldContentConfiguration>(configuration: self, view: textField) { config, textField in
textField.text = config.text
textField.placeholder = config.placeholderText
textField.addAction(UIAction { _ in config.textChanged(textField.text ?? "") }, for: .editingChanged)
}
}
func updated(for state: UIConfigurationState) -> TextFieldContentConfiguration {
self
}
}
struct ButtonContentConfiguration : UIContentConfiguration
{
var menu: UIMenu
@@ -56,11 +80,13 @@ class GeneralSettingsViewController: UIViewController
{
enum Section: String, CaseIterable {
case searchEngine = "Search Engine"
case syncServer = "Sync Server"
}
typealias Item = String
static let SearchProviderPopupItem = "searchProvider.popup"
static let SyncServerItem = "syncServer.field"
let dataSource: UICollectionViewDiffableDataSource<Section, Item>
let collectionView: UICollectionView
@@ -109,7 +135,7 @@ class GeneralSettingsViewController: UIViewController
if idiom == .mac {
return LabelContentConfiguration(
text: sectionName + ": ",
insets: UIEdgeInsets(top: 0, left: 10.0, bottom: 0, right: 10.0),
insets: UIEdgeInsets(top: 0.0, left: 10.0, bottom: 0, right: 10.0),
textAlignment: .right
)
} else {
@@ -141,11 +167,19 @@ class GeneralSettingsViewController: UIViewController
})
cell.contentConfiguration = ButtonContentConfiguration(menu: menu)
#if !targetEnvironment(macCatalyst)
cell.backgroundConfiguration = UIBackgroundConfiguration.listGroupedCell()
#endif
} else if identifier == Self.SyncServerItem {
cell.contentConfiguration = TextFieldContentConfiguration(
text: Settings.shared.syncServer,
placeholderText: "https://sync.server.com",
textChanged: { newString in
Settings.shared.syncServer = newString
}
)
}
#if !targetEnvironment(macCatalyst)
cell.backgroundConfiguration = UIBackgroundConfiguration.listGroupedCell()
#endif
}
let sectionHeaderRegistry = UICollectionView.SupplementaryRegistration<UICollectionViewCell>(elementKind: UICollectionView.elementKindSectionHeader, handler: {
@@ -189,6 +223,7 @@ class GeneralSettingsViewController: UIViewController
// iOS
// snapshot.appendItems(Settings.SearchProviderSetting.allCases.map { $0.rawValue }, toSection: .searchEngine)
snapshot.appendItems([ Self.SearchProviderPopupItem ], toSection: .searchEngine)
snapshot.appendItems([ Self.SyncServerItem ], toSection: .syncServer)
dataSource.apply(snapshot, animatingDifferences: false)
}

View File

@@ -105,4 +105,7 @@ class Settings
@SettingProperty(key: "userStylesheet")
public var userStylesheet: String = ""
@SettingProperty(key: "syncServer")
public var syncServer: String = "https://attractor.severnaya.net"
}

View File

@@ -40,8 +40,6 @@
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>

View File

@@ -8,5 +8,7 @@
<true/>
<key>com.apple.developer.web-browser</key>
<true/>
<key>com.apple.developer.device-information.user-assigned-device-name</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,76 @@
//
// AttractorServer.swift
// App
//
// Created by James Magahern on 8/5/22.
//
import Foundation
class AttractorServer
{
static let shared = AttractorServer()
private var endpointURL: URL {
get { URL(string: Settings.shared.syncServer) ?? URL(string: "http://localhost")! }
}
private func getHostname() -> String {
// Need an entitlement for this...
return UIDevice.current.name
}
public func publishTabInfo(_ tabInfos: [TabInfo]) {
let hostName = getHostname()
let rpcURL = endpointURL.appendingPathComponent("publishTabInfo")
var components = URLComponents(url: rpcURL, resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "host", value: hostName)
]
let encoder = JSONEncoder()
if let bodyData = try? encoder.encode(tabInfos) {
var request = URLRequest(url: components.url!)
request.httpBody = bodyData
request.httpMethod = "POST"
let dataTask = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error {
print("Error publishing tab info: \(error.localizedDescription)")
}
}
dataTask.resume()
}
}
public func getTabInfos(_ completion: @escaping(Result<[String: [TabInfo]], Error>) -> Void) {
let rpcURL = endpointURL.appendingPathComponent("getTabInfos")
let request = URLRequest(url: rpcURL)
let myHostname = getHostname()
let dataTask = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error {
print("Error getting tab infos: \(error.localizedDescription)")
DispatchQueue.main.async { completion(.failure(error)) }
}
let decoder = JSONDecoder()
if let data {
do {
let result = try decoder.decode([String: [TabInfo]].self, from: data)
.filter({ (host, tabInfo) in
// Filter out tabs from the same machine.
return host != myHostname
})
DispatchQueue.main.async { completion(.success(result)) }
} catch {
print("Error decoding tabs: \(error.localizedDescription)")
DispatchQueue.main.async { completion(.failure(error)) }
}
}
}
dataTask.resume()
}
}

View File

@@ -21,8 +21,8 @@ class Tab: NSObject, SBRProcessBundleBridgeDelegate
get {
TabInfo(
title: loadedWebView?.title,
url: loadedWebView?.url ?? self.homeURL,
favicon: self.favicon,
urlString: loadedWebView?.url?.absoluteString ?? self.homeURL?.absoluteString,
faviconData: self.favicon?.pngData(),
identifier: self.identifier
)
}
@@ -45,7 +45,15 @@ class Tab: NSObject, SBRProcessBundleBridgeDelegate
private var loadedWebView: WKWebView? = nil
public var title: String? { get { tabInfo.title } }
public var url: URL? { get { tabInfo.url } }
public var url: URL? {
get {
if let urlString = tabInfo.urlString {
return URL(string: urlString)
}
return nil
}
}
public var javaScriptEnabled: Bool = false {
didSet { bridge.allowAllScripts = javaScriptEnabled }

View File

@@ -8,14 +8,21 @@
import Foundation
import Combine
struct TabInfo
struct TabInfo: Codable, Hashable
{
public var title: String?
public var url: URL?
public var favicon: UIImage?
public var urlString: String?
public var faviconData: Data?
public var identifier = UUID()
public static func ==(lhs: TabInfo, rhs: TabInfo) -> Bool {
return lhs.identifier == rhs.identifier
}
enum CodingKeys: String, CodingKey {
case title
case urlString = "url"
case faviconData
case identifier
}
}

View File

@@ -11,9 +11,9 @@ import Combine
protocol TabPickerViewControllerDelegate: AnyObject
{
func tabPicker(_ picker: TabPickerViewController, createNewTabWithURL: URL?)
func tabPicker(_ picker: TabPickerViewController, didSelectTabIdentifier tab: UUID)
func tabPicker(_ picker: TabPickerViewController, closeTabWithIdentifier tab: UUID)
func tabPicker(_ picker: TabPickerViewController, tabInfoForIdentifier: UUID) -> TabInfo
func tabPicker(_ picker: TabPickerViewController, didSelectTabInfo: TabInfo, fromHost: String)
}
class TabPickerViewController: UIViewController, UICollectionViewDelegate
@@ -22,13 +22,16 @@ class TabPickerViewController: UIViewController, UICollectionViewDelegate
public static var localHostIdentifier = "__localhost__";
public var selectedTabIdentifier: UUID?
public var selectedTabIdentifier: TabID?
public var selectedTabHost: String? { didSet { didChangeSelectedTabHost(selectedTabHost!) } }
weak var delegate: TabPickerViewControllerDelegate?
public var tabObserver: AnyCancellable?
private var selectedTabIdentifiersForEditing: Set<UUID> = []
private var tabIdentifiersByHost: [String: [UUID]] = [:]
public var displayedError: Error? = nil { didSet { didSetDisplayedError(displayedError) } }
private var displayedErrorView: UITextView?
private var selectedTabIdentifiersForEditing: Set<TabInfo> = []
private var tabIdentifiersByHost: [String: [TabInfo]] = [:]
private var listConfiguration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
private lazy var listLayout = UICollectionViewCompositionalLayout.list(using: listConfiguration)
@@ -38,43 +41,41 @@ class TabPickerViewController: UIViewController, UICollectionViewDelegate
collectionView.delegate = self
}
private lazy var cellRegistry = UICollectionView.CellRegistration<UICollectionViewListCell, TabID> { [unowned self] (listCell, indexPath, item) in
private lazy var cellRegistry = UICollectionView.CellRegistration<UICollectionViewListCell, TabInfo> { [unowned self] (listCell, indexPath, tab) in
var config = listCell.defaultContentConfiguration()
if let tab = delegate?.tabPicker(self, tabInfoForIdentifier: 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 let selectedTabIdentifier, selectedTabIdentifier == item {
listCell.accessories = [ .checkmark() ]
} else {
listCell.accessories = []
}
if let title = tab.title, title.count > 0 {
config.text = title
config.secondaryText = tab.urlString
} else if let url = tab.urlString {
config.text = url
config.secondaryText = url
} else {
config.text = "New Tab"
}
config.textProperties.numberOfLines = 1
config.secondaryTextProperties.numberOfLines = 1
if let faviconData = tab.faviconData, let image = UIImage(data: faviconData) {
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 let selectedTabIdentifier, selectedTabIdentifier == tab.identifier {
listCell.accessories = [ .checkmark() ]
} else {
listCell.accessories = []
}
listCell.contentConfiguration = config
}
private lazy var dataSource = UICollectionViewDiffableDataSource<Int, TabID>(collectionView: collectionView)
private lazy var dataSource = UICollectionViewDiffableDataSource<Int, TabInfo>(collectionView: collectionView)
{ [unowned self] (collectionView, indexPath, item) -> UICollectionViewCell? in
return collectionView.dequeueConfiguredReusableCell(using: cellRegistry, for: indexPath, item: item)
}
@@ -96,12 +97,14 @@ class TabPickerViewController: UIViewController, UICollectionViewDelegate
}()
private lazy var hostPickerButton: UIButton = {
var buttonConfiguration = UIButton.Configuration.filled()
var buttonConfiguration = UIButton.Configuration.bordered()
buttonConfiguration.title = "Host"
let button = UIButton(configuration: buttonConfiguration)
button.changesSelectionAsPrimaryAction = true
button.showsMenuAsPrimaryAction = true
button.translatesAutoresizingMaskIntoConstraints = false
button.setContentCompressionResistancePriority(.required, for: .horizontal)
return button
}()
@@ -123,7 +126,7 @@ class TabPickerViewController: UIViewController, UICollectionViewDelegate
return UISwipeActionsConfiguration(actions: [ UIContextualAction(style: .destructive, title: "Close", handler: { [unowned self] (action, view, completionHandler) in
if let item = dataSource.itemIdentifier(for: indexPath) {
var snapshot = dataSource.snapshot()
delegate?.tabPicker(self, closeTabWithIdentifier: item)
delegate?.tabPicker(self, closeTabWithIdentifier: item.identifier)
snapshot.deleteItems([ item ])
dataSource.apply(snapshot, animatingDifferences: true)
@@ -136,14 +139,18 @@ class TabPickerViewController: UIViewController, UICollectionViewDelegate
configureNavigationButtons(forEditing: isEditing)
}
public func setTabIdentifiers(_ identifiers: [UUID], forHost host: String) {
tabIdentifiersByHost[host] = identifiers
if host == selectedTabHost {
public func setTabInfos(_ infos: [TabInfo], forHost host: String) {
let wasEmpty = tabIdentifiersByHost.isEmpty
tabIdentifiersByHost[host] = infos
if wasEmpty {
selectedTabHost = host
} else if host == selectedTabHost {
var snapshot = dataSource.snapshot()
snapshot.deleteAllItems()
snapshot.appendSections([ 0 ])
snapshot.appendItems(identifiers)
dataSource.apply(snapshot)
snapshot.appendItems(infos)
dataSource.apply(snapshot) // crashing here...
}
reloadHostPickerButtonMenu()
@@ -187,7 +194,7 @@ class TabPickerViewController: UIViewController, UICollectionViewDelegate
var snapshot = dataSource.snapshot()
for tab in selectedTabIdentifiersForEditing {
snapshot.deleteItems([ tab ])
delegate?.tabPicker(self, closeTabWithIdentifier: tab)
delegate?.tabPicker(self, closeTabWithIdentifier: tab.identifier)
}
dataSource.apply(snapshot, animatingDifferences: true)
@@ -203,11 +210,50 @@ class TabPickerViewController: UIViewController, UICollectionViewDelegate
dataSource.applySnapshotUsingReloadData(snapshot)
}
private func didSetDisplayedError(_ displayedError: Error?) {
if let displayedError {
// Clear items first
var snapshot = dataSource.snapshot()
snapshot.deleteAllItems()
dataSource.applySnapshotUsingReloadData(snapshot)
if displayedErrorView == nil {
let errorView = UITextView(frame: .zero)
errorView.isUserInteractionEnabled = false
errorView.translatesAutoresizingMaskIntoConstraints = false
errorView.isScrollEnabled = false
collectionView.addSubview(errorView)
let guide = collectionView.layoutMarginsGuide
NSLayoutConstraint.activate([
errorView.leadingAnchor .constraint(equalTo: guide.leadingAnchor),
errorView.trailingAnchor .constraint(equalTo: guide.trailingAnchor),
errorView.centerYAnchor .constraint(equalTo: guide.centerYAnchor),
])
self.displayedErrorView = errorView
}
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
var attributedString = try! AttributedString(markdown: "**Error loading tabs**: \(displayedError.localizedDescription)")
attributedString.foregroundColor = UIColor.secondaryLabel
attributedString.paragraphStyle = paragraphStyle
attributedString.font = UIFont.preferredFont(forTextStyle: .body)
displayedErrorView?.attributedText = NSAttributedString(attributedString)
} else {
displayedErrorView?.removeFromSuperview()
displayedErrorView = nil
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let tab = dataSource.itemIdentifier(for: indexPath) else { return }
if !isEditing {
delegate?.tabPicker(self, didSelectTabIdentifier: tab)
delegate?.tabPicker(self, didSelectTabInfo: tab, fromHost: selectedTabHost!)
} else {
deleteTabButton.isEnabled = collectionView.indexPathsForSelectedItems?.count ?? 0 > 0
selectedTabIdentifiersForEditing.update(with: tab)

View File

@@ -75,6 +75,7 @@
CDE6A30425F023BC00E912A4 /* AmberSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE6A30325F023BC00E912A4 /* AmberSettingsViewController.swift */; };
CDE6A30625F023EA00E912A4 /* AmberSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE6A30525F023EA00E912A4 /* AmberSettingsView.swift */; };
CDEDD8AA25D62ADB00862605 /* UITraitCollection+MacLike.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEDD8A925D62ADB00862605 /* UITraitCollection+MacLike.swift */; };
CDF255FD289DD7CF0059F021 /* AttractorServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF255FC289DD7CF0059F021 /* AttractorServer.swift */; };
CDF3468E276C105900FB3141 /* SettingsSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF3468D276C105900FB3141 /* SettingsSceneDelegate.swift */; };
CDF34690276C14BD00FB3141 /* CodeEditorSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF3468F276C14BD00FB3141 /* CodeEditorSettingsViewController.swift */; };
/* End PBXBuildFile section */
@@ -181,6 +182,7 @@
CDE6A30325F023BC00E912A4 /* AmberSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmberSettingsViewController.swift; sourceTree = "<group>"; };
CDE6A30525F023EA00E912A4 /* AmberSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmberSettingsView.swift; sourceTree = "<group>"; };
CDEDD8A925D62ADB00862605 /* UITraitCollection+MacLike.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITraitCollection+MacLike.swift"; sourceTree = "<group>"; };
CDF255FC289DD7CF0059F021 /* AttractorServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttractorServer.swift; sourceTree = "<group>"; };
CDF3468D276C105900FB3141 /* SettingsSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSceneDelegate.swift; sourceTree = "<group>"; };
CDF3468F276C14BD00FB3141 /* CodeEditorSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditorSettingsViewController.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -297,6 +299,7 @@
CDC5DA3C25DB7A5500BA8D99 /* Reader View */,
1ADFF4CE24CBBCBD006DC7AE /* Script Policy UI */,
CDE6A30225F023A000E912A4 /* Settings */,
CDF255FB289DD7BD0059F021 /* Sync */,
1AB88F0324D3E1EC0006F850 /* Tabs */,
1AB88F0424D3E1F90006F850 /* Titlebar and URL Bar */,
1ADFF4C124CA6AE4006DC7AE /* Utilities */,
@@ -473,6 +476,14 @@
path = Settings;
sourceTree = "<group>";
};
CDF255FB289DD7BD0059F021 /* Sync */ = {
isa = PBXGroup;
children = (
CDF255FC289DD7CF0059F021 /* AttractorServer.swift */,
);
path = Sync;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -617,6 +628,7 @@
CDE6A30625F023EA00E912A4 /* AmberSettingsView.swift in Sources */,
CD7A7E9D2686A9A500E20BA3 /* SettingsViewController.swift in Sources */,
1AB88EFD24D3BA560006F850 /* TabController.swift in Sources */,
CDF255FD289DD7CF0059F021 /* AttractorServer.swift in Sources */,
1ADFF4C324CA6AF6006DC7AE /* Geometry.swift in Sources */,
CD7F2135265DAD010001D042 /* MFMailComposeViewControllerFix.m in Sources */,
CDAD9CE8263A2DF200FF7199 /* DocumentControlsView.swift in Sources */,