| 1 | import UIKit |
| 2 | |
| 3 | /// Connect to Server, as Files and the Finder offer it: an `smb://` address, then a guest |
| 4 | /// or an account whose password goes in the Keychain, then a folder to open. |
| 5 | final class ServerViewController: UITableViewController, UITextFieldDelegate { |
| 6 | private static let lastKey = "server" |
| 7 | |
| 8 | private let address = UITextField() |
| 9 | private let name = UITextField() |
| 10 | private let password = UITextField() |
| 11 | private let guest = UISegmentedControl(items: ["Guest", "Registered User"]) |
| 12 | private var connecting = false |
| 13 | var onOpen: ((Notebook) -> Void)? |
| 14 | |
| 15 | init() { |
| 16 | super.init(style: .insetGrouped) |
| 17 | title = "Connect to Server" |
| 18 | } |
| 19 | |
| 20 | required init?(coder: NSCoder) { fatalError() } |
| 21 | |
| 22 | override func viewDidLoad() { |
| 23 | super.viewDidLoad() |
| 24 | navigationItem.leftBarButtonItem = UIBarButtonItem( |
| 25 | systemItem: .cancel, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) |
| 26 | navigationItem.rightBarButtonItem = UIBarButtonItem( |
| 27 | title: "Connect", primaryAction: UIAction { [weak self] _ in self?.connect() }) |
| 28 | address.placeholder = "smb://server/share" |
| 29 | address.text = UserDefaults.standard.string(forKey: Self.lastKey) |
| 30 | address.keyboardType = .URL |
| 31 | address.autocapitalizationType = .none |
| 32 | address.autocorrectionType = .no |
| 33 | address.clearButtonMode = .whileEditing |
| 34 | name.placeholder = "Name" |
| 35 | name.textContentType = .username |
| 36 | name.autocapitalizationType = .none |
| 37 | name.autocorrectionType = .no |
| 38 | password.placeholder = "Password" |
| 39 | password.textContentType = .password |
| 40 | password.isSecureTextEntry = true |
| 41 | for field in [address, name, password] { |
| 42 | field.delegate = self |
| 43 | field.returnKeyType = field === password || field === address ? .go : .next |
| 44 | } |
| 45 | guest.selectedSegmentIndex = 1 |
| 46 | guest.addAction(UIAction { [weak self] _ in self?.tableView.reloadData() }, for: .valueChanged) |
| 47 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "field") |
| 48 | } |
| 49 | |
| 50 | override func viewDidAppear(_ animated: Bool) { |
| 51 | super.viewDidAppear(animated) |
| 52 | address.becomeFirstResponder() |
| 53 | } |
| 54 | |
| 55 | func textFieldShouldReturn(_ field: UITextField) -> Bool { |
| 56 | if field === name { |
| 57 | password.becomeFirstResponder() |
| 58 | } else { |
| 59 | connect() |
| 60 | } |
| 61 | return false |
| 62 | } |
| 63 | |
| 64 | private var registered: Bool { guest.selectedSegmentIndex == 1 } |
| 65 | |
| 66 | /// `smb://user@host:port/share/folder`, or the same without a scheme. |
| 67 | private func server() -> Server? { |
| 68 | var text = address.text?.trimmingCharacters(in: .whitespaces) ?? "" |
| 69 | if !text.contains("://") { text = "smb://" + text } |
| 70 | guard let url = URLComponents(string: text), url.scheme?.lowercased() == "smb", let host = url.host, |
| 71 | !host.isEmpty |
| 72 | else { return nil } |
| 73 | let parts = url.path.split(separator: "/").map(String.init) |
| 74 | guard let share = parts.first else { return nil } |
| 75 | let user = url.user ?? (registered ? name.text ?? "" : "") |
| 76 | return Server( |
| 77 | host: url.port.map { "\(host):\($0)" } ?? host, share: share, user: user, |
| 78 | root: parts.dropFirst().joined(separator: "/")) |
| 79 | } |
| 80 | |
| 81 | private func connect() { |
| 82 | guard !connecting else { return } |
| 83 | guard let server = server() else { |
| 84 | return alert("Enter a Server Address", "Use the form smb://server/share.") |
| 85 | } |
| 86 | let secret = registered ? password.text ?? "" : "" |
| 87 | connecting = true |
| 88 | navigationItem.rightBarButtonItem?.isEnabled = false |
| 89 | let spinner = UIActivityIndicatorView(style: .medium) |
| 90 | spinner.startAnimating() |
| 91 | navigationItem.titleView = spinner |
| 92 | background({ () -> (Int, String?) in |
| 93 | var error: UnsafeMutablePointer<CChar>? |
| 94 | let share = sb_share_connect(server.host, server.share, server.user, secret, "", &error) |
| 95 | return (Int(bitPattern: share), take(error)) |
| 96 | }) { [weak self] share, _ in |
| 97 | guard let self else { return } |
| 98 | connecting = false |
| 99 | navigationItem.rightBarButtonItem?.isEnabled = true |
| 100 | navigationItem.titleView = nil |
| 101 | guard let share = OpaquePointer(bitPattern: share) else { |
| 102 | return alert( |
| 103 | "Can’t Connect", "Check the address, name and password, and that the server is on this network.") |
| 104 | } |
| 105 | if registered { Keychain.save(secret, for: server) } |
| 106 | UserDefaults.standard.set(address.text, forKey: Self.lastKey) |
| 107 | let browser = ShareViewController(share: SharedConnection(share), server: server, path: server.root) |
| 108 | browser.onOpen = onOpen |
| 109 | navigationController?.pushViewController(browser, animated: true) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | override func numberOfSections(in tableView: UITableView) -> Int { registered ? 2 : 1 } |
| 114 | |
| 115 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 2 } |
| 116 | |
| 117 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { |
| 118 | let cell = tableView.dequeueReusableCell(withIdentifier: "field", for: indexPath) |
| 119 | cell.contentView.subviews.forEach { $0.removeFromSuperview() } |
| 120 | cell.selectionStyle = .none |
| 121 | let view: UIView = |
| 122 | switch (indexPath.section, indexPath.row) { |
| 123 | case (0, 0): address |
| 124 | case (0, _): guest |
| 125 | case (_, 0): name |
| 126 | default: password |
| 127 | } |
| 128 | view.translatesAutoresizingMaskIntoConstraints = false |
| 129 | cell.contentView.addSubview(view) |
| 130 | let margins = cell.contentView.layoutMarginsGuide |
| 131 | NSLayoutConstraint.activate([ |
| 132 | view.leadingAnchor.constraint(equalTo: margins.leadingAnchor), |
| 133 | view.trailingAnchor.constraint(equalTo: margins.trailingAnchor), |
| 134 | view.topAnchor.constraint(equalTo: margins.topAnchor), |
| 135 | view.bottomAnchor.constraint(equalTo: margins.bottomAnchor), |
| 136 | view.heightAnchor.constraint(greaterThanOrEqualToConstant: 32), |
| 137 | ]) |
| 138 | return cell |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | /// A share connection the browser's folders share, closed with the last of them. |
| 143 | final class SharedConnection { |
| 144 | let handle: OpaquePointer |
| 145 | init(_ handle: OpaquePointer) { self.handle = handle } |
| 146 | deinit { sb_share_free(handle) } |
| 147 | } |
| 148 | |
| 149 | /// A folder on a share: its folders to go into, and Open where it holds a notebook. |
| 150 | final class ShareViewController: UITableViewController { |
| 151 | private struct Listing: Decodable { |
| 152 | let notebook: Bool |
| 153 | let folders: [String] |
| 154 | let sections: [String] |
| 155 | } |
| 156 | |
| 157 | private let share: SharedConnection |
| 158 | private let server: Server |
| 159 | private let path: String |
| 160 | private var listing: Listing? |
| 161 | var onOpen: ((Notebook) -> Void)? |
| 162 | |
| 163 | init(share: SharedConnection, server: Server, path: String) { |
| 164 | self.share = share |
| 165 | self.server = server |
| 166 | self.path = path |
| 167 | super.init(style: .insetGrouped) |
| 168 | title = path.split(separator: "/").last.map(String.init) ?? server.share |
| 169 | } |
| 170 | |
| 171 | required init?(coder: NSCoder) { fatalError() } |
| 172 | |
| 173 | override func viewDidLoad() { |
| 174 | super.viewDidLoad() |
| 175 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "entry") |
| 176 | let open = UIBarButtonItem(title: "Open", primaryAction: UIAction { [weak self] _ in self?.open() }) |
| 177 | open.style = .done |
| 178 | open.isEnabled = false |
| 179 | navigationItem.rightBarButtonItem = open |
| 180 | var loading = UIContentUnavailableConfiguration.loading() |
| 181 | loading.text = "Loading…" |
| 182 | contentUnavailableConfiguration = loading |
| 183 | let (handle, path) = (Int(bitPattern: share.handle), path) |
| 184 | background({ |
| 185 | decode(Listing.self, sb_share_list(OpaquePointer(bitPattern: handle), path)) |
| 186 | }) { [weak self] listing in |
| 187 | guard let self else { return } |
| 188 | self.listing = listing |
| 189 | tableView.reloadData() |
| 190 | open.isEnabled = listing.map { $0.notebook || !$0.sections.isEmpty } ?? false |
| 191 | var empty = UIContentUnavailableConfiguration.empty() |
| 192 | if listing == nil { |
| 193 | empty.text = "Can’t Read Folder" |
| 194 | empty.secondaryText = "Check that this account can open it." |
| 195 | } else { |
| 196 | empty.text = "No Folders" |
| 197 | empty.secondaryText = "OneNote notebooks are folders with sections in them." |
| 198 | } |
| 199 | contentUnavailableConfiguration = |
| 200 | listing.map { $0.folders.isEmpty && $0.sections.isEmpty } ?? true ? empty : nil |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | private func open() { |
| 205 | var server = server |
| 206 | server.root = path |
| 207 | let name = path.split(separator: "/").last.map(String.init) ?? server.share |
| 208 | onOpen?(Notebook(name: name, source: .server(server))) |
| 209 | } |
| 210 | |
| 211 | override func numberOfSections(in tableView: UITableView) -> Int { 2 } |
| 212 | |
| 213 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { |
| 214 | section == 0 ? listing?.folders.count ?? 0 : listing?.sections.count ?? 0 |
| 215 | } |
| 216 | |
| 217 | override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { |
| 218 | section == 1 && !(listing?.sections.isEmpty ?? true) ? "Sections" : nil |
| 219 | } |
| 220 | |
| 221 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { |
| 222 | let cell = tableView.dequeueReusableCell(withIdentifier: "entry", for: indexPath) |
| 223 | var content = cell.defaultContentConfiguration() |
| 224 | if indexPath.section == 0 { |
| 225 | content.text = listing?.folders[indexPath.row] |
| 226 | content.image = UIImage(systemName: "folder.fill") |
| 227 | cell.accessoryType = .disclosureIndicator |
| 228 | cell.selectionStyle = .default |
| 229 | } else { |
| 230 | content.text = listing.map { URL(fileURLWithPath: $0.sections[indexPath.row]).deletingPathExtension().lastPathComponent } |
| 231 | content.image = UIImage(systemName: "rectangle.portrait.fill") |
| 232 | content.imageProperties.tintColor = .secondaryLabel |
| 233 | content.textProperties.color = .secondaryLabel |
| 234 | cell.accessoryType = .none |
| 235 | cell.selectionStyle = .none |
| 236 | } |
| 237 | cell.contentConfiguration = content |
| 238 | return cell |
| 239 | } |
| 240 | |
| 241 | override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { |
| 242 | indexPath.section == 0 ? indexPath : nil |
| 243 | } |
| 244 | |
| 245 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { |
| 246 | guard let folder = listing?.folders[indexPath.row] else { return } |
| 247 | let child = ShareViewController( |
| 248 | share: share, server: server, path: path.isEmpty ? folder : "\(path)/\(folder)") |
| 249 | child.onOpen = onOpen |
| 250 | navigationController?.pushViewController(child, animated: true) |
| 251 | } |
| 252 | } |