@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,318 @@
|
|
|
1
|
+
import WebKit
|
|
2
|
+
|
|
3
|
+
#if os(macOS)
|
|
4
|
+
import AppKit
|
|
5
|
+
#else
|
|
6
|
+
import UIKit
|
|
7
|
+
#endif
|
|
8
|
+
|
|
9
|
+
/// A dialog the page asked for with `alert`, `confirm` or `prompt`.
|
|
10
|
+
enum JSDialog {
|
|
11
|
+
case alert(String)
|
|
12
|
+
case confirm(String)
|
|
13
|
+
case prompt(String, defaultText: String?)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
enum JSDialogResult {
|
|
17
|
+
case ok(String?)
|
|
18
|
+
case cancel
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// The platform half of the shell: windows, panels and system services. The web view and the
|
|
22
|
+
/// rules around it live in `StorWebHost`, which is the same on the Mac and on iOS.
|
|
23
|
+
@MainActor
|
|
24
|
+
protocol StorWebHostDelegate: AnyObject {
|
|
25
|
+
func host(_ host: StorWebHost, received message: ShellMessage)
|
|
26
|
+
/// A page asked for a new window (`window.open`, `target="_blank"`) on the app's own origin.
|
|
27
|
+
func host(_ host: StorWebHost, openWindowFor configuration: WKWebViewConfiguration, url: URL) -> WKWebView?
|
|
28
|
+
func host(_ host: StorWebHost, showSignIn url: URL)
|
|
29
|
+
func host(_ host: StorWebHost, openExternally url: URL)
|
|
30
|
+
/// Where to save a download, or nil to cancel it.
|
|
31
|
+
func host(_ host: StorWebHost, chooseDestinationFor suggestedFilename: String) async -> URL?
|
|
32
|
+
func host(_ host: StorWebHost, savedDownload url: URL)
|
|
33
|
+
func host(_ host: StorWebHost, present dialog: JSDialog) async -> JSDialogResult
|
|
34
|
+
#if os(macOS)
|
|
35
|
+
func host(_ host: StorWebHost, chooseFilesFor parameters: WKOpenPanelParameters) async -> [URL]?
|
|
36
|
+
#endif
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Owns the `WKWebView` that shows the site and applies the shell's rules to it: which pages may
|
|
40
|
+
/// load, where downloads go, which permissions the app's own origin gets, and how the page and the
|
|
41
|
+
/// shell talk to each other.
|
|
42
|
+
@MainActor
|
|
43
|
+
final class StorWebHost: NSObject {
|
|
44
|
+
static let messageHandlerName = "stor"
|
|
45
|
+
static let bridgeVersion = 1
|
|
46
|
+
|
|
47
|
+
let site: SiteConfig
|
|
48
|
+
let policy: NavigationPolicy
|
|
49
|
+
let dataStore: WKWebsiteDataStore
|
|
50
|
+
let webView: WKWebView
|
|
51
|
+
weak var delegate: StorWebHostDelegate?
|
|
52
|
+
|
|
53
|
+
private var downloadDestinations: [ObjectIdentifier: URL] = [:]
|
|
54
|
+
|
|
55
|
+
init(site: SiteConfig, kind: ShellKind, inspectable: Bool, lightsWidth: CGFloat? = nil) {
|
|
56
|
+
self.site = site
|
|
57
|
+
policy = NavigationPolicy(origin: site.origin, accessHost: site.accessHost)
|
|
58
|
+
dataStore = WKWebsiteDataStore(forIdentifier: site.dataStoreIdentifier)
|
|
59
|
+
|
|
60
|
+
let configuration = WKWebViewConfiguration()
|
|
61
|
+
configuration.websiteDataStore = dataStore
|
|
62
|
+
#if STOR_DEV
|
|
63
|
+
// Scripted runs have no user gesture, which window.open normally needs.
|
|
64
|
+
if ProcessInfo.processInfo.environment["STOR_DEV_ALLOW_POPUPS"] == "1" {
|
|
65
|
+
configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
|
|
66
|
+
}
|
|
67
|
+
#endif
|
|
68
|
+
#if STOR_DEV
|
|
69
|
+
let legacyPage = ProcessInfo.processInfo.environment["STOR_DEV_LEGACY_PAGE"] == "1"
|
|
70
|
+
#else
|
|
71
|
+
let legacyPage = false
|
|
72
|
+
#endif
|
|
73
|
+
configuration.userContentController.addUserScript(WKUserScript(
|
|
74
|
+
source: legacyPage ? "" : Self.bootstrapScript(kind: kind, site: site.id, lightsWidth: lightsWidth),
|
|
75
|
+
injectionTime: .atDocumentStart,
|
|
76
|
+
forMainFrameOnly: true
|
|
77
|
+
))
|
|
78
|
+
if kind == .mac {
|
|
79
|
+
configuration.userContentController.addUserScript(WKUserScript(
|
|
80
|
+
source: WindowDragScript.source,
|
|
81
|
+
injectionTime: .atDocumentStart,
|
|
82
|
+
forMainFrameOnly: true
|
|
83
|
+
))
|
|
84
|
+
}
|
|
85
|
+
#if os(iOS)
|
|
86
|
+
// Service workers and script messages need App-Bound Domains on iOS; the tenant host is
|
|
87
|
+
// listed in Info.plist as WKAppBoundDomains.
|
|
88
|
+
if Bundle.main.object(forInfoDictionaryKey: "WKAppBoundDomains") != nil {
|
|
89
|
+
configuration.limitsNavigationsToAppBoundDomains = true
|
|
90
|
+
}
|
|
91
|
+
configuration.allowsInlineMediaPlayback = true
|
|
92
|
+
#endif
|
|
93
|
+
|
|
94
|
+
webView = WKWebView(frame: .zero, configuration: configuration)
|
|
95
|
+
webView.isInspectable = inspectable
|
|
96
|
+
webView.allowsBackForwardNavigationGestures = true
|
|
97
|
+
#if os(macOS)
|
|
98
|
+
webView.allowsMagnification = false
|
|
99
|
+
// The window's glass shows through the transparent page (see app.css, html[data-stor-shell="mac"]).
|
|
100
|
+
webView.setValue(false, forKey: "drawsBackground")
|
|
101
|
+
#else
|
|
102
|
+
webView.allowsLinkPreview = false
|
|
103
|
+
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
|
104
|
+
webView.scrollView.bounces = false
|
|
105
|
+
webView.scrollView.pinchGestureRecognizer?.isEnabled = false
|
|
106
|
+
#endif
|
|
107
|
+
super.init()
|
|
108
|
+
|
|
109
|
+
configuration.userContentController.add(MessageProxy(host: self), name: Self.messageHandlerName)
|
|
110
|
+
webView.navigationDelegate = self
|
|
111
|
+
webView.uiDelegate = self
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// MARK: Loading
|
|
115
|
+
|
|
116
|
+
func load(path: String = "/") {
|
|
117
|
+
guard ShellMessage.isAppPath(path), let url = URL(string: path, relativeTo: site.origin)?.absoluteURL else { return }
|
|
118
|
+
webView.load(URLRequest(url: url))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Back from the sign-in window: the cookie is set, so open the page again.
|
|
122
|
+
func reloadAfterSignIn() {
|
|
123
|
+
if webView.url == nil || webView.url?.scheme == "about" {
|
|
124
|
+
load()
|
|
125
|
+
} else {
|
|
126
|
+
webView.reload()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// MARK: Talking to the page
|
|
131
|
+
|
|
132
|
+
/// Sends an event the page listens for (`window` event `stor-shell`).
|
|
133
|
+
func dispatch(_ detail: [String: Any]) {
|
|
134
|
+
guard let data = try? JSONSerialization.data(withJSONObject: detail),
|
|
135
|
+
let json = String(data: data, encoding: .utf8)
|
|
136
|
+
else { return }
|
|
137
|
+
webView.evaluateJavaScript("window.dispatchEvent(new CustomEvent('stor-shell', { detail: \(json) }))", completionHandler: nil)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func navigate(to path: String) {
|
|
141
|
+
guard ShellMessage.isAppPath(path) else { return }
|
|
142
|
+
dispatch(["type": "navigate", "path": path])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// Marks window state on `<html>` so CSS can react (`data-stor-inactive`, `data-stor-fullscreen`).
|
|
146
|
+
func setAttribute(_ name: String, _ on: Bool) {
|
|
147
|
+
webView.evaluateJavaScript("document.documentElement.toggleAttribute('\(name)', \(on))", completionHandler: nil)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private static func bootstrapScript(kind: ShellKind, site: String, lightsWidth: CGFloat?) -> String {
|
|
151
|
+
let lights = lightsWidth.map { ", lights: \(Int($0))" } ?? ""
|
|
152
|
+
return """
|
|
153
|
+
(() => {
|
|
154
|
+
if (window.storShell) return;
|
|
155
|
+
Object.defineProperty(window, 'storShell', {
|
|
156
|
+
value: Object.freeze({ kind: '\(kind.rawValue)', site: '\(site)', version: \(bridgeVersion)\(lights) }),
|
|
157
|
+
enumerable: false,
|
|
158
|
+
});
|
|
159
|
+
})();
|
|
160
|
+
"""
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fileprivate func receive(_ message: WKScriptMessage) {
|
|
164
|
+
// Only the app's own page may drive the shell, never an embedded frame from elsewhere.
|
|
165
|
+
guard message.frameInfo.isMainFrame,
|
|
166
|
+
message.frameInfo.securityOrigin.host.lowercased() == site.host,
|
|
167
|
+
let parsed = ShellMessage(message.body)
|
|
168
|
+
else { return }
|
|
169
|
+
delegate?.host(self, received: parsed)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private static func errorPage(for site: SiteConfig) -> String {
|
|
173
|
+
"""
|
|
174
|
+
<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
175
|
+
<style>
|
|
176
|
+
:root { color-scheme: light dark; font: 15px/1.5 -apple-system, system-ui, sans-serif; }
|
|
177
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; text-align: center; padding: 24px; box-sizing: border-box; }
|
|
178
|
+
p { margin: 0 0 16px; color: GrayText; } h1 { margin: 0 0 6px; font-size: 20px; }
|
|
179
|
+
a { display: inline-block; padding: 10px 18px; border-radius: 999px; background: AccentColor; color: AccentColorText; text-decoration: none; font-weight: 600; }
|
|
180
|
+
</style>
|
|
181
|
+
<main><h1>\(site.title) can't open yet</h1><p>Connect to the internet once so the app can download itself.</p>
|
|
182
|
+
<a href="\(site.origin.absoluteString)">Try again</a></main>
|
|
183
|
+
"""
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// `WKUserContentController` retains its handlers, so the host must not be one directly.
|
|
188
|
+
private final class MessageProxy: NSObject, WKScriptMessageHandler {
|
|
189
|
+
weak var host: StorWebHost?
|
|
190
|
+
|
|
191
|
+
init(host: StorWebHost) { self.host = host }
|
|
192
|
+
|
|
193
|
+
@MainActor
|
|
194
|
+
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
195
|
+
host?.receive(message)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// MARK: - Navigation
|
|
200
|
+
|
|
201
|
+
extension StorWebHost: WKNavigationDelegate {
|
|
202
|
+
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
|
|
203
|
+
if navigationAction.shouldPerformDownload { return .download }
|
|
204
|
+
let url = navigationAction.request.url
|
|
205
|
+
let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
|
|
206
|
+
switch policy.decide(url, isMainFrame: isMainFrame) {
|
|
207
|
+
case .allow:
|
|
208
|
+
return .allow
|
|
209
|
+
case .external:
|
|
210
|
+
if let url { delegate?.host(self, openExternally: url) }
|
|
211
|
+
return .cancel
|
|
212
|
+
case .signIn:
|
|
213
|
+
if let url { delegate?.host(self, showSignIn: url) }
|
|
214
|
+
return .cancel
|
|
215
|
+
case .cancel:
|
|
216
|
+
return .cancel
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy {
|
|
221
|
+
if let response = navigationResponse.response as? HTTPURLResponse,
|
|
222
|
+
response.value(forHTTPHeaderField: "Content-Disposition")?.lowercased().hasPrefix("attachment") == true {
|
|
223
|
+
return .download
|
|
224
|
+
}
|
|
225
|
+
return navigationResponse.canShowMIMEType ? .allow : .download
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
|
229
|
+
download.delegate = self
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
|
|
233
|
+
download.delegate = self
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
237
|
+
showErrorPageIfNeeded(webView, error: error)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
241
|
+
showErrorPageIfNeeded(webView, error: error)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// A first launch without a connection has nothing cached to show. Cancelled loads and loads our
|
|
245
|
+
/// own policy interrupted are not failures.
|
|
246
|
+
private func showErrorPageIfNeeded(_ webView: WKWebView, error: Error) {
|
|
247
|
+
let nsError = error as NSError
|
|
248
|
+
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { return }
|
|
249
|
+
if nsError.domain == "WebKitErrorDomain" && nsError.code == 102 { return }
|
|
250
|
+
guard webView === self.webView, webView.url == nil || webView.url?.scheme == "about" else { return }
|
|
251
|
+
webView.loadHTMLString(Self.errorPage(for: site), baseURL: nil)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// MARK: - Downloads
|
|
256
|
+
|
|
257
|
+
extension StorWebHost: WKDownloadDelegate {
|
|
258
|
+
func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String) async -> URL? {
|
|
259
|
+
guard let destination = await delegate?.host(self, chooseDestinationFor: suggestedFilename) else { return nil }
|
|
260
|
+
// WebKit refuses to write over a file; the person already agreed to replace it.
|
|
261
|
+
try? FileManager.default.removeItem(at: destination)
|
|
262
|
+
downloadDestinations[ObjectIdentifier(download)] = destination
|
|
263
|
+
return destination
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
func downloadDidFinish(_ download: WKDownload) {
|
|
267
|
+
guard let destination = downloadDestinations.removeValue(forKey: ObjectIdentifier(download)) else { return }
|
|
268
|
+
delegate?.host(self, savedDownload: destination)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
|
|
272
|
+
if let destination = downloadDestinations.removeValue(forKey: ObjectIdentifier(download)) {
|
|
273
|
+
try? FileManager.default.removeItem(at: destination)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// MARK: - UI
|
|
279
|
+
|
|
280
|
+
extension StorWebHost: WKUIDelegate {
|
|
281
|
+
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
|
282
|
+
guard let url = navigationAction.request.url else { return nil }
|
|
283
|
+
switch policy.decide(url, isMainFrame: true) {
|
|
284
|
+
case .allow:
|
|
285
|
+
return delegate?.host(self, openWindowFor: configuration, url: url)
|
|
286
|
+
case .external:
|
|
287
|
+
delegate?.host(self, openExternally: url)
|
|
288
|
+
return nil
|
|
289
|
+
case .signIn, .cancel:
|
|
290
|
+
return nil
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async {
|
|
295
|
+
_ = await delegate?.host(self, present: .alert(message))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async -> Bool {
|
|
299
|
+
if case .ok = await delegate?.host(self, present: .confirm(message)) { return true }
|
|
300
|
+
return false
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo) async -> String? {
|
|
304
|
+
if case .ok(let text) = await delegate?.host(self, present: .prompt(prompt, defaultText: defaultText)) { return text ?? "" }
|
|
305
|
+
return nil
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/// The barcode scanner asks for the camera; only the app's own origin may have it.
|
|
309
|
+
func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin: WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision {
|
|
310
|
+
origin.host.lowercased() == site.host && type == .camera ? .grant : .deny
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#if os(macOS)
|
|
314
|
+
func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo) async -> [URL]? {
|
|
315
|
+
await delegate?.host(self, chooseFilesFor: parameters)
|
|
316
|
+
}
|
|
317
|
+
#endif
|
|
318
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/// Tells the shell whether the pointer is over a background, whichever version of the page is deployed.
|
|
2
|
+
/// The shell injects it, because the page cannot do this alone: a web view gives the window no drag area.
|
|
3
|
+
/// The window itself starts the drag on the mouse-down (`StorWindow`), which has to happen synchronously
|
|
4
|
+
/// and so cannot wait for the page to answer.
|
|
5
|
+
///
|
|
6
|
+
/// A background is anything that is not a control, not text, not a graphic and not clickable (a link,
|
|
7
|
+
/// button, field, role, `draggable`, `cursor: pointer`, or anything marked `data-stor-no-drag`).
|
|
8
|
+
/// `data-stor-drag` marks areas that count even over text. While a dialog, drawer or menu is open
|
|
9
|
+
/// nothing counts, so a click outside it still closes it.
|
|
10
|
+
enum WindowDragScript {
|
|
11
|
+
static let source = #"""
|
|
12
|
+
(() => {
|
|
13
|
+
if (window.__storWindowDrag) return;
|
|
14
|
+
window.__storWindowDrag = true;
|
|
15
|
+
const INTERACTIVE = 'a, button, input, select, textarea, summary, label, video, audio, iframe, canvas, embed, object, ' +
|
|
16
|
+
'[role="button"], [role="link"], [role="tab"], [role="switch"], [role="checkbox"], [role="radio"], [role="menuitem"], ' +
|
|
17
|
+
'[role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="slider"], [role="spinbutton"], [role="textbox"], ' +
|
|
18
|
+
'[role="searchbox"], [role="combobox"], [role="listbox"], [role="scrollbar"], [contenteditable]:not([contenteditable="false"]), ' +
|
|
19
|
+
'[draggable="true"], [tabindex]:not([tabindex="-1"])';
|
|
20
|
+
const OVERLAY = '[role="dialog"], [role="alertdialog"], [aria-modal="true"], [role="menu"], [role="listbox"], [data-stor-drawer]';
|
|
21
|
+
const ownText = (element) => {
|
|
22
|
+
for (const node of element.childNodes) if (node.nodeType === 3 && node.nodeValue.trim() !== '') return true;
|
|
23
|
+
return false;
|
|
24
|
+
};
|
|
25
|
+
const isBackground = (target) => {
|
|
26
|
+
if (!(target instanceof Element) || target instanceof SVGElement) return false;
|
|
27
|
+
if (target.closest(INTERACTIVE) || target.closest('[data-stor-no-drag]')) return false;
|
|
28
|
+
if (document.querySelector(OVERLAY)) return false;
|
|
29
|
+
if (target.closest('[data-stor-drag]')) return true;
|
|
30
|
+
if (ownText(target)) return false;
|
|
31
|
+
const cursor = getComputedStyle(target).cursor;
|
|
32
|
+
return cursor === 'auto' || cursor === 'default';
|
|
33
|
+
};
|
|
34
|
+
let reported = false;
|
|
35
|
+
let lastTarget = null;
|
|
36
|
+
const report = (on) => {
|
|
37
|
+
if (on === reported) return;
|
|
38
|
+
reported = on;
|
|
39
|
+
try {
|
|
40
|
+
window.webkit.messageHandlers.stor.postMessage({ type: 'backgroundHover', body: { on } });
|
|
41
|
+
} catch {}
|
|
42
|
+
};
|
|
43
|
+
const options = { capture: true, passive: true };
|
|
44
|
+
document.addEventListener('mousemove', (event) => {
|
|
45
|
+
if (event.target === lastTarget && reported === (lastTarget !== null)) return;
|
|
46
|
+
lastTarget = event.target;
|
|
47
|
+
report(isBackground(event.target));
|
|
48
|
+
}, options);
|
|
49
|
+
// Pressing re-checks, so a menu or dialog that opened under a still pointer is noticed for the next click.
|
|
50
|
+
document.addEventListener('mousedown', (event) => { lastTarget = event.target; report(isBackground(event.target)); }, options);
|
|
51
|
+
document.addEventListener('mouseout', (event) => { if (!event.relatedTarget) { lastTarget = null; report(false); } }, options);
|
|
52
|
+
window.addEventListener('blur', () => { lastTarget = null; report(false); });
|
|
53
|
+
})();
|
|
54
|
+
"""#
|
|
55
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// Checks the parts of the shell that decide access and what gets scheduled. They need no window,
|
|
4
|
+
// web view or XCTest, so they run with the Command Line Tools: `pnpm apple:test`.
|
|
5
|
+
|
|
6
|
+
var failures = 0
|
|
7
|
+
var checks = 0
|
|
8
|
+
|
|
9
|
+
func expect(_ condition: @autoclosure () -> Bool, _ message: String, line: Int = #line) {
|
|
10
|
+
checks += 1
|
|
11
|
+
if !condition() {
|
|
12
|
+
failures += 1
|
|
13
|
+
print("FAIL (line \(line)): \(message)")
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func url(_ string: String) -> URL { URL(string: string)! }
|
|
18
|
+
|
|
19
|
+
// MARK: NavigationPolicy
|
|
20
|
+
|
|
21
|
+
let policy = NavigationPolicy(origin: url("https://alpha.example.workers.dev"), accessHost: "your-team.cloudflareaccess.com")
|
|
22
|
+
|
|
23
|
+
func decision(_ string: String, main: Bool = true) -> NavigationPolicy.Decision { policy.decide(url(string), isMainFrame: main) }
|
|
24
|
+
|
|
25
|
+
expect(decision("https://alpha.example.workers.dev/invoices?tab=received") == .allow, "the app's pages load")
|
|
26
|
+
expect(decision("https://alpha.example.workers.dev:443/") == .allow, "an explicit default port is the same origin")
|
|
27
|
+
expect(decision("https://ALPHA.example.workers.dev/") == .allow, "host case does not matter")
|
|
28
|
+
expect(decision("blob:https://alpha.example.workers.dev/3f2c") == .allow, "the app's own blob URLs load")
|
|
29
|
+
expect(decision("about:blank") == .allow, "about:blank loads")
|
|
30
|
+
expect(decision("data:text/html,hi", main: false) == .allow, "data in a frame loads")
|
|
31
|
+
|
|
32
|
+
expect(decision("https://your-team.cloudflareaccess.com/cdn-cgi/access/login/x") == .signIn, "Access opens the sign-in window")
|
|
33
|
+
expect(decision("https://your-team.cloudflareaccess.com/cdn-cgi/access/login/x", main: false) == .cancel, "Access never loads in a frame")
|
|
34
|
+
|
|
35
|
+
expect(decision("https://example.com/") == .external, "other sites open in the browser")
|
|
36
|
+
expect(decision("mailto:a@example.com") == .external, "mail opens in Mail")
|
|
37
|
+
expect(decision("tel:+420123456789") == .external, "phone numbers open in Phone")
|
|
38
|
+
expect(decision("https://alpha.example.workers.dev.evil.example/") == .external, "a lookalike host is another site")
|
|
39
|
+
expect(decision("https://xalpha.example.workers.dev/") == .external, "a sibling host is another site")
|
|
40
|
+
expect(decision("https://example.com/", main: false) == .cancel, "other sites never load in a frame")
|
|
41
|
+
|
|
42
|
+
expect(decision("http://alpha.example.workers.dev/") == .cancel, "the app's host is never reached over http")
|
|
43
|
+
expect(decision("https://alpha.example.workers.dev:8443/") == .cancel, "the app's host on another port is not the app")
|
|
44
|
+
expect(decision("javascript:alert(1)") == .cancel, "javascript: is refused")
|
|
45
|
+
expect(decision("file:///etc/passwd") == .cancel, "file: is refused")
|
|
46
|
+
expect(decision("data:text/html,hi") == .cancel, "data: never becomes a page")
|
|
47
|
+
expect(decision("blob:https://evil.example/3f2c") == .cancel, "another site's blob URL is refused")
|
|
48
|
+
expect(policy.decide(nil, isMainFrame: true) == .cancel, "no URL is refused")
|
|
49
|
+
|
|
50
|
+
expect(policy.finishesSignIn(url("https://alpha.example.workers.dev/")), "back at the app ends the sign-in")
|
|
51
|
+
expect(!policy.finishesSignIn(url("https://alpha.example.workers.dev/cdn-cgi/access/authorized?token=x")), "the cookie-setting step must complete first")
|
|
52
|
+
expect(!policy.finishesSignIn(url("https://example.com/")), "another site does not end the sign-in")
|
|
53
|
+
|
|
54
|
+
expect(policy.allowsSignIn(url("https://accounts.example.com/login")), "the login may pass through an identity provider over https")
|
|
55
|
+
expect(!policy.allowsSignIn(url("http://accounts.example.com/login")), "but not over http")
|
|
56
|
+
expect(!policy.allowsSignIn(url("file:///etc/passwd")), "or from disk")
|
|
57
|
+
expect(policy.allowsSignIn(url("about:blank")), "about:blank is fine")
|
|
58
|
+
|
|
59
|
+
let development = NavigationPolicy(origin: url("http://127.0.0.1:5180"), accessHost: "localhost")
|
|
60
|
+
expect(development.decide(url("http://127.0.0.1:5180/x"), isMainFrame: true) == .allow, "development origin loads")
|
|
61
|
+
expect(development.allowsSignIn(url("http://localhost:5192/login")), "the development Access host works over the app's scheme")
|
|
62
|
+
expect(!development.allowsSignIn(url("http://evil.example/")), "other hosts still need https")
|
|
63
|
+
|
|
64
|
+
// MARK: ShellMessage
|
|
65
|
+
|
|
66
|
+
func message(_ type: String, _ body: [String: Any]) -> ShellMessage? { ShellMessage(["type": type, "body": body]) }
|
|
67
|
+
|
|
68
|
+
expect(message("badge", ["count": 3]) == .badge(3), "badge decodes")
|
|
69
|
+
expect(message("badge", ["count": -2]) == .badge(0), "a negative badge becomes zero")
|
|
70
|
+
expect(message("badge", [:]) == nil, "badge without a count is dropped")
|
|
71
|
+
expect(ShellMessage("not a dictionary") == nil, "garbage is dropped")
|
|
72
|
+
expect(ShellMessage(["type": "badge"]) == nil, "a message without a body is dropped")
|
|
73
|
+
expect(message("unknown", [:]) == nil, "unknown types are dropped")
|
|
74
|
+
expect(message("lights", ["x": 26.5, "y": 38]) == .lights(x: 26.5, y: 38), "lights decode")
|
|
75
|
+
expect(message("outbox", ["pending": 4]) == .outbox(pending: 4), "outbox decodes")
|
|
76
|
+
expect(message("haptic", ["kind": "success"]) == .haptic(success: true), "haptic success decodes")
|
|
77
|
+
expect(message("drag", ["clickCount": 2]) == .drag(clickCount: 2), "the old per-page drag still decodes")
|
|
78
|
+
expect(message("backgroundHover", ["on": true]) == .backgroundHover(on: true), "hovering a background decodes")
|
|
79
|
+
expect(message("backgroundHover", ["on": false]) == .backgroundHover(on: false), "leaving a background decodes")
|
|
80
|
+
expect(message("backgroundHover", [:]) == .backgroundHover(on: false), "a hover without a value is not a background")
|
|
81
|
+
expect(message("appearance", ["preference": "dark", "resolved": "dark", "background": "#181d27"]) == .appearance(preference: .dark, resolved: .dark, background: "#181d27"), "appearance decodes")
|
|
82
|
+
expect(message("appearance", ["preference": "purple", "resolved": "dark"]) == nil, "an unknown appearance is dropped")
|
|
83
|
+
|
|
84
|
+
if case .menu(let destinations, let actions)? = message("menu", [
|
|
85
|
+
"destinations": [
|
|
86
|
+
["id": "invoices", "title": "Invoices", "path": "/invoices", "children": [
|
|
87
|
+
["id": "issued", "title": "Issued", "path": "/invoices"],
|
|
88
|
+
["id": "received", "title": "Received", "path": "/invoices?tab=received"],
|
|
89
|
+
["id": "bad", "title": "Bad", "path": "//evil.example"],
|
|
90
|
+
]],
|
|
91
|
+
["id": "finance", "title": "Finance", "path": "/finance"],
|
|
92
|
+
["id": "x", "title": "Evil", "path": "//evil.example"],
|
|
93
|
+
],
|
|
94
|
+
"actions": [["id": "new", "title": "New", "path": "/finance?newTransaction=1"], ["id": "y", "title": "Bad", "path": "https://evil.example"]],
|
|
95
|
+
]) {
|
|
96
|
+
expect(destinations.map(\.id) == ["invoices", "finance"], "menu entries that are not in-app routes are dropped")
|
|
97
|
+
expect(destinations.first?.children.map(\.id) == ["issued", "received"], "sub-pages decode and bad routes are dropped")
|
|
98
|
+
expect(destinations.last?.children.isEmpty == true, "a section without sub-pages has none")
|
|
99
|
+
expect(actions.map(\.id) == ["new"], "menu actions that are not in-app routes are dropped")
|
|
100
|
+
} else {
|
|
101
|
+
expect(false, "menu decodes")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
expect(ShellMessage.components("2026-10-08T09:00")?.hour == 9, "reminder time decodes")
|
|
105
|
+
expect(ShellMessage.components("2026-13-08T09:00") == nil, "an impossible month is refused")
|
|
106
|
+
expect(ShellMessage.components("2026-10-08T25:00") == nil, "an impossible hour is refused")
|
|
107
|
+
expect(ShellMessage.components("soon") == nil, "free text is refused")
|
|
108
|
+
|
|
109
|
+
if case .reminders(let reminders)? = message("reminders", ["items": [
|
|
110
|
+
["id": "a", "title": "A", "body": "", "at": "2026-10-08T09:00", "path": "/invoices"],
|
|
111
|
+
["id": "b", "title": "B", "body": "", "at": "yesterday", "path": "/invoices"],
|
|
112
|
+
["id": "c", "title": "C", "body": "", "at": "2026-10-09T09:00", "path": "https://evil.example"],
|
|
113
|
+
]]) {
|
|
114
|
+
expect(reminders.map(\.id) == ["a"], "reminders with a bad time or route are dropped")
|
|
115
|
+
} else {
|
|
116
|
+
expect(false, "reminders decode")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// MARK: Reminder planning
|
|
120
|
+
|
|
121
|
+
func reminder(_ id: String, daysFromNow days: Int, hour: Int = 9, now: Date) -> Reminder {
|
|
122
|
+
let date = Calendar.current.date(byAdding: .day, value: days, to: now)!
|
|
123
|
+
var parts = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
|
124
|
+
parts.hour = hour
|
|
125
|
+
parts.minute = 0
|
|
126
|
+
parts.calendar = .current
|
|
127
|
+
parts.timeZone = .current
|
|
128
|
+
return Reminder(id: id, title: id, body: "", at: parts, path: "/finance")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let now = Date()
|
|
132
|
+
let planned = ReminderScheduler.plan([
|
|
133
|
+
reminder("later", daysFromNow: 5, now: now),
|
|
134
|
+
reminder("past", daysFromNow: -2, now: now),
|
|
135
|
+
reminder("soon", daysFromNow: 1, now: now),
|
|
136
|
+
], now: now)
|
|
137
|
+
expect(planned.map(\.id) == ["soon", "later"], "past reminders are dropped and the rest are in date order")
|
|
138
|
+
|
|
139
|
+
let many = (1...80).map { reminder("r\($0)", daysFromNow: $0, now: now) }
|
|
140
|
+
let kept = ReminderScheduler.plan(many.reversed(), now: now)
|
|
141
|
+
expect(kept.count == 60, "no more than 60 reminders are kept")
|
|
142
|
+
expect(kept.first?.id == "r1" && kept.last?.id == "r60", "the nearest ones are the ones kept")
|
|
143
|
+
|
|
144
|
+
// MARK: SiteConfig
|
|
145
|
+
|
|
146
|
+
func site(_ id: String, development: Bool = false) -> SiteConfig {
|
|
147
|
+
SiteConfig(id: id, title: id, origin: url("https://\(id).example"), accessHost: nil, isDevelopmentOrigin: development)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
expect(site("alpha").dataStoreIdentifier == site("alpha").dataStoreIdentifier, "a site keeps its data store")
|
|
151
|
+
expect(site("alpha").dataStoreIdentifier != site("delta").dataStoreIdentifier, "sites never share a data store")
|
|
152
|
+
expect(site("alpha").dataStoreIdentifier != site("alpha", development: true).dataStoreIdentifier, "a development build never shares the real data")
|
|
153
|
+
|
|
154
|
+
print(failures == 0 ? "All \(checks) checks passed." : "\(failures) of \(checks) checks failed.")
|
|
155
|
+
exit(failures == 0 ? 0 : 1)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import UserNotifications
|
|
3
|
+
|
|
4
|
+
@main
|
|
5
|
+
final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
|
6
|
+
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
|
7
|
+
UNUserNotificationCenter.current().delegate = self
|
|
8
|
+
return true
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
|
12
|
+
[.banner, .list, .sound]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// Tapping a reminder opens the page it is about.
|
|
16
|
+
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
|
|
17
|
+
guard let path = response.notification.request.content.userInfo["path"] as? String else { return }
|
|
18
|
+
await MainActor.run { SceneDelegate.current?.open(path: path) }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Asks iOS for a little more time when the app is sent away with changes still waiting to upload.
|
|
4
|
+
/// The page does the uploading; this only keeps the app running while it has some left.
|
|
5
|
+
@MainActor
|
|
6
|
+
final class BackgroundFlush {
|
|
7
|
+
var pending = 0 {
|
|
8
|
+
didSet { if pending == 0 { end() } }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private var task = UIBackgroundTaskIdentifier.invalid
|
|
12
|
+
|
|
13
|
+
func begin() {
|
|
14
|
+
guard pending > 0, task == .invalid else { return }
|
|
15
|
+
task = UIApplication.shared.beginBackgroundTask(withName: "stor.outbox") { [weak self] in
|
|
16
|
+
self?.end()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func end() {
|
|
21
|
+
guard task != .invalid else { return }
|
|
22
|
+
UIApplication.shared.endBackgroundTask(task)
|
|
23
|
+
task = .invalid
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Referenced by name from Info.plist (`UISceneDelegateClassName`), so the Objective-C name is fixed.
|
|
4
|
+
@objc(StorSceneDelegate)
|
|
5
|
+
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|
6
|
+
static weak var current: SceneDelegate?
|
|
7
|
+
|
|
8
|
+
var window: UIWindow?
|
|
9
|
+
private var controller: WebViewController?
|
|
10
|
+
|
|
11
|
+
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
|
12
|
+
guard let windowScene = scene as? UIWindowScene else { return }
|
|
13
|
+
let controller = WebViewController(site: SiteConfig.load())
|
|
14
|
+
let window = UIWindow(windowScene: windowScene)
|
|
15
|
+
window.rootViewController = controller
|
|
16
|
+
window.makeKeyAndVisible()
|
|
17
|
+
self.window = window
|
|
18
|
+
self.controller = controller
|
|
19
|
+
Self.current = self
|
|
20
|
+
|
|
21
|
+
// A reminder that launched the app opens its page; otherwise the start page.
|
|
22
|
+
let launchPath = connectionOptions.notificationResponse?.notification.request.content.userInfo["path"] as? String
|
|
23
|
+
controller.host.load(path: launchPath ?? "/")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func open(path: String) {
|
|
27
|
+
controller?.host.navigate(to: path)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func sceneDidEnterBackground(_ scene: UIScene) {
|
|
31
|
+
controller?.didEnterBackground()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func sceneWillEnterForeground(_ scene: UIScene) {
|
|
35
|
+
controller?.willEnterForeground()
|
|
36
|
+
}
|
|
37
|
+
}
|