1import 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.
5final 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.
143final 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.
150final 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}