1import UIKit
2
3/// A section's sync status from `sb_library_sync_status`.
4struct 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.
20enum 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.
138enum 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.
177final 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}