1import UIKit
2
3/// Runs the library's file access under `NSFileCoordinator`, so file providers such as
4/// iCloud Drive download before it reads and learn of what it writes.
5private let coordinate: sb_coordinator = { path, write, body, context in
6 guard let path, let body else { return }
7 let url = URL(fileURLWithPath: String(cString: path))
8 var error: NSError?
9 if write {
10 NSFileCoordinator().coordinate(writingItemAt: url, options: .forMerging, error: &error) { _ in body(context) }
11 } else {
12 NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &error) { _ in body(context) }
13 }
14}
15
16@main
17final class AppDelegate: UIResponder, UIApplicationDelegate {
18 func application(
19 _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
20 ) -> Bool {
21 Crash.start()
22 sb_set_coordinator(coordinate)
23 Editing.apply()
24 ICloud.start()
25 sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } }
26 #if DEBUG
27 Presenter.watch()
28 #endif
29 return true
30 }
31
32 func application(
33 _ application: UIApplication,
34 configurationForConnecting session: UISceneSession,
35 options: UIScene.ConnectionOptions
36 ) -> UISceneConfiguration {
37 let configuration = UISceneConfiguration(name: nil, sessionRole: session.role)
38 configuration.delegateClass = SceneDelegate.self
39 return configuration
40 }
41
42 /// Edits are in the cache once stored; this gives publishing a moment before the app goes.
43 func applicationWillTerminate(_ application: UIApplication) {
44 NotificationCenter.default.post(name: PageViewController.leaving, object: nil)
45 for section in Section.all { _ = section.flush(2) }
46 }
47}
48
49/// The desktop's Appearance option: the system's, or light or dark regardless of it.
50enum Appearance {
51 static let key = "appearance"
52
53 static var style: UIUserInterfaceStyle {
54 get { UIUserInterfaceStyle(rawValue: UserDefaults.standard.integer(forKey: key)) ?? .unspecified }
55 set {
56 UserDefaults.standard.set(newValue.rawValue, forKey: key)
57 for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
58 for window in scene.windows { window.overrideUserInterfaceStyle = newValue }
59 }
60 }
61 }
62
63 static func menu() -> UIMenu {
64 let choices: [(String, UIUserInterfaceStyle)] = [("System", .unspecified), ("Light", .light), ("Dark", .dark)]
65 return UIMenu(
66 title: "Appearance", image: UIImage(systemName: "circle.lefthalf.filled"),
67 children: choices.map { title, style in
68 let action = UIAction(title: title) { _ in Appearance.style = style }
69 action.state = Appearance.style == style ? .on : .off
70 return action
71 })
72 }
73}
74
75/// OneNote's Snap To Grid and Default font, and Markdown Shortcuts, kept between launches,
76/// which the pages opened from then on take.
77enum Editing {
78 /// OneNote 2010's Default font sizes, in points.
79 static let sizes: [Float] = [8, 9, 9.5, 10, 10.5, 11, 11.5, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]
80 static let faces = ["Calibri", "Arial", "Times New Roman", "Courier New", "Georgia", "Verdana"]
81
82 static var snapToGrid: Bool {
83 get { !UserDefaults.standard.bool(forKey: "ignoreGrid") }
84 set {
85 UserDefaults.standard.set(!newValue, forKey: "ignoreGrid")
86 apply()
87 }
88 }
89
90 static var markdownShortcuts: Bool {
91 get { !UserDefaults.standard.bool(forKey: "ignoreMarkdown") }
92 set {
93 UserDefaults.standard.set(!newValue, forKey: "ignoreMarkdown")
94 apply()
95 }
96 }
97
98 static var face: String {
99 get { UserDefaults.standard.string(forKey: "defaultFontFace") ?? "Calibri" }
100 set {
101 UserDefaults.standard.set(newValue, forKey: "defaultFontFace")
102 apply()
103 }
104 }
105
106 static var size: Float {
107 get { UserDefaults.standard.object(forKey: "defaultFontSize") as? Float ?? 11 }
108 set {
109 UserDefaults.standard.set(newValue, forKey: "defaultFontSize")
110 apply()
111 }
112 }
113
114 static func apply() {
115 sb_set_snap_to_grid(snapToGrid)
116 sb_set_default_font(face, size)
117 sb_set_markdown_shortcuts(markdownShortcuts)
118 }
119
120 /// Markdown Shortcuts: typed Markdown becomes formatting.
121 static func markdownAction() -> UIAction {
122 let action = UIAction(title: "Markdown Shortcuts", image: UIImage(systemName: "number")) { _ in
123 markdownShortcuts.toggle()
124 }
125 action.state = markdownShortcuts ? .on : .off
126 return action
127 }
128
129 /// Options' Default font: the face and size new text takes.
130 static func fontMenu() -> UIMenu {
131 let faces = faces.map { name in
132 let action = UIAction(title: name) { _ in face = name }
133 action.state = face == name ? .on : .off
134 return action
135 }
136 let sizes = sizes.map { points in
137 let action = UIAction(title: points.formatted()) { _ in size = points }
138 action.state = size == points ? .on : .off
139 return action
140 }
141 return UIMenu(
142 title: "Default Font", image: UIImage(systemName: "textformat"),
143 children: [
144 UIMenu(options: .displayInline, children: faces),
145 UIMenu(title: "Size", children: sizes),
146 ])
147 }
148}
149
150/// Where the reader was, reopened on the next launch as Notes reopens its last note.
151private struct Place: Codable {
152 let notebook: String
153 let section: String
154 let page: String?
155
156 static let key = "place"
157
158 static var saved: Place? {
159 get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode(Place.self, from: $0) } }
160 set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) }
161 }
162}
163
164/// Notebooks and sections, pages and a page, as Notes lays out folders, notes and a note:
165/// side by side on a wide screen, a navigation stack on a phone.
166final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate {
167 var window: UIWindow?
168 private let split = RootViewController(style: .tripleColumn)
169 private let notebooks = NotebooksViewController()
170 private let pages = PagesViewController()
171 private var showingPage = false
172 /// A first launch opened a page by itself.
173 private var landed = false
174 private var background = UIBackgroundTaskIdentifier.invalid
175
176 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
177 guard let scene = scene as? UIWindowScene else { return }
178 let window = UIWindow(windowScene: scene)
179 window.overrideUserInterfaceStyle = Appearance.style
180 split.delegate = self
181 split.scene = self
182 split.preferredDisplayMode = .oneBesideSecondary
183 split.preferredSplitBehavior = .tile
184 split.setViewController(notebooks, for: .primary)
185 split.setViewController(pages, for: .supplementary)
186 split.setViewController(UIViewController(), for: .secondary)
187 notebooks.onOpen = { [weak self] tab, notebook in self?.open(tab, of: notebook) }
188 pages.onOpen = { [weak self] section, id in self?.show(section: section, page: id) }
189 window.rootViewController = split
190 window.makeKeyAndVisible()
191 self.window = window
192 Notebooks.load()
193 notebooks.reload()
194 for notebook in Notebooks.all {
195 notebook.open { [weak self] in
196 self?.notebooks.reload()
197 self?.restore(notebook)
198 }
199 }
200 // iCloud Drive's notebooks list once its folder is found, and again when the account
201 // changes.
202 NotificationCenter.default.addObserver(forName: ICloud.changed, object: nil, queue: .main) { [weak self] _ in
203 self?.notebooks.rescan { self?.restore($0) }
204 }
205 self.scene(scene, openURLContexts: options.urlContexts)
206 DispatchQueue.main.async { Crash.offer(from: self.split) }
207 }
208
209 /// A section file Files opens in Snowbound, in place.
210 func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) {
211 for context in contexts where context.url.isFileURL { notebooks.open(document: context.url) }
212 }
213
214 /// Reopens the section and page shown last, or the scripted one.
215 private func restore(_ notebook: Notebook) {
216 let environment = ProcessInfo.processInfo.environment
217 if let index = environment["SNOWBOUND_SECTION"].flatMap(Int.init) {
218 let readable = notebook.tabs.filter(\.readable)
219 guard index < readable.count else { return }
220 open(readable[index], of: notebook) { [weak self] section in
221 guard let page = environment["SNOWBOUND_PAGE"].flatMap(Int.init), page < section.rows.count else {
222 return
223 }
224 self?.show(section: section, page: section.rows[page].id)
225 }
226 return
227 }
228 guard let place = Place.saved else {
229 // The first notebook found opens at its first page, as Notes opens on a note.
230 guard Prototype.welcome, !landed, let tab = notebook.tabs.first(where: \.readable) else { return }
231 landed = true
232 return open(tab, of: notebook) { [weak self] section in
233 if let page = section.rows.first { self?.show(section: section, page: page.id) }
234 }
235 }
236 guard place.notebook == notebook.id, let tab = notebook.tabs.first(where: { $0.path == place.section })
237 else { return }
238 open(tab, of: notebook, page: place.page)
239 }
240
241 func open(
242 _ tab: Tab, of notebook: Notebook, page: String? = nil, reveal: Reveal? = nil,
243 then: ((Section) -> Void)? = nil
244 ) {
245 // The welcome prototype asks at the first edit instead.
246 guard Author.name != nil || Prototype.welcome else {
247 return Author.ask(from: split) { [weak self] in
248 self?.open(tab, of: notebook, page: page, reveal: reveal, then: then)
249 }
250 }
251 pages.loading(tab)
252 split.show(.supplementary)
253 Section.open(tab, of: notebook) { [weak self] section, problem in
254 guard let self else { return }
255 guard let section else {
256 pages.failed(problem)
257 return
258 }
259 pages.load(section)
260 notebooks.select(tab, of: notebook)
261 Place.saved = Place(notebook: notebook.id, section: tab.path, page: page)
262 if let page, section.row(of: page) != nil {
263 show(section: section, page: page, reveal: reveal)
264 }
265 then?(section)
266 }
267 }
268
269 /// Opens `page` of the section at catalog `path` in `notebook`, as a search result or
270 /// the Tags Summary leads there.
271 func open(_ path: String, of notebook: Notebook, page: String, reveal: Reveal) {
272 if let section = pages.section, section.notebook === notebook, section.tab.path == path {
273 return show(section: section, page: page, reveal: reveal)
274 }
275 guard let tab = notebook.tabs.first(where: { $0.path == path }) else { return }
276 open(tab, of: notebook, page: page, reveal: reveal)
277 }
278
279 /// OneNote's Tags Summary for `notebook`, as a sheet over `controller`.
280 func showTags(of notebook: Notebook, from controller: UIViewController) {
281 let tags = TagsViewController(notebook: notebook)
282 tags.onOpen = { [weak self] notebook, path, page, paragraph in
283 self?.open(path, of: notebook, page: page, reveal: .paragraph(paragraph))
284 }
285 let navigation = UINavigationController(rootViewController: tags)
286 navigation.sheetPresentationController?.detents = [.medium(), .large()]
287 navigation.sheetPresentationController?.prefersGrabberVisible = true
288 controller.present(navigation, animated: true)
289 }
290
291 func show(section: Section, page: String, reveal: Reveal? = nil, titleFocus: Bool = false) {
292 showingPage = true
293 let controller = PageViewController(section: section, page: page, reveal: reveal, titleFocus: titleFocus)
294 controller.onOpen = { [weak self] id in self?.show(section: section, page: id) }
295 split.setViewController(UINavigationController(rootViewController: controller), for: .secondary)
296 split.show(.secondary)
297 pages.select(page)
298 Place.saved = Place(notebook: section.notebook.id, section: section.tab.path, page: page)
299 }
300
301 /// Returns to the list from any section of `notebook` shown, as before its folder moves.
302 func close(_ notebook: Notebook) {
303 guard pages.section?.notebook === notebook else { return }
304 pages.clear()
305 showingPage = false
306 split.setViewController(UIViewController(), for: .secondary)
307 split.show(.primary)
308 }
309
310 /// Lets go of the section shown where it is the section or group at `path` of `notebook`
311 /// or within it, as before its file moves; its path and page, to open once moved.
312 func release(_ path: String, of notebook: Notebook) -> (section: String, page: String?)? {
313 guard let section = pages.section, section.notebook === notebook,
314 section.tab.path == path || section.tab.path.hasPrefix(path + "/")
315 else { return nil }
316 let shown = (section.tab.path, Place.saved?.page)
317 close(notebook)
318 return shown
319 }
320
321 /// Adds a page to the open section and opens it, its title ready for typing.
322 func newPage(subpage: Bool = false) {
323 guard let section = pages.section else { return }
324 let parent = subpage ? (Place.saved?.page).flatMap { section.row(of: $0)?.row.id } : nil
325 guard let id = section.newPage(under: parent) else { return }
326 show(section: section, page: id, titleFocus: true)
327 }
328
329 func search() {
330 split.show(.supplementary)
331 pages.startSearching()
332 }
333
334 /// A phone opens on the notebooks, as Notes opens on its folders.
335 func splitViewController(
336 _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column
337 ) -> UISplitViewController.Column {
338 showingPage ? proposedTopColumn : .primary
339 }
340
341 /// Composition ends and every edit is stored and published while the system allows.
342 func sceneDidEnterBackground(_ scene: UIScene) {
343 NotificationCenter.default.post(name: PageViewController.leaving, object: nil)
344 // Protected sections open again only once unlocked.
345 for notebook in Notebooks.all { notebook.pause(); notebook.lockAll() }
346 let sections = Section.all
347 guard !sections.isEmpty, background == .invalid else { return }
348 background = UIApplication.shared.beginBackgroundTask(withName: "Saving") { [weak self] in
349 self?.finishBackground()
350 }
351 DispatchQueue.global(qos: .userInitiated).async {
352 for section in sections { _ = section.flush(20) }
353 DispatchQueue.main.async { [weak self] in self?.finishBackground() }
354 }
355 }
356
357 private func finishBackground() {
358 guard background != .invalid else { return }
359 UIApplication.shared.endBackgroundTask(background)
360 background = .invalid
361 }
362
363 /// Someone moving to another device finds their edits there: they publish now rather than
364 /// after a pause in typing.
365 func sceneWillResignActive(_ scene: UIScene) {
366 for section in Section.all { section.wake() }
367 }
368
369 /// Changes made elsewhere while away show at once.
370 func sceneWillEnterForeground(_ scene: UIScene) {
371 for notebook in Notebooks.all { notebook.resume() }
372 notebooks.rescan()
373 for section in Section.all { section.wake() }
374 }
375}
376
377/// Takes the app's keyboard shortcuts wherever focus is.
378final class RootViewController: UISplitViewController {
379 weak var scene: SceneDelegate?
380
381 override var keyCommands: [UIKeyCommand]? {
382 [
383 UIKeyCommand(title: "New Page", action: #selector(newPage), input: "n", modifierFlags: .command),
384 UIKeyCommand(
385 title: "New Subpage", action: #selector(newSubpage), input: "n",
386 modifierFlags: [.command, .shift, .alternate]),
387 UIKeyCommand(title: "Search", action: #selector(search), input: "f", modifierFlags: [.command, .alternate]),
388 ]
389 }
390
391 @objc private func newPage() { scene?.newPage() }
392 @objc private func newSubpage() { scene?.newPage(subpage: true) }
393 @objc private func search() { scene?.search() }
394}