@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,85 @@
|
|
|
1
|
+
#if STOR_DEV
|
|
2
|
+
import AppKit
|
|
3
|
+
import WebKit
|
|
4
|
+
|
|
5
|
+
/// Debugging aids for builds made with `--dev` (never in a release build). Set in the environment:
|
|
6
|
+
/// STOR_ORIGIN open this origin instead of the site's, for example http://127.0.0.1:5180
|
|
7
|
+
/// STOR_DEV_DELAY seconds to wait for the page before the steps below (default 6)
|
|
8
|
+
/// STOR_DEV_EVAL body of an async JavaScript function; its returned string is printed as `EVAL <value>`
|
|
9
|
+
/// STOR_DEV_EVAL_FILE the same, read from a file
|
|
10
|
+
/// STOR_DEV_NAVIGATE a route to open through the shell, as the Go menu does
|
|
11
|
+
/// STOR_ACCESS_HOST treat this host as the Cloudflare Access login
|
|
12
|
+
/// STOR_DEV_CLICK "x,y;x,y" window points (from the top left) to send a mouse-down to, after the eval
|
|
13
|
+
/// STOR_DEV_LEGACY_PAGE=1 do not define window.storShell, as a page that predates the shell sees it
|
|
14
|
+
/// STOR_DEV_DOWNLOAD_DIR save downloads here without a panel
|
|
15
|
+
/// STOR_DEV_ALLOW_POPUPS=1 let scripts call window.open without a user gesture
|
|
16
|
+
/// STOR_DEV_NO_NOTIFICATIONS=1 do not schedule reminders (that asks for permission); log them instead
|
|
17
|
+
/// STOR_DEV_SETTLE seconds to wait after the eval before the screenshot (default 0)
|
|
18
|
+
/// STOR_DEV_SCREENSHOT write a PNG of the window, with its window controls and glass
|
|
19
|
+
/// STOR_DEV_EXIT=1 quit afterwards
|
|
20
|
+
@MainActor
|
|
21
|
+
enum DevTools {
|
|
22
|
+
static let environment = ProcessInfo.processInfo.environment
|
|
23
|
+
|
|
24
|
+
static func log(_ message: String) {
|
|
25
|
+
print("LOG", message)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static func schedule(for controller: MainWindowController) {
|
|
29
|
+
let environment = Self.environment
|
|
30
|
+
guard environment["STOR_DEV_EVAL"] != nil || environment["STOR_DEV_EVAL_FILE"] != nil || environment["STOR_DEV_SCREENSHOT"] != nil || environment["STOR_DEV_NAVIGATE"] != nil else { return }
|
|
31
|
+
let delay = Double(environment["STOR_DEV_DELAY"] ?? "") ?? 6
|
|
32
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
33
|
+
Task { @MainActor in
|
|
34
|
+
if let path = environment["STOR_DEV_NAVIGATE"] {
|
|
35
|
+
controller.host.navigate(to: path)
|
|
36
|
+
try? await Task.sleep(for: .seconds(1))
|
|
37
|
+
}
|
|
38
|
+
let script = environment["STOR_DEV_EVAL"] ?? environment["STOR_DEV_EVAL_FILE"].flatMap { try? String(contentsOfFile: $0, encoding: .utf8) }
|
|
39
|
+
if let body = script {
|
|
40
|
+
do {
|
|
41
|
+
let value = try await controller.host.webView.callAsyncJavaScript(body, contentWorld: .page)
|
|
42
|
+
print("EVAL", value ?? "nil")
|
|
43
|
+
} catch {
|
|
44
|
+
print("EVAL error:", error.localizedDescription)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if let clicks = environment["STOR_DEV_CLICK"], let window = controller.window {
|
|
48
|
+
for (index, spec) in clicks.split(separator: ";").enumerated() {
|
|
49
|
+
let parts = spec.split(separator: ",").compactMap { Double($0) }
|
|
50
|
+
guard parts.count == 2,
|
|
51
|
+
let event = NSEvent.mouseEvent(with: .leftMouseDown, location: NSPoint(x: parts[0], y: window.frame.height - parts[1]), modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, eventNumber: index + 1, clickCount: 1, pressure: 1)
|
|
52
|
+
else { continue }
|
|
53
|
+
log("click \(spec)")
|
|
54
|
+
window.sendEvent(event)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
try? await Task.sleep(for: .seconds(Double(environment["STOR_DEV_SETTLE"] ?? "") ?? 0))
|
|
58
|
+
if let window = controller.window, let close = window.standardWindowButton(.closeButton) {
|
|
59
|
+
var chain: [String] = []
|
|
60
|
+
var view: NSView? = close
|
|
61
|
+
while let current = view { chain.append("\(type(of: current)) \(current.frame)"); view = current.superview }
|
|
62
|
+
print("TITLEBAR", chain.joined(separator: " < "))
|
|
63
|
+
}
|
|
64
|
+
if let window = controller.window {
|
|
65
|
+
let buttons = [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton].compactMap { window.standardWindowButton($0)?.frame }
|
|
66
|
+
print("STATE badge=\(NSApp.dockTile.badgeLabel ?? "none") menus=\((NSApp.delegate as? AppDelegate)?.menuSummary ?? "")")
|
|
67
|
+
print("WINDOW key=\(window.isKeyWindow) visible=\(window.isVisible) occluded=\(!window.occlusionState.contains(.visible)) appActive=\(NSApp.isActive) frame=\(window.frame) buttons=\(buttons.map { "\($0.origin.x),\($0.origin.y)" })")
|
|
68
|
+
}
|
|
69
|
+
if let path = environment["STOR_DEV_SCREENSHOT"], let window = controller.window {
|
|
70
|
+
print("SCREENSHOT", capture(window, to: path) ? path : "failed")
|
|
71
|
+
}
|
|
72
|
+
if environment["STOR_DEV_EXIT"] == "1" { exit(0) }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private static func capture(_ window: NSWindow, to path: String) -> Bool {
|
|
78
|
+
let options: CGWindowImageOption = [.boundsIgnoreFraming, .bestResolution]
|
|
79
|
+
guard let image = CGWindowListCreateImage(.null, .optionIncludingWindow, CGWindowID(window.windowNumber), options),
|
|
80
|
+
let png = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:])
|
|
81
|
+
else { return false }
|
|
82
|
+
return (try? png.write(to: URL(fileURLWithPath: path))) != nil
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
#endif
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
/// One window onto the site. The title bar is hidden and the content runs under it: the page draws
|
|
5
|
+
/// the sidebar, the window's glass shows through it, and the standard window buttons sit on the
|
|
6
|
+
/// sidebar's brand row.
|
|
7
|
+
@MainActor
|
|
8
|
+
final class MainWindowController: NSWindowController, NSWindowDelegate, StorWebHostDelegate {
|
|
9
|
+
let host: StorWebHost
|
|
10
|
+
private(set) var pendingUploads = 0
|
|
11
|
+
|
|
12
|
+
private unowned let app: AppDelegate
|
|
13
|
+
private let lights: TrafficLights
|
|
14
|
+
private var signIn: SignInWindowController?
|
|
15
|
+
private var titleObservation: NSKeyValueObservation?
|
|
16
|
+
|
|
17
|
+
/// Smaller than this the page would switch to its phone layout, which a Mac window never shows.
|
|
18
|
+
static let minimumSize = NSSize(width: 860, height: 560)
|
|
19
|
+
|
|
20
|
+
init(site: SiteConfig, app: AppDelegate, inspectable: Bool, restoresFrame: Bool) {
|
|
21
|
+
self.app = app
|
|
22
|
+
let window = StorWindow(
|
|
23
|
+
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 820),
|
|
24
|
+
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
|
25
|
+
backing: .buffered,
|
|
26
|
+
defer: false
|
|
27
|
+
)
|
|
28
|
+
window.title = site.title
|
|
29
|
+
window.titleVisibility = .hidden
|
|
30
|
+
window.titlebarAppearsTransparent = true
|
|
31
|
+
window.tabbingMode = .disallowed
|
|
32
|
+
window.isReleasedWhenClosed = false
|
|
33
|
+
window.contentMinSize = Self.minimumSize
|
|
34
|
+
|
|
35
|
+
host = StorWebHost(site: site, kind: .mac, inspectable: inspectable, lightsWidth: TrafficLights.extent(of: window))
|
|
36
|
+
lights = TrafficLights(window: window)
|
|
37
|
+
|
|
38
|
+
// The glass behind the page, with one canvas tint across the window and a translucent sidebar.
|
|
39
|
+
let glass = NSVisualEffectView()
|
|
40
|
+
glass.material = .sidebar
|
|
41
|
+
glass.blendingMode = .behindWindow
|
|
42
|
+
glass.state = .followsWindowActiveState
|
|
43
|
+
host.webView.frame = glass.bounds
|
|
44
|
+
host.webView.autoresizingMask = [.width, .height]
|
|
45
|
+
glass.addSubview(host.webView)
|
|
46
|
+
window.contentView = glass
|
|
47
|
+
window.pageView = host.webView
|
|
48
|
+
|
|
49
|
+
window.center()
|
|
50
|
+
if restoresFrame { window.setFrameAutosaveName("StorWindow.\(site.id)") }
|
|
51
|
+
|
|
52
|
+
super.init(window: window)
|
|
53
|
+
window.delegate = self
|
|
54
|
+
host.delegate = self
|
|
55
|
+
titleObservation = host.webView.observe(\.title, options: [.new]) { [weak window] webView, _ in
|
|
56
|
+
if let title = webView.title, !title.isEmpty { window?.title = title }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
|
61
|
+
|
|
62
|
+
override func showWindow(_ sender: Any?) {
|
|
63
|
+
super.showWindow(sender)
|
|
64
|
+
lights.apply()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// MARK: Window
|
|
68
|
+
|
|
69
|
+
func windowDidBecomeKey(_ notification: Notification) { host.setAttribute("data-stor-inactive", false) }
|
|
70
|
+
func windowDidResignKey(_ notification: Notification) { host.setAttribute("data-stor-inactive", true) }
|
|
71
|
+
func windowWillEnterFullScreen(_ notification: Notification) { host.setAttribute("data-stor-fullscreen", true) }
|
|
72
|
+
func windowDidExitFullScreen(_ notification: Notification) {
|
|
73
|
+
host.setAttribute("data-stor-fullscreen", false)
|
|
74
|
+
lights.apply()
|
|
75
|
+
}
|
|
76
|
+
func windowWillClose(_ notification: Notification) { app.windowClosed(self) }
|
|
77
|
+
|
|
78
|
+
// MARK: StorWebHostDelegate
|
|
79
|
+
|
|
80
|
+
func host(_ host: StorWebHost, received message: ShellMessage) {
|
|
81
|
+
switch message {
|
|
82
|
+
case .lights(let x, let y): lights.move(to: CGPoint(x: x, y: y))
|
|
83
|
+
case .backgroundHover(let on): (window as? StorWindow)?.backgroundHover = on
|
|
84
|
+
case .outbox(let pending): pendingUploads = pending
|
|
85
|
+
case .haptic, .drag: break
|
|
86
|
+
case .appearance, .badge, .reminders, .menu: app.handle(message)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func host(_ host: StorWebHost, openWindowFor configuration: WKWebViewConfiguration, url: URL) -> WKWebView? {
|
|
91
|
+
app.openPopup(configuration: configuration, host: host)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func host(_ host: StorWebHost, showSignIn url: URL) {
|
|
95
|
+
#if STOR_DEV
|
|
96
|
+
DevTools.log("sign-in window for \(url)")
|
|
97
|
+
#endif
|
|
98
|
+
if let signIn {
|
|
99
|
+
signIn.showWindow(nil)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
let controller = SignInWindowController(site: host.site, policy: host.policy, dataStore: host.dataStore, url: url) { [weak self] success in
|
|
103
|
+
guard let self else { return }
|
|
104
|
+
self.signIn = nil
|
|
105
|
+
#if STOR_DEV
|
|
106
|
+
DevTools.log("sign-in finished success=\(success)")
|
|
107
|
+
#endif
|
|
108
|
+
if success { self.host.reloadAfterSignIn() }
|
|
109
|
+
}
|
|
110
|
+
signIn = controller
|
|
111
|
+
controller.showWindow(nil)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func host(_ host: StorWebHost, openExternally url: URL) {
|
|
115
|
+
NSWorkspace.shared.open(url)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func host(_ host: StorWebHost, chooseDestinationFor suggestedFilename: String) async -> URL? {
|
|
119
|
+
#if STOR_DEV
|
|
120
|
+
// No panel for automated runs.
|
|
121
|
+
if let directory = DevTools.environment["STOR_DEV_DOWNLOAD_DIR"] {
|
|
122
|
+
DevTools.log("download \(suggestedFilename)")
|
|
123
|
+
return URL(fileURLWithPath: directory).appendingPathComponent(suggestedFilename)
|
|
124
|
+
}
|
|
125
|
+
#endif
|
|
126
|
+
let panel = NSSavePanel()
|
|
127
|
+
panel.nameFieldStringValue = suggestedFilename
|
|
128
|
+
panel.canCreateDirectories = true
|
|
129
|
+
return await run(panel) == .OK ? panel.url : nil
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func host(_ host: StorWebHost, savedDownload url: URL) {
|
|
133
|
+
#if STOR_DEV
|
|
134
|
+
DevTools.log("download saved \(url.lastPathComponent)")
|
|
135
|
+
#endif
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func host(_ host: StorWebHost, chooseFilesFor parameters: WKOpenPanelParameters) async -> [URL]? {
|
|
139
|
+
let panel = NSOpenPanel()
|
|
140
|
+
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
|
|
141
|
+
panel.canChooseDirectories = parameters.allowsDirectories
|
|
142
|
+
panel.canChooseFiles = true
|
|
143
|
+
return await run(panel) == .OK ? panel.urls : nil
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func host(_ host: StorWebHost, present dialog: JSDialog) async -> JSDialogResult {
|
|
147
|
+
let alert = NSAlert()
|
|
148
|
+
alert.messageText = host.site.title
|
|
149
|
+
var field: NSTextField?
|
|
150
|
+
switch dialog {
|
|
151
|
+
case .alert(let message):
|
|
152
|
+
alert.informativeText = message
|
|
153
|
+
alert.addButton(withTitle: "OK")
|
|
154
|
+
case .confirm(let message):
|
|
155
|
+
alert.informativeText = message
|
|
156
|
+
alert.addButton(withTitle: "OK")
|
|
157
|
+
alert.addButton(withTitle: "Cancel")
|
|
158
|
+
case .prompt(let message, let defaultText):
|
|
159
|
+
alert.informativeText = message
|
|
160
|
+
alert.addButton(withTitle: "OK")
|
|
161
|
+
alert.addButton(withTitle: "Cancel")
|
|
162
|
+
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
|
163
|
+
input.stringValue = defaultText ?? ""
|
|
164
|
+
alert.accessoryView = input
|
|
165
|
+
field = input
|
|
166
|
+
}
|
|
167
|
+
let response: NSApplication.ModalResponse
|
|
168
|
+
if let window { response = await alert.beginSheetModal(for: window) } else { response = alert.runModal() }
|
|
169
|
+
return response == .alertFirstButtonReturn ? .ok(field?.stringValue) : .cancel
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// Shows a save or open panel as a sheet on this window.
|
|
173
|
+
private func run(_ panel: NSSavePanel) async -> NSApplication.ModalResponse {
|
|
174
|
+
if let window { return await panel.beginSheetModal(for: window) }
|
|
175
|
+
return await panel.begin()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// A window the page opened itself, such as a PDF in its own viewer. It shares the site's data store
|
|
180
|
+
/// and follows the same navigation rules as the main window.
|
|
181
|
+
@MainActor
|
|
182
|
+
final class PopupWindowController: NSWindowController, NSWindowDelegate {
|
|
183
|
+
private let onClose: (PopupWindowController) -> Void
|
|
184
|
+
|
|
185
|
+
init(webView: WKWebView, title: String, onClose: @escaping (PopupWindowController) -> Void) {
|
|
186
|
+
self.onClose = onClose
|
|
187
|
+
let window = NSWindow(
|
|
188
|
+
contentRect: NSRect(x: 0, y: 0, width: 920, height: 1040),
|
|
189
|
+
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
190
|
+
backing: .buffered,
|
|
191
|
+
defer: false
|
|
192
|
+
)
|
|
193
|
+
window.title = title
|
|
194
|
+
window.isReleasedWhenClosed = false
|
|
195
|
+
webView.frame = NSRect(origin: .zero, size: window.contentLayoutRect.size)
|
|
196
|
+
webView.autoresizingMask = [.width, .height]
|
|
197
|
+
window.contentView = webView
|
|
198
|
+
window.center()
|
|
199
|
+
super.init(window: window)
|
|
200
|
+
window.delegate = self
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
|
204
|
+
|
|
205
|
+
func windowWillClose(_ notification: Notification) { onClose(self) }
|
|
206
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
/// The Cloudflare Access login, in its own window. It shares the app's data store, so the session
|
|
5
|
+
/// cookie Access sets is the one the app's web view sends. The window closes itself as soon as
|
|
6
|
+
/// Access sends the person back to the app.
|
|
7
|
+
@MainActor
|
|
8
|
+
final class SignInWindowController: NSWindowController, NSWindowDelegate, WKNavigationDelegate {
|
|
9
|
+
private let policy: NavigationPolicy
|
|
10
|
+
private let webView: WKWebView
|
|
11
|
+
private let onFinished: (Bool) -> Void
|
|
12
|
+
private var finished = false
|
|
13
|
+
|
|
14
|
+
init(site: SiteConfig, policy: NavigationPolicy, dataStore: WKWebsiteDataStore, url: URL, onFinished: @escaping (Bool) -> Void) {
|
|
15
|
+
self.policy = policy
|
|
16
|
+
self.onFinished = onFinished
|
|
17
|
+
|
|
18
|
+
let configuration = WKWebViewConfiguration()
|
|
19
|
+
configuration.websiteDataStore = dataStore
|
|
20
|
+
webView = WKWebView(frame: .zero, configuration: configuration)
|
|
21
|
+
|
|
22
|
+
let window = NSWindow(
|
|
23
|
+
contentRect: NSRect(x: 0, y: 0, width: 480, height: 700),
|
|
24
|
+
styleMask: [.titled, .closable, .miniaturizable],
|
|
25
|
+
backing: .buffered,
|
|
26
|
+
defer: false
|
|
27
|
+
)
|
|
28
|
+
window.title = "Sign in to \(site.title)"
|
|
29
|
+
window.isReleasedWhenClosed = false
|
|
30
|
+
window.contentView = webView
|
|
31
|
+
window.center()
|
|
32
|
+
super.init(window: window)
|
|
33
|
+
|
|
34
|
+
window.delegate = self
|
|
35
|
+
webView.navigationDelegate = self
|
|
36
|
+
webView.load(URLRequest(url: url))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
|
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
|
+
func windowWillClose(_ notification: Notification) {
|
|
51
|
+
finish(success: false)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private func finish(success: Bool) {
|
|
55
|
+
guard !finished else { return }
|
|
56
|
+
finished = true
|
|
57
|
+
onFinished(success)
|
|
58
|
+
if success { close() }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// The Mac window. A click on a background of the page (the shell's script reports when the pointer is
|
|
4
|
+
/// over one) moves the window. The mouse-down has to be handled here, before any view sees it and with
|
|
5
|
+
/// the very event that started it: a drag started later, from a message, is not reliable.
|
|
6
|
+
@MainActor
|
|
7
|
+
final class StorWindow: NSWindow {
|
|
8
|
+
/// Set from the page: the pointer is over something that is not a control, text or a graphic.
|
|
9
|
+
var backgroundHover = false
|
|
10
|
+
/// The web view; only clicks that land inside it can be background clicks, so the window buttons
|
|
11
|
+
/// and the native title bar strip keep behaving as they do in any Mac window.
|
|
12
|
+
weak var pageView: NSView?
|
|
13
|
+
|
|
14
|
+
override func sendEvent(_ event: NSEvent) {
|
|
15
|
+
if event.type == .leftMouseDown, backgroundHover, startsBackgroundDrag(event) { return }
|
|
16
|
+
super.sendEvent(event)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private func startsBackgroundDrag(_ event: NSEvent) -> Bool {
|
|
20
|
+
#if STOR_DEV
|
|
21
|
+
if let frameView = contentView?.superview {
|
|
22
|
+
let hit = frameView.hitTest(event.locationInWindow)
|
|
23
|
+
var chain: [String] = []
|
|
24
|
+
var view = hit
|
|
25
|
+
while let current = view, chain.count < 8 { chain.append(String(describing: type(of: current))); view = current.superview }
|
|
26
|
+
DevTools.log("hit \(chain.joined(separator: " < ")) inPage=\(hit?.isDescendant(of: pageView ?? frameView) == true) frameView=\(type(of: frameView))")
|
|
27
|
+
}
|
|
28
|
+
#endif
|
|
29
|
+
// Modified clicks are the page's (selection, context menus).
|
|
30
|
+
guard event.modifierFlags.intersection([.control, .option, .command, .shift]).isEmpty,
|
|
31
|
+
let pageView, let frameView = contentView?.superview,
|
|
32
|
+
frameView.hitTest(event.locationInWindow)?.isDescendant(of: pageView) == true
|
|
33
|
+
else { return false }
|
|
34
|
+
#if STOR_DEV
|
|
35
|
+
DevTools.log("background mouse-down clickCount=\(event.clickCount)")
|
|
36
|
+
#endif
|
|
37
|
+
if event.clickCount >= 2 {
|
|
38
|
+
switch UserDefaults.standard.string(forKey: "AppleActionOnDoubleClick") {
|
|
39
|
+
case "Minimize": miniaturize(nil)
|
|
40
|
+
case "None": break
|
|
41
|
+
default: zoom(nil)
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
performDrag(with: event)
|
|
45
|
+
}
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// Places the standard window buttons where the page's sidebar reserved room for them. The page
|
|
4
|
+
/// reports that spot (`[data-stor-lights]`), so the layout decides it and it follows the sidebar.
|
|
5
|
+
@MainActor
|
|
6
|
+
final class TrafficLights {
|
|
7
|
+
/// Before the page reports: the expanded sidebar's brand row.
|
|
8
|
+
static let fallback = CGPoint(x: 26, y: 38)
|
|
9
|
+
|
|
10
|
+
private weak var window: NSWindow?
|
|
11
|
+
private var anchor = TrafficLights.fallback
|
|
12
|
+
private let buttonSpacing: CGFloat
|
|
13
|
+
private var observers: [NSObjectProtocol] = []
|
|
14
|
+
private var applyScheduled = false
|
|
15
|
+
private var isApplying = false
|
|
16
|
+
|
|
17
|
+
init(window: NSWindow) {
|
|
18
|
+
self.window = window
|
|
19
|
+
// Keep AppKit's original spacing: a frame notification may arrive while only one button
|
|
20
|
+
// has been reset, so measuring the partly updated group would give the wrong spacing.
|
|
21
|
+
if let close = window.standardWindowButton(.closeButton),
|
|
22
|
+
let miniaturize = window.standardWindowButton(.miniaturizeButton) {
|
|
23
|
+
buttonSpacing = miniaturize.frame.minX - close.frame.minX
|
|
24
|
+
} else {
|
|
25
|
+
buttonSpacing = 20
|
|
26
|
+
}
|
|
27
|
+
let center = NotificationCenter.default
|
|
28
|
+
// AppKit lays the buttons out again on these, so put them back each time.
|
|
29
|
+
for name in [NSWindow.didResizeNotification, NSWindow.didEndLiveResizeNotification,
|
|
30
|
+
NSWindow.didExitFullScreenNotification, NSWindow.didBecomeKeyNotification,
|
|
31
|
+
NSWindow.didResignKeyNotification] {
|
|
32
|
+
observers.append(center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
|
|
33
|
+
MainActor.assumeIsolated {
|
|
34
|
+
if name == NSWindow.didResizeNotification {
|
|
35
|
+
// AppKit has finished this resize's layout. Restore the group before it
|
|
36
|
+
// is displayed; queued work can wait until the mouse is released.
|
|
37
|
+
self?.apply()
|
|
38
|
+
} else {
|
|
39
|
+
self?.scheduleApply()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
let close = window.standardWindowButton(.closeButton)
|
|
45
|
+
// Focus changes can reset just the buttons without changing their container's frame.
|
|
46
|
+
let views: [NSView?] = [close, window.standardWindowButton(.miniaturizeButton),
|
|
47
|
+
window.standardWindowButton(.zoomButton), close?.superview,
|
|
48
|
+
close?.superview?.superview]
|
|
49
|
+
for view in views.compactMap({ $0 }) {
|
|
50
|
+
view.postsFrameChangedNotifications = true
|
|
51
|
+
observers.append(center.addObserver(forName: NSView.frameDidChangeNotification, object: view, queue: .main) { [weak self] _ in
|
|
52
|
+
MainActor.assumeIsolated { self?.scheduleApply() }
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
deinit {
|
|
58
|
+
observers.forEach(NotificationCenter.default.removeObserver)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Width the three buttons take, so the page can leave exactly that much room. Spacing differs between macOS versions.
|
|
62
|
+
static func extent(of window: NSWindow) -> CGFloat {
|
|
63
|
+
guard let close = window.standardWindowButton(.closeButton),
|
|
64
|
+
let zoom = window.standardWindowButton(.zoomButton)
|
|
65
|
+
else { return 62 }
|
|
66
|
+
let width = zoom.frame.maxX - close.frame.minX
|
|
67
|
+
return width >= 40 ? ceil(width) : 62
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func move(to anchor: CGPoint) {
|
|
71
|
+
self.anchor = anchor
|
|
72
|
+
apply()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private func scheduleApply() {
|
|
76
|
+
guard !isApplying, !applyScheduled else { return }
|
|
77
|
+
applyScheduled = true
|
|
78
|
+
// Finish the current AppKit layout before moving the group; never move one button back
|
|
79
|
+
// synchronously while AppKit is still resetting the others.
|
|
80
|
+
DispatchQueue.main.async { [weak self] in
|
|
81
|
+
guard let self else { return }
|
|
82
|
+
self.applyScheduled = false
|
|
83
|
+
self.apply()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func apply() {
|
|
88
|
+
guard !isApplying, let window, !window.styleMask.contains(.fullScreen),
|
|
89
|
+
let close = window.standardWindowButton(.closeButton),
|
|
90
|
+
let miniaturize = window.standardWindowButton(.miniaturizeButton),
|
|
91
|
+
let zoom = window.standardWindowButton(.zoomButton),
|
|
92
|
+
let container = close.superview
|
|
93
|
+
else { return }
|
|
94
|
+
isApplying = true
|
|
95
|
+
defer { isApplying = false }
|
|
96
|
+
let step = buttonSpacing
|
|
97
|
+
// macOS only hit-tests a view inside its container, so a button moved below the title bar would stop
|
|
98
|
+
// working. Grow the title bar container to hold them, and only as wide as they are, so the page's
|
|
99
|
+
// own controls along the top edge stay reachable.
|
|
100
|
+
if let titlebarView = close.superview, let titlebarContainer = titlebarView.superview, titlebarContainer !== window.contentView?.superview {
|
|
101
|
+
let height = ceil(anchor.y + close.frame.height)
|
|
102
|
+
let width = ceil(anchor.x + 2 * step + close.frame.width + 12)
|
|
103
|
+
let frame = NSRect(x: 0, y: window.frame.height - height, width: width, height: height)
|
|
104
|
+
if titlebarContainer.frame != frame { titlebarContainer.frame = frame }
|
|
105
|
+
if titlebarView.frame != titlebarContainer.bounds { titlebarView.frame = titlebarContainer.bounds }
|
|
106
|
+
}
|
|
107
|
+
// The page measures from the window's top; window coordinates start at the bottom.
|
|
108
|
+
let centerY = window.frame.height - anchor.y
|
|
109
|
+
for (index, button) in [close, miniaturize, zoom].enumerated() {
|
|
110
|
+
let point = container.convert(NSPoint(x: anchor.x + CGFloat(index) * step, y: centerY), from: nil)
|
|
111
|
+
let origin = NSPoint(x: point.x, y: point.y - button.frame.height / 2)
|
|
112
|
+
if button.frame.origin != origin { button.setFrameOrigin(origin) }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
package/README.md
DELETED