@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.
- package/LICENSE +235 -0
- package/package.json +35 -3
- package/resign.example.json +6 -0
- package/src/ios.ts +112 -0
- package/src/mac.ts +138 -0
- package/src/project.ts +365 -0
- package/src/resign.ts +280 -0
- package/src/site.ts +268 -0
- package/src/swift-checks.ts +35 -0
- package/swift/Shared/NavigationPolicy.swift +70 -0
- package/swift/Shared/ReminderScheduler.swift +65 -0
- package/swift/Shared/ShellMessage.swift +118 -0
- package/swift/Shared/SiteConfig.swift +47 -0
- package/swift/Shared/StorWebHost.swift +318 -0
- package/swift/Shared/WindowDragScript.swift +55 -0
- package/swift/Tests/main.swift +155 -0
- package/swift/iOS/AppDelegate.swift +20 -0
- package/swift/iOS/BackgroundFlush.swift +25 -0
- package/swift/iOS/SceneDelegate.swift +37 -0
- package/swift/iOS/SignInViewController.swift +62 -0
- package/swift/iOS/WebViewController.swift +196 -0
- package/swift/macOS/AppDelegate.swift +190 -0
- package/swift/macOS/AppMenu.swift +159 -0
- package/swift/macOS/DevTools.swift +85 -0
- package/swift/macOS/MainWindowController.swift +206 -0
- package/swift/macOS/SignInWindowController.swift +60 -0
- package/swift/macOS/StorWindow.swift +48 -0
- package/swift/macOS/TrafficLights.swift +115 -0
- package/swift/macOS/main.swift +9 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|