@hearthlog/native 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ /// The Cloudflare Access login, in a sheet. It shares the app's data store, so the session cookie
5
+ /// Access sets is the one the app's web view sends. The sheet closes itself as soon as Access sends
6
+ /// the person back to the app.
7
+ @MainActor
8
+ final class SignInViewController: UIViewController, WKNavigationDelegate {
9
+ private let policy: NavigationPolicy
10
+ private let webView: WKWebView
11
+ private let url: URL
12
+ private let onFinished: (Bool) -> Void
13
+ private var finished = false
14
+
15
+ init(site: SiteConfig, policy: NavigationPolicy, dataStore: WKWebsiteDataStore, url: URL, onFinished: @escaping (Bool) -> Void) {
16
+ self.policy = policy
17
+ self.url = url
18
+ self.onFinished = onFinished
19
+ let configuration = WKWebViewConfiguration()
20
+ configuration.websiteDataStore = dataStore
21
+ webView = WKWebView(frame: .zero, configuration: configuration)
22
+ super.init(nibName: nil, bundle: nil)
23
+ title = "Sign in to \(site.title)"
24
+ navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .cancel, primaryAction: UIAction { [weak self] _ in
25
+ self?.dismiss(animated: true)
26
+ })
27
+ }
28
+
29
+ required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
30
+
31
+ override func viewDidLoad() {
32
+ super.viewDidLoad()
33
+ view.backgroundColor = .systemBackground
34
+ webView.frame = view.bounds
35
+ webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
36
+ webView.navigationDelegate = self
37
+ view.addSubview(webView)
38
+ webView.load(URLRequest(url: url))
39
+ }
40
+
41
+ func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
42
+ let url = navigationAction.request.url
43
+ if policy.finishesSignIn(url) {
44
+ finish(success: true)
45
+ return .cancel
46
+ }
47
+ return policy.allowsSignIn(url) ? .allow : .cancel
48
+ }
49
+
50
+ /// Swiping the sheet away or tapping Cancel ends the sign-in without a session.
51
+ override func viewDidDisappear(_ animated: Bool) {
52
+ super.viewDidDisappear(animated)
53
+ if isMovingFromParent || navigationController?.isBeingDismissed == true { finish(success: false) }
54
+ }
55
+
56
+ private func finish(success: Bool) {
57
+ guard !finished else { return }
58
+ finished = true
59
+ onFinished(success)
60
+ if success { navigationController?.dismiss(animated: true) }
61
+ }
62
+ }
@@ -0,0 +1,196 @@
1
+ import UIKit
2
+ import UserNotifications
3
+ import WebKit
4
+
5
+ /// The whole app on iOS: the site, edge to edge. The page draws its own header and dock (as glass),
6
+ /// so this only supplies what the page can't: sheets, share, haptics, reminders and the app badge.
7
+ @MainActor
8
+ final class WebViewController: UIViewController, StorWebHostDelegate {
9
+ let host: StorWebHost
10
+ private let flush = BackgroundFlush()
11
+ private var signIn: SignInViewController?
12
+
13
+ init(site: SiteConfig) {
14
+ #if DEBUG
15
+ let inspectable = true
16
+ #else
17
+ let inspectable = false
18
+ #endif
19
+ host = StorWebHost(site: site, kind: .ios, inspectable: inspectable)
20
+ super.init(nibName: nil, bundle: nil)
21
+ host.delegate = self
22
+ }
23
+
24
+ required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
25
+
26
+ override func viewDidLoad() {
27
+ super.viewDidLoad()
28
+ view.backgroundColor = .systemBackground
29
+ host.webView.frame = view.bounds
30
+ host.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
31
+ view.addSubview(host.webView)
32
+ }
33
+
34
+ /// The phone layout and its dock are portrait; an iPad turns freely.
35
+ override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
36
+ UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all
37
+ }
38
+
39
+ // MARK: Lifecycle
40
+
41
+ func didEnterBackground() {
42
+ flush.begin()
43
+ }
44
+
45
+ func willEnterForeground() {
46
+ flush.end()
47
+ host.dispatch(["type": "resume"])
48
+ }
49
+
50
+ // MARK: Messages
51
+
52
+ func host(_ host: StorWebHost, received message: ShellMessage) {
53
+ switch message {
54
+ case .appearance(let preference, _, let background):
55
+ overrideUserInterfaceStyle = preference == .dark ? .dark : preference == .light ? .light : .unspecified
56
+ if let color = UIColor(hex: background) {
57
+ view.backgroundColor = color
58
+ host.webView.underPageBackgroundColor = color
59
+ }
60
+ case .badge(let count):
61
+ setBadge(count)
62
+ case .reminders(let reminders):
63
+ Task { await ReminderScheduler.shared.apply(reminders) }
64
+ case .outbox(let pending):
65
+ flush.pending = pending
66
+ case .haptic(let success):
67
+ UINotificationFeedbackGenerator().notificationOccurred(success ? .success : .warning)
68
+ case .menu, .lights, .drag, .backgroundHover:
69
+ break
70
+ }
71
+ }
72
+
73
+ private func setBadge(_ count: Int) {
74
+ Task {
75
+ // Showing a number needs the notification permission; clearing one does not.
76
+ if count > 0, !(await ReminderScheduler.shared.authorize()) { return }
77
+ try? await UNUserNotificationCenter.current().setBadgeCount(count)
78
+ }
79
+ }
80
+
81
+ // MARK: StorWebHostDelegate
82
+
83
+ func host(_ host: StorWebHost, openWindowFor configuration: WKWebViewConfiguration, url: URL) -> WKWebView? {
84
+ let webView = WKWebView(frame: .zero, configuration: configuration)
85
+ webView.navigationDelegate = host
86
+ webView.uiDelegate = host
87
+ webView.isInspectable = host.webView.isInspectable
88
+ let popup = PopupViewController(webView: webView, title: host.site.title)
89
+ presenter.present(UINavigationController(rootViewController: popup), animated: true)
90
+ return webView
91
+ }
92
+
93
+ func host(_ host: StorWebHost, showSignIn url: URL) {
94
+ guard signIn == nil else { return }
95
+ let controller = SignInViewController(site: host.site, policy: host.policy, dataStore: host.dataStore, url: url) { [weak self] success in
96
+ guard let self else { return }
97
+ self.signIn = nil
98
+ if success { self.host.reloadAfterSignIn() }
99
+ }
100
+ signIn = controller
101
+ presenter.present(UINavigationController(rootViewController: controller), animated: true)
102
+ }
103
+
104
+ func host(_ host: StorWebHost, openExternally url: URL) {
105
+ UIApplication.shared.open(url)
106
+ }
107
+
108
+ /// Downloads land in a temporary folder and leave through the share sheet, which offers Save to Files.
109
+ func host(_ host: StorWebHost, chooseDestinationFor suggestedFilename: String) async -> URL? {
110
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
111
+ guard (try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)) != nil else { return nil }
112
+ let name = (suggestedFilename as NSString).lastPathComponent
113
+ return directory.appendingPathComponent(name.isEmpty ? "download" : name)
114
+ }
115
+
116
+ func host(_ host: StorWebHost, savedDownload url: URL) {
117
+ let sheet = UIActivityViewController(activityItems: [url], applicationActivities: nil)
118
+ if let popover = sheet.popoverPresentationController {
119
+ popover.sourceView = view
120
+ popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.midY, width: 0, height: 0)
121
+ popover.permittedArrowDirections = []
122
+ }
123
+ presenter.present(sheet, animated: true)
124
+ }
125
+
126
+ func host(_ host: StorWebHost, present dialog: JSDialog) async -> JSDialogResult {
127
+ await withCheckedContinuation { continuation in
128
+ let alert: UIAlertController
129
+ switch dialog {
130
+ case .alert(let message):
131
+ alert = UIAlertController(title: host.site.title, message: message, preferredStyle: .alert)
132
+ alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in continuation.resume(returning: .ok(nil)) })
133
+ case .confirm(let message):
134
+ alert = UIAlertController(title: host.site.title, message: message, preferredStyle: .alert)
135
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in continuation.resume(returning: .cancel) })
136
+ alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in continuation.resume(returning: .ok(nil)) })
137
+ case .prompt(let message, let defaultText):
138
+ alert = UIAlertController(title: host.site.title, message: message, preferredStyle: .alert)
139
+ alert.addTextField { $0.text = defaultText }
140
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in continuation.resume(returning: .cancel) })
141
+ alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak alert] _ in
142
+ continuation.resume(returning: .ok(alert?.textFields?.first?.text))
143
+ })
144
+ }
145
+ presenter.present(alert, animated: true)
146
+ }
147
+ }
148
+
149
+ /// Whatever is on top, so a sheet can open over another sheet.
150
+ private var presenter: UIViewController {
151
+ var top: UIViewController = self
152
+ while let next = top.presentedViewController { top = next }
153
+ return top
154
+ }
155
+ }
156
+
157
+ /// A window the page opened itself, such as a PDF in its own viewer.
158
+ @MainActor
159
+ final class PopupViewController: UIViewController {
160
+ private let webView: WKWebView
161
+
162
+ init(webView: WKWebView, title: String) {
163
+ self.webView = webView
164
+ super.init(nibName: nil, bundle: nil)
165
+ self.title = title
166
+ navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak self] _ in
167
+ self?.dismiss(animated: true)
168
+ })
169
+ }
170
+
171
+ required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
172
+
173
+ override func viewDidLoad() {
174
+ super.viewDidLoad()
175
+ view.backgroundColor = .systemBackground
176
+ webView.frame = view.bounds
177
+ webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
178
+ view.addSubview(webView)
179
+ }
180
+ }
181
+
182
+ private extension UIColor {
183
+ /// `#rgb` or `#rrggbb`, as the page's theme tokens are written.
184
+ convenience init?(hex: String?) {
185
+ guard var text = hex?.trimmingCharacters(in: .whitespaces), text.hasPrefix("#") else { return nil }
186
+ text.removeFirst()
187
+ if text.count == 3 { text = text.map { "\($0)\($0)" }.joined() }
188
+ guard text.count == 6, let value = Int(text, radix: 16) else { return nil }
189
+ self.init(
190
+ red: CGFloat((value >> 16) & 0xFF) / 255,
191
+ green: CGFloat((value >> 8) & 0xFF) / 255,
192
+ blue: CGFloat(value & 0xFF) / 255,
193
+ alpha: 1
194
+ )
195
+ }
196
+ }
@@ -0,0 +1,190 @@
1
+ import AppKit
2
+ import UserNotifications
3
+ import WebKit
4
+
5
+ @MainActor
6
+ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation {
7
+ let site = SiteConfig.load()
8
+
9
+ private lazy var menu = AppMenu(appName: site.title)
10
+ private var controllers: [MainWindowController] = []
11
+ private var popups: [PopupWindowController] = []
12
+ #if STOR_DEV
13
+ private var popupTitles: [NSKeyValueObservation] = []
14
+ #endif
15
+
16
+ private var inspectable: Bool {
17
+ #if STOR_DEV
18
+ true
19
+ #else
20
+ Bundle.main.object(forInfoDictionaryKey: "StorInspectable") as? Bool ?? false
21
+ #endif
22
+ }
23
+
24
+ // MARK: Lifecycle
25
+
26
+ func applicationDidFinishLaunching(_ notification: Notification) {
27
+ UNUserNotificationCenter.current().delegate = self
28
+ menu.install()
29
+ openWindow(path: "/")
30
+ NSApp.activate()
31
+ }
32
+
33
+ /// The date may have moved on while the app was in the background, so the page plans its reminders again.
34
+ func applicationDidBecomeActive(_ notification: Notification) {
35
+ controllers.forEach { $0.host.dispatch(["type": "resume"]) }
36
+ }
37
+
38
+ func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
39
+ if !flag { openWindow(path: "/") }
40
+ return true
41
+ }
42
+
43
+ /// Right-clicking the Dock icon lists the pages the page reported, with a section's sub-pages.
44
+ func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
45
+ menu.dockMenu()
46
+ }
47
+
48
+ /// Closing the last window keeps the app, so sync and reminders carry on.
49
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
50
+
51
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
52
+ let pending = controllers.map(\.pendingUploads).max() ?? 0
53
+ guard pending > 0 else { return .terminateNow }
54
+ let alert = NSAlert()
55
+ alert.messageText = "Quit \(site.title)?"
56
+ alert.informativeText = "\(pending) \(pending == 1 ? "change hasn't" : "changes haven't") been sent yet. \(pending == 1 ? "It is" : "They are") saved on this Mac and will upload the next time the app opens."
57
+ alert.addButton(withTitle: "Quit")
58
+ alert.addButton(withTitle: "Cancel")
59
+ return alert.runModal() == .alertFirstButtonReturn ? .terminateNow : .terminateCancel
60
+ }
61
+
62
+ // MARK: Windows
63
+
64
+ @discardableResult
65
+ func openWindow(path: String) -> MainWindowController {
66
+ let isFirst = controllers.isEmpty
67
+ let controller = MainWindowController(site: site, app: self, inspectable: inspectable, restoresFrame: isFirst)
68
+ // Another window opens just down and right of the one in front, so it never hides it exactly.
69
+ if let window = controller.window, let front = activeController?.window {
70
+ window.setFrameTopLeftPoint(NSPoint(x: front.frame.minX + 28, y: front.frame.maxY - 28))
71
+ }
72
+ controllers.append(controller)
73
+ controller.host.load(path: path)
74
+ controller.showWindow(nil)
75
+ #if STOR_DEV
76
+ DevTools.schedule(for: controller)
77
+ #endif
78
+ return controller
79
+ }
80
+
81
+ func windowClosed(_ controller: MainWindowController) {
82
+ controllers.removeAll { $0 === controller }
83
+ }
84
+
85
+ func openPopup(configuration: WKWebViewConfiguration, host: StorWebHost) -> WKWebView {
86
+ let webView = WKWebView(frame: .zero, configuration: configuration)
87
+ webView.navigationDelegate = host
88
+ webView.uiDelegate = host
89
+ webView.isInspectable = inspectable
90
+ let popup = PopupWindowController(webView: webView, title: site.title) { [weak self] closed in
91
+ self?.popups.removeAll { $0 === closed }
92
+ }
93
+ popups.append(popup)
94
+ popup.showWindow(nil)
95
+ #if STOR_DEV
96
+ DevTools.log("popup opened")
97
+ popupTitles.append(webView.observe(\.title) { webView, _ in DevTools.log("popup title \(webView.title ?? "")") })
98
+ #endif
99
+ return webView
100
+ }
101
+
102
+ private var activeController: MainWindowController? {
103
+ controllers.first { $0.window === NSApp.keyWindow } ?? controllers.first { $0.window?.isVisible == true }
104
+ }
105
+
106
+ var menuSummary: String { menu.summary }
107
+
108
+ // MARK: Messages from the page that concern the whole app
109
+
110
+ func handle(_ message: ShellMessage) {
111
+ #if STOR_DEV
112
+ DevTools.log("message \(message)")
113
+ #endif
114
+ switch message {
115
+ case .appearance(let preference, _, _):
116
+ NSApp.appearance = preference == .system ? nil : NSAppearance(named: preference == .dark ? .darkAqua : .aqua)
117
+ case .badge(let count):
118
+ NSApp.dockTile.badgeLabel = count > 0 ? String(count) : nil
119
+ case .reminders(let reminders):
120
+ #if STOR_DEV
121
+ if DevTools.environment["STOR_DEV_NO_NOTIFICATIONS"] == "1" {
122
+ DevTools.log("reminders planned \(ReminderScheduler.plan(reminders, now: Date()).map(\.id))")
123
+ break
124
+ }
125
+ #endif
126
+ Task { await ReminderScheduler.shared.apply(reminders) }
127
+ case .menu(let destinations, let actions):
128
+ menu.update(destinations: destinations, actions: actions)
129
+ case .lights, .outbox, .haptic, .drag, .backgroundHover:
130
+ break
131
+ }
132
+ }
133
+
134
+ // MARK: Menu actions
135
+
136
+ @objc func newWindow(_ sender: Any?) {
137
+ openWindow(path: "/")
138
+ }
139
+
140
+ @objc func openPath(_ sender: NSMenuItem) {
141
+ guard let path = sender.representedObject as? String else { return }
142
+ open(path: path)
143
+ }
144
+
145
+ @objc func toggleSidebar(_ sender: Any?) {
146
+ activeController?.host.dispatch(["type": "toggle-sidebar"])
147
+ }
148
+
149
+ @objc func reload(_ sender: Any?) {
150
+ activeController?.host.webView.reload()
151
+ }
152
+
153
+ @objc func goBack(_ sender: Any?) {
154
+ activeController?.host.webView.goBack()
155
+ }
156
+
157
+ @objc func goForward(_ sender: Any?) {
158
+ activeController?.host.webView.goForward()
159
+ }
160
+
161
+ func validateMenuItem(_ item: NSMenuItem) -> Bool {
162
+ switch item.action {
163
+ case #selector(goBack(_:)): return activeController?.host.webView.canGoBack ?? false
164
+ case #selector(goForward(_:)): return activeController?.host.webView.canGoForward ?? false
165
+ case #selector(toggleSidebar(_:)), #selector(reload(_:)): return activeController != nil
166
+ default: return true
167
+ }
168
+ }
169
+
170
+ private func open(path: String) {
171
+ if let controller = activeController {
172
+ controller.host.navigate(to: path)
173
+ controller.window?.makeKeyAndOrderFront(nil)
174
+ } else {
175
+ openWindow(path: path)
176
+ }
177
+ NSApp.activate()
178
+ }
179
+
180
+ // MARK: Notifications
181
+
182
+ nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
183
+ [.banner, .list, .sound]
184
+ }
185
+
186
+ nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
187
+ guard let path = response.notification.request.content.userInfo["path"] as? String else { return }
188
+ await MainActor.run { open(path: path) }
189
+ }
190
+ }
@@ -0,0 +1,159 @@
1
+ import AppKit
2
+
3
+ /// The Mac menu bar. The standard items are fixed; File's create shortcuts and Go's sections come from
4
+ /// the page, which knows which features this site has, and each one opens a route that already exists.
5
+ @MainActor
6
+ final class AppMenu {
7
+ private let appName: String
8
+ private let fileMenu = NSMenu(title: "File")
9
+ private let goMenu = NSMenu(title: "Go")
10
+ private var destinations: [MenuEntry] = []
11
+ private var actions: [MenuEntry] = []
12
+
13
+ init(appName: String) {
14
+ self.appName = appName
15
+ }
16
+
17
+ func install() {
18
+ let main = NSMenu()
19
+ main.addItem(submenuItem(appMenu()))
20
+ main.addItem(submenuItem(fileMenu))
21
+ main.addItem(submenuItem(editMenu()))
22
+ main.addItem(submenuItem(viewMenu()))
23
+ main.addItem(submenuItem(goMenu))
24
+ let window = windowMenu()
25
+ main.addItem(submenuItem(window))
26
+ NSApp.mainMenu = main
27
+ NSApp.windowsMenu = window
28
+ rebuildDynamicMenus()
29
+ }
30
+
31
+ /// File, Go and the Dock menu as they stand, for the debugging aids.
32
+ var summary: String {
33
+ "File=\(fileMenu.items.map(\.title)) Go=\(goMenu.items.map(\.title)) Dock=\(dockSummary)"
34
+ }
35
+
36
+ /// The Dock icon's menu: every available section, its sub-pages as submenus, then a new window.
37
+ func dockMenu() -> NSMenu {
38
+ let menu = NSMenu(title: appName)
39
+ for destination in destinations {
40
+ menu.addItem(destinationItem(destination))
41
+ }
42
+ if !destinations.isEmpty { menu.addItem(.separator()) }
43
+ menu.addItem(item("New Window", #selector(AppDelegate.newWindow(_:)), "", []))
44
+ return menu
45
+ }
46
+
47
+ private func destinationItem(_ destination: MenuEntry) -> NSMenuItem {
48
+ guard !destination.children.isEmpty else {
49
+ // A Dock menu has no key equivalents, so sections open on a plain click.
50
+ return pathItem(destination.title, destination.path, "", [])
51
+ }
52
+ let item = NSMenuItem(title: destination.title, action: nil, keyEquivalent: "")
53
+ let submenu = NSMenu(title: destination.title)
54
+ for child in destination.children {
55
+ submenu.addItem(pathItem(child.title, child.path, "", []))
56
+ }
57
+ item.submenu = submenu
58
+ return item
59
+ }
60
+
61
+ private var dockSummary: String {
62
+ dockMenu().items.filter { !$0.isSeparatorItem }.map { item in
63
+ guard let submenu = item.submenu else { return item.title }
64
+ return "\(item.title)[\(submenu.items.map(\.title).joined(separator: ","))]"
65
+ }.joined(separator: " ")
66
+ }
67
+
68
+ func update(destinations: [MenuEntry], actions: [MenuEntry]) {
69
+ guard destinations != self.destinations || actions != self.actions else { return }
70
+ self.destinations = destinations
71
+ self.actions = actions
72
+ rebuildDynamicMenus()
73
+ }
74
+
75
+ // MARK: Menus
76
+
77
+ private func appMenu() -> NSMenu {
78
+ let menu = NSMenu(title: appName)
79
+ menu.addItem(item("About \(appName)", #selector(NSApplication.orderFrontStandardAboutPanel(_:))))
80
+ menu.addItem(.separator())
81
+ menu.addItem(item("Hide \(appName)", #selector(NSApplication.hide(_:)), "h"))
82
+ menu.addItem(item("Hide Others", #selector(NSApplication.hideOtherApplications(_:)), "h", [.command, .option]))
83
+ menu.addItem(item("Show All", #selector(NSApplication.unhideAllApplications(_:))))
84
+ menu.addItem(.separator())
85
+ menu.addItem(item("Quit \(appName)", #selector(NSApplication.terminate(_:)), "q"))
86
+ return menu
87
+ }
88
+
89
+ private func editMenu() -> NSMenu {
90
+ let menu = NSMenu(title: "Edit")
91
+ menu.addItem(item("Undo", Selector(("undo:")), "z"))
92
+ menu.addItem(item("Redo", Selector(("redo:")), "z", [.command, .shift]))
93
+ menu.addItem(.separator())
94
+ menu.addItem(item("Cut", #selector(NSText.cut(_:)), "x"))
95
+ menu.addItem(item("Copy", #selector(NSText.copy(_:)), "c"))
96
+ menu.addItem(item("Paste", #selector(NSText.paste(_:)), "v"))
97
+ menu.addItem(item("Select All", #selector(NSText.selectAll(_:)), "a"))
98
+ return menu
99
+ }
100
+
101
+ private func viewMenu() -> NSMenu {
102
+ let menu = NSMenu(title: "View")
103
+ menu.addItem(item("Toggle Sidebar", #selector(AppDelegate.toggleSidebar(_:)), "s", [.command, .control]))
104
+ menu.addItem(item("Reload", #selector(AppDelegate.reload(_:)), "r"))
105
+ menu.addItem(.separator())
106
+ menu.addItem(item("Enter Full Screen", #selector(NSWindow.toggleFullScreen(_:)), "f", [.command, .control]))
107
+ return menu
108
+ }
109
+
110
+ private func windowMenu() -> NSMenu {
111
+ let menu = NSMenu(title: "Window")
112
+ menu.addItem(item("Minimize", #selector(NSWindow.performMiniaturize(_:)), "m"))
113
+ menu.addItem(item("Zoom", #selector(NSWindow.performZoom(_:))))
114
+ menu.addItem(.separator())
115
+ menu.addItem(item("Bring All to Front", #selector(NSApplication.arrangeInFront(_:))))
116
+ return menu
117
+ }
118
+
119
+ private func rebuildDynamicMenus() {
120
+ fileMenu.removeAllItems()
121
+ for (index, action) in actions.enumerated() {
122
+ // The first two get the conventional New shortcuts.
123
+ let (key, modifiers): (String, NSEvent.ModifierFlags) = index == 0 ? ("n", [.command]) : index == 1 ? ("n", [.command, .shift]) : ("", [])
124
+ fileMenu.addItem(pathItem(action.title.capitalized, action.path, key, modifiers))
125
+ }
126
+ if !actions.isEmpty { fileMenu.addItem(.separator()) }
127
+ fileMenu.addItem(item("New Window", #selector(AppDelegate.newWindow(_:)), "n", [.command, .option]))
128
+ fileMenu.addItem(.separator())
129
+ fileMenu.addItem(item("Close Window", #selector(NSWindow.performClose(_:)), "w"))
130
+
131
+ goMenu.removeAllItems()
132
+ goMenu.addItem(item("Back", #selector(AppDelegate.goBack(_:)), "["))
133
+ goMenu.addItem(item("Forward", #selector(AppDelegate.goForward(_:)), "]"))
134
+ if !destinations.isEmpty { goMenu.addItem(.separator()) }
135
+ for (index, destination) in destinations.enumerated() {
136
+ goMenu.addItem(pathItem(destination.title, destination.path, index < 9 ? String(index + 1) : "", [.command]))
137
+ }
138
+ }
139
+
140
+ // MARK: Items
141
+
142
+ private func submenuItem(_ menu: NSMenu) -> NSMenuItem {
143
+ let item = NSMenuItem(title: menu.title, action: nil, keyEquivalent: "")
144
+ item.submenu = menu
145
+ return item
146
+ }
147
+
148
+ private func item(_ title: String, _ action: Selector, _ key: String = "", _ modifiers: NSEvent.ModifierFlags = [.command]) -> NSMenuItem {
149
+ let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
150
+ item.keyEquivalentModifierMask = modifiers
151
+ return item
152
+ }
153
+
154
+ private func pathItem(_ title: String, _ path: String, _ key: String, _ modifiers: NSEvent.ModifierFlags) -> NSMenuItem {
155
+ let item = item(title, #selector(AppDelegate.openPath(_:)), key, modifiers)
156
+ item.representedObject = path
157
+ return item
158
+ }
159
+ }