| 1 | import UIKit |
| 2 | |
| 3 | /// A section's sync status from `sb_library_sync_status`. |
| 4 | struct SectionSync: Decodable { |
| 5 | enum State: UInt8, Decodable, Comparable { |
| 6 | case upToDate, syncing, inUse, notConnected, readOnly, protected, unreadable, failed |
| 7 | |
| 8 | static func < (a: State, b: State) -> Bool { a.rawValue < b.rawValue } |
| 9 | } |
| 10 | |
| 11 | let path: String |
| 12 | let state: State |
| 13 | /// Seconds since 1970 when the section file was last reached. |
| 14 | let synced: Double? |
| 15 | let queued: Int |
| 16 | let error: String? |
| 17 | } |
| 18 | |
| 19 | /// OneNote's Work Offline and Sync Now over every open notebook, and their status. |
| 20 | enum Sync { |
| 21 | /// Posted on the main thread when a notebook's closed sections report. |
| 22 | static let changed = Notification.Name("SyncChanged") |
| 23 | private static let key = "offline" |
| 24 | |
| 25 | static var offline: Bool { |
| 26 | get { UserDefaults.standard.bool(forKey: key) } |
| 27 | set { |
| 28 | UserDefaults.standard.set(newValue, forKey: key) |
| 29 | for notebook in Notebooks.all { notebook.follow() } |
| 30 | NotificationCenter.default.post(name: changed, object: nil) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | static func now() { |
| 35 | for notebook in Notebooks.all { |
| 36 | if let handle = notebook.handle { sb_library_sync_now(handle) } |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | /// Every open notebook's sections with their status, read off the main thread. |
| 41 | static func status(done: @escaping ([(Notebook, [SectionSync])]) -> Void) { |
| 42 | let notebooks = Notebooks.all.compactMap { notebook in notebook.handle.map { (notebook, Int(bitPattern: $0)) } } |
| 43 | background({ |
| 44 | notebooks.map { notebook, pointer in |
| 45 | (notebook, decode([SectionSync].self, sb_library_sync_status(OpaquePointer(bitPattern: pointer))) ?? []) |
| 46 | } |
| 47 | }, done: done) |
| 48 | } |
| 49 | |
| 50 | /// What `state` comes to for the reader, as the desktop names it. |
| 51 | static func label(_ state: SectionSync.State) -> String { |
| 52 | if offline { return "Working offline" } |
| 53 | return switch state { |
| 54 | case .upToDate: "Up to date" |
| 55 | case .syncing: "Syncing…" |
| 56 | case .inUse: "Section in use" |
| 57 | case .notConnected: "Not connected" |
| 58 | case .protected: "Password protected" |
| 59 | case .unreadable: "Can’t read this section" |
| 60 | case .readOnly, .failed: "Unable to sync" |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | static func symbol(_ state: SectionSync.State) -> String { |
| 65 | if offline { return "icloud.slash" } |
| 66 | return switch state { |
| 67 | case .upToDate: "checkmark.circle" |
| 68 | case .syncing, .inUse: "arrow.triangle.2.circlepath" |
| 69 | case .notConnected: "slash.circle" |
| 70 | case .protected: "lock" |
| 71 | case .readOnly, .unreadable, .failed: "exclamationmark.triangle" |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | /// What the reader can do about `state`. |
| 76 | static func advice(_ state: SectionSync.State) -> String? { |
| 77 | let device = UIDevice.current.model |
| 78 | if offline { return "Changes stay on this \(device) until you sync." } |
| 79 | return switch state { |
| 80 | case .readOnly: "You can’t change this notebook where it’s stored. Changes stay on this \(device)." |
| 81 | case .inUse: "Someone else is saving this section. Sync continues when they finish." |
| 82 | case .notConnected: "Changes stay on this \(device) and sync when the notebook is back." |
| 83 | case .protected: "Snowbound can’t open password-protected sections yet." |
| 84 | default: nil |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | /// A notebook's status as its row shows it, while its sections aren't syncing. |
| 89 | struct Attention: Equatable { |
| 90 | let state: SectionSync.State |
| 91 | let queued: Int |
| 92 | /// The oldest of the sections' last syncs, unless one never synced. |
| 93 | let synced: Double? |
| 94 | |
| 95 | /// The last sync and the edits waiting, as a line. |
| 96 | var detail: String { |
| 97 | [synced.map { "Last sync \(Sync.when($0))" }, queued > 0 ? "\(Sync.changes(queued)) waiting" : nil] |
| 98 | .compactMap { $0 }.joined(separator: " · ") |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | static func attention(_ sections: [SectionSync]) -> Attention? { |
| 103 | // A protected section says so on its own row. |
| 104 | let syncing = sections.filter { $0.state != .protected } |
| 105 | guard let worst = syncing.map(\.state).max(), offline || worst >= .notConnected else { return nil } |
| 106 | let synced = syncing.map(\.synced) |
| 107 | return Attention( |
| 108 | state: worst, queued: syncing.map(\.queued).reduce(0, +), |
| 109 | synced: synced.contains { $0 == nil } ? nil : synced.compactMap { $0 }.min()) |
| 110 | } |
| 111 | |
| 112 | /// What a toast says when `notebook` has just stopped syncing with edits waiting; nil |
| 113 | /// otherwise, as its row already says so. |
| 114 | static func toast(_ notebook: String, from before: Attention?, to after: Attention?) -> (title: String, detail: String)? { |
| 115 | guard before == nil, let after, after.queued > 0, !offline else { return nil } |
| 116 | let title = |
| 117 | switch after.state { |
| 118 | case .notConnected: "Can’t reach “\(notebook)”" |
| 119 | case .readOnly: "Can’t save to “\(notebook)”" |
| 120 | default: "Can’t sync “\(notebook)”" |
| 121 | } |
| 122 | return (title, "\(changes(after.queued)) waiting on this \(UIDevice.current.model)") |
| 123 | } |
| 124 | |
| 125 | static func changes(_ count: Int) -> String { count == 1 ? "1 change" : "\(count) changes" } |
| 126 | |
| 127 | /// When a section was last reached: the time, and the date too before today. |
| 128 | static func when(_ seconds: Double) -> String { |
| 129 | let date = Date(timeIntervalSince1970: seconds) |
| 130 | return Calendar.current.isDateInToday(date) |
| 131 | ? DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short) |
| 132 | : DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /// A passing note at the top of `window` that opens `details` when tapped, for a change the |
| 137 | /// reader would otherwise miss. |
| 138 | enum SyncToast { |
| 139 | static func show(_ title: String, _ detail: String, in window: UIWindow, details: @escaping () -> Void) { |
| 140 | var configuration: UIButton.Configuration |
| 141 | if #available(iOS 26, *) { |
| 142 | configuration = .glass() |
| 143 | } else { |
| 144 | configuration = .gray() |
| 145 | configuration.cornerStyle = .capsule |
| 146 | } |
| 147 | configuration.title = title |
| 148 | configuration.subtitle = detail |
| 149 | configuration.image = UIImage(systemName: "exclamationmark.icloud") |
| 150 | configuration.imagePadding = 10 |
| 151 | configuration.titleLineBreakMode = .byTruncatingMiddle |
| 152 | configuration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 20) |
| 153 | let toast = UIButton(configuration: configuration) |
| 154 | toast.accessibilityHint = "Shows sync status" |
| 155 | toast.translatesAutoresizingMaskIntoConstraints = false |
| 156 | toast.alpha = 0 |
| 157 | let dismiss = { [weak toast] in |
| 158 | UIView.animate(withDuration: 0.3, animations: { toast?.alpha = 0 }) { _ in toast?.removeFromSuperview() } |
| 159 | } |
| 160 | toast.addAction(UIAction { _ in |
| 161 | dismiss() |
| 162 | details() |
| 163 | }, for: .primaryActionTriggered) |
| 164 | window.addSubview(toast) |
| 165 | NSLayoutConstraint.activate([ |
| 166 | toast.topAnchor.constraint(equalTo: window.safeAreaLayoutGuide.topAnchor, constant: 8), |
| 167 | toast.centerXAnchor.constraint(equalTo: window.centerXAnchor), |
| 168 | toast.widthAnchor.constraint(lessThanOrEqualTo: window.widthAnchor, constant: -32), |
| 169 | ]) |
| 170 | UIView.animate(withDuration: 0.3) { toast.alpha = 1 } |
| 171 | UIAccessibility.post(notification: .announcement, argument: "\(title). \(detail)") |
| 172 | DispatchQueue.main.asyncAfter(deadline: .now() + 6, execute: dismiss) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | /// OneNote's Notebook Sync Status: Work Offline, Sync Now, and each notebook's sections. |
| 177 | final class SyncViewController: UITableViewController { |
| 178 | /// Shows the status as a sheet over `controller`. |
| 179 | static func present(from controller: UIViewController) { |
| 180 | let navigation = UINavigationController(rootViewController: SyncViewController()) |
| 181 | navigation.sheetPresentationController?.detents = [.medium(), .large()] |
| 182 | navigation.sheetPresentationController?.prefersGrabberVisible = true |
| 183 | (controller.presentedViewController ?? controller).present(navigation, animated: true) |
| 184 | } |
| 185 | |
| 186 | private var notebooks: [(Notebook, [SectionSync])] = [] |
| 187 | private var timer: Timer? |
| 188 | private let offline = UISwitch() |
| 189 | |
| 190 | init() { |
| 191 | super.init(style: .insetGrouped) |
| 192 | title = "Sync Status" |
| 193 | } |
| 194 | |
| 195 | required init?(coder: NSCoder) { fatalError() } |
| 196 | |
| 197 | override func viewDidLoad() { |
| 198 | super.viewDidLoad() |
| 199 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "row") |
| 200 | navigationItem.rightBarButtonItem = UIBarButtonItem( |
| 201 | systemItem: .done, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) |
| 202 | offline.addAction(UIAction { [weak self] _ in |
| 203 | Sync.offline = self?.offline.isOn ?? false |
| 204 | }, for: .valueChanged) |
| 205 | for name in [Sync.changed, Section.changed] { |
| 206 | NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | override func viewWillAppear(_ animated: Bool) { |
| 211 | super.viewWillAppear(animated) |
| 212 | refresh() |
| 213 | // The last sync time follows each round, which reports only when something changes. |
| 214 | timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in self?.refresh() } |
| 215 | } |
| 216 | |
| 217 | override func viewDidDisappear(_ animated: Bool) { |
| 218 | super.viewDidDisappear(animated) |
| 219 | timer?.invalidate() |
| 220 | } |
| 221 | |
| 222 | @objc private func refresh() { |
| 223 | Sync.status { [weak self] notebooks in |
| 224 | self?.notebooks = notebooks.filter { !$0.1.isEmpty } |
| 225 | self?.tableView.reloadData() |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | override func numberOfSections(in tableView: UITableView) -> Int { 1 + notebooks.count } |
| 230 | |
| 231 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { |
| 232 | section == 0 ? 2 : notebooks[section - 1].1.count |
| 233 | } |
| 234 | |
| 235 | override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { |
| 236 | section == 0 ? nil : notebooks[section - 1].0.name |
| 237 | } |
| 238 | |
| 239 | override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { |
| 240 | guard section > 0 else { return Sync.offline ? Sync.advice(.upToDate) : nil } |
| 241 | let (notebook, sections) = notebooks[section - 1] |
| 242 | let worst = sections.map(\.state).max() ?? .upToDate |
| 243 | let synced = sections.map(\.synced) |
| 244 | let oldest = synced.contains(where: { $0 == nil }) ? nil : synced.compactMap { $0 }.min() |
| 245 | return [ |
| 246 | notebook.location, |
| 247 | oldest.map { "Last sync \(Sync.when($0))" }, |
| 248 | Sync.offline ? nil : Sync.advice(worst), |
| 249 | ].compactMap { $0 }.joined(separator: "\n") |
| 250 | } |
| 251 | |
| 252 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { |
| 253 | let cell = tableView.dequeueReusableCell(withIdentifier: "row", for: indexPath) |
| 254 | cell.accessoryView = nil |
| 255 | cell.selectionStyle = .none |
| 256 | if indexPath.section == 0 { |
| 257 | var content = UIListContentConfiguration.cell() |
| 258 | if indexPath.row == 0 { |
| 259 | content.text = "Work Offline" |
| 260 | offline.isOn = Sync.offline |
| 261 | cell.accessoryView = offline |
| 262 | } else { |
| 263 | content.text = "Sync Now" |
| 264 | content.textProperties.color = .tintColor |
| 265 | cell.selectionStyle = .default |
| 266 | } |
| 267 | cell.contentConfiguration = content |
| 268 | return cell |
| 269 | } |
| 270 | let (notebook, sections) = notebooks[indexPath.section - 1] |
| 271 | let sync = sections[indexPath.row] |
| 272 | var content = UIListContentConfiguration.valueCell() |
| 273 | let tab = notebook.tabs.first { $0.path == sync.path } |
| 274 | content.text = tab?.name ?? URL(fileURLWithPath: sync.path).deletingPathExtension().lastPathComponent |
| 275 | content.image = UIImage(systemName: "rectangle.portrait.fill") |
| 276 | content.imageProperties.tintColor = tab?.uiColor ?? .systemGray |
| 277 | let label = Sync.label(sync.state) |
| 278 | content.secondaryText = sync.queued > 0 ? "\(label), \(Sync.changes(sync.queued))" : label |
| 279 | if let error = sync.error { |
| 280 | content = UIListContentConfiguration.subtitleCell() |
| 281 | content.text = tab?.name ?? sync.path |
| 282 | content.image = UIImage(systemName: "rectangle.portrait.fill") |
| 283 | content.imageProperties.tintColor = tab?.uiColor ?? .systemGray |
| 284 | content.secondaryText = "\(label): \(error)" |
| 285 | content.secondaryTextProperties.color = .secondaryLabel |
| 286 | } |
| 287 | cell.contentConfiguration = content |
| 288 | return cell |
| 289 | } |
| 290 | |
| 291 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { |
| 292 | tableView.deselectRow(at: indexPath, animated: true) |
| 293 | guard indexPath == IndexPath(row: 1, section: 0) else { return } |
| 294 | Sync.now() |
| 295 | refresh() |
| 296 | } |
| 297 | } |