craft-native 0.0.395 → 0.0.408

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.
Files changed (37) hide show
  1. package/README.md +7 -0
  2. package/dist/android/templates/CraftBridge.kt.template +2 -0
  3. package/dist/android/templates/CraftNative.kt.template +7 -0
  4. package/dist/android/templates/CraftNativeFlatList.kt.template +7 -0
  5. package/dist/android/templates/CraftNativeMapGoogle.kt.template +3 -0
  6. package/dist/android/templates/CraftNativeMapLibre.kt.template +3 -0
  7. package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +9 -0
  8. package/dist/android/templates/CraftNativeServices.kt.template +2 -1
  9. package/dist/android/templates/CraftNativeShell.kt.template +12 -2
  10. package/dist/android/templates/LocationRecordingService.kt.template +125 -6
  11. package/dist/android/templates/MainActivityNative.kt.template +338 -21
  12. package/dist/api/mobile.d.ts +59 -2
  13. package/dist/cli.js +1 -1
  14. package/dist/components/index.d.ts +16 -0
  15. package/dist/index.cjs +54 -7
  16. package/dist/index.d.cts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +54 -7
  19. package/dist/ios/src/index.d.ts +7 -0
  20. package/dist/ios/templates/CraftApp.swift +268 -12
  21. package/dist/ios/templates/CraftHybrid.swift +1 -0
  22. package/dist/ios/templates/CraftNativeActions.swift +3 -1
  23. package/dist/ios/templates/CraftNativeFlatList.swift +4 -0
  24. package/dist/ios/templates/CraftNativeMutation.swift +27 -2
  25. package/dist/ios/templates/CraftNativeScreen.swift +293 -44
  26. package/dist/ios/templates/CraftNativeUI.swift +89 -0
  27. package/dist/ios/templates/testing/DriverTests.swift.template +17 -1
  28. package/dist/macos/src/index.d.ts +14 -3
  29. package/dist/macos/src/index.js +124 -27
  30. package/dist/macos/src/toolchain.d.ts +56 -0
  31. package/dist/macos/templates/CraftMacApplication.swift +54 -9
  32. package/dist/macos/templates/CraftMacRenderer.swift +376 -18
  33. package/dist/macos/templates/CraftMacRuntime.swift +114 -1
  34. package/dist/macos/templates/CraftMacWebView.swift +416 -0
  35. package/dist/macos/templates/CraftNativeMutation.swift +27 -2
  36. package/dist/mobile.js +53 -6
  37. package/package.json +1 -1
@@ -1,9 +1,73 @@
1
1
  import AppKit
2
2
  import ZigJS
3
3
  import Foundation
4
+ import Security
5
+
6
+ /// The macOS Keychain behind `craft.secureStorage`: one generic password per
7
+ /// key, under a service named for the app. The data-protection keychain is
8
+ /// used when the app is signed for it (a Mac App Store build, which carries
9
+ /// the application-identifier / keychain-access-groups entitlements); an
10
+ /// unsigned or ad-hoc development build has none and uses the login keychain,
11
+ /// so the same code runs both.
12
+ enum CraftMacKeychain {
13
+ static var service: String { (Bundle.main.bundleIdentifier ?? "dev.craft.native") + ".secure" }
14
+ /// Whether this process carries the keychain-access-groups entitlement, which
15
+ /// is what the data-protection keychain requires. Decided once, from the
16
+ /// signature, so reads and writes always use the same keychain: a failure
17
+ /// based fallback let a write land in the login keychain and the next
18
+ /// launch read an empty data-protection one.
19
+ private static var dataProtection: Bool = {
20
+ guard let task = SecTaskCreateFromSelf(nil) else { return false }
21
+ return SecTaskCopyValueForEntitlement(task, "keychain-access-groups" as CFString, nil) != nil
22
+ || SecTaskCopyValueForEntitlement(task, "com.apple.application-identifier" as CFString, nil) != nil
23
+ }()
24
+ private static func base(_ account: String?) -> [String: Any] {
25
+ var query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service]
26
+ if let account { query[kSecAttrAccount as String] = account }
27
+ if dataProtection { query[kSecUseDataProtectionKeychain as String] = true }
28
+ return query
29
+ }
30
+ private static func run(_ account: String?, _ body: ([String: Any]) -> OSStatus) -> OSStatus { body(base(account)) }
31
+ static func get(_ account: String) -> String? {
32
+ var result: AnyObject?
33
+ let status = run(account) { query in
34
+ var query = query; query[kSecReturnData as String] = true; query[kSecMatchLimit as String] = kSecMatchLimitOne
35
+ // Never stop the main thread on a Keychain permission prompt: an older item's
36
+ // access list does not trust a rebuilt app, and the prompt (with no window behind
37
+ // it) would hang the launch. With interaction off the read fails instead, the
38
+ // user signs in again, and the new item is trusted. Neither LAContext nor
39
+ // kSecUseAuthenticationUI stops a login-keychain access-list prompt; this does.
40
+ SecKeychainSetUserInteractionAllowed(false)
41
+ defer { SecKeychainSetUserInteractionAllowed(true) }
42
+ return SecItemCopyMatching(query as CFDictionary, &result)
43
+ }
44
+ guard status == errSecSuccess, let data = result as? Data else { return nil }
45
+ return String(decoding: data, as: UTF8.self)
46
+ }
47
+ @discardableResult static func set(_ account: String, _ value: String) -> Bool {
48
+ _ = run(account) { SecItemDelete($0 as CFDictionary) }
49
+ let status = run(account) { query in
50
+ var query = query; query[kSecValueData as String] = Data(value.utf8); query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
51
+ return SecItemAdd(query as CFDictionary, nil)
52
+ }
53
+ return status == errSecSuccess
54
+ }
55
+ @discardableResult static func remove(_ account: String?) -> Bool {
56
+ let status = run(account) { SecItemDelete($0 as CFDictionary) }
57
+ return status == errSecSuccess || status == errSecItemNotFound
58
+ }
59
+ }
4
60
 
5
61
  /// One zig-js context per STX window. No DOM and no browser view.
6
62
  final class CraftMacRuntime {
63
+ /// What this host offers a screen. `web` (tabs in a `<WebView>`) only in a build made with `webkit: true`.
64
+ static var capabilities: [String] {
65
+ var list = ["desktop", "fetch", "eventStream", "storage", "secureStorage", "clipboard", "dialog", "linking", "haptics", "notifications"]
66
+ #if CRAFT_WEBKIT
67
+ list.append("web")
68
+ #endif
69
+ return list
70
+ }
7
71
  let context = ZigJSContext()!
8
72
  let route: String
9
73
  let network: CraftMacNetwork
@@ -39,6 +103,18 @@ final class CraftMacRuntime {
39
103
  if level == "error" { self?.errors.append(text) }
40
104
  fputs("[stx \(level)] \(text)\n", stderr)
41
105
  }
106
+ // The synchronous halves of craft.storage and craft.secureStorage
107
+ // (getSync/setSync), which the stx runtime keeps when the host has them.
108
+ let storageGet: @convention(block) (String) -> String? = { key in
109
+ guard let value = UserDefaults.standard.object(forKey: "craft.native." + key) else { return nil }
110
+ if let text = value as? String { return text }
111
+ return (try? JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed])).flatMap { String(data: $0, encoding: .utf8) }
112
+ }
113
+ let storageSet: @convention(block) (String, String) -> Void = { key, json in UserDefaults.standard.set(json, forKey: "craft.native." + key) }
114
+ let secureGet: @convention(block) (String) -> String? = { key in CraftMacKeychain.get(key) }
115
+ context.setObject(storageGet, forKeyedSubscript: "craftNativeStorageGet" as NSString)
116
+ context.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
117
+ context.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
42
118
  context.setObject(post, forKeyedSubscript: "craftNativePostMessage" as NSString)
43
119
  context.setObject(schedule, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
44
120
  context.setObject(cancel, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
@@ -48,7 +124,8 @@ final class CraftMacRuntime {
48
124
  let info = Bundle.main.infoDictionary ?? [:]
49
125
  let bridge: [String: Any] = [
50
126
  "platform": "macos", "mutationProtocolVersion": 1, "capabilityProtocolVersion": 1,
51
- "capabilityTimeoutMs": 30_000, "capabilities": ["desktop", "fetch", "eventStream", "storage", "clipboard", "dialog", "linking", "haptics", "notifications"],
127
+ "capabilityTimeoutMs": 30_000, "capabilities": Self.capabilities,
128
+ "sessionTokenKey": "auth.token",
52
129
  "initialAppState": "active", "colorScheme": CraftMacColor.isDark ? "dark" : "light",
53
130
  "appURL": network.base.absoluteString, "appVersion": info["CFBundleShortVersionString"] as? String ?? "", "appBuild": info["CFBundleVersion"] as? String ?? ""
54
131
  ]
@@ -58,6 +135,12 @@ final class CraftMacRuntime {
58
135
  globalThis.__stxNativeBridge = \(bridgeJSON);
59
136
  __stxNativeBridge.postMessage = function(text) { craftNativePostMessage(text); };
60
137
  __stxNativeBridge.onMessage = function(callback) { globalThis.__stxNativeCallback = callback; };
138
+ globalThis.craft = globalThis.craft || {};
139
+ craft.storage = {
140
+ getSync: function(key) { var raw = craftNativeStorageGet(String(key)); if (raw === null || raw === undefined) return null; try { return JSON.parse(raw); } catch (e) { return raw; } },
141
+ setSync: function(key, value) { craftNativeStorageSet(String(key), JSON.stringify(value === undefined ? null : value)); }
142
+ };
143
+ craft.secureStorage = { getSync: function(key) { var value = craftNativeSecureGet(String(key)); return value === null || value === undefined ? null : value; } };
61
144
  globalThis.console = {};
62
145
  ['log','info','warn','error','debug'].forEach(function(level) {
63
146
  console[level] = function() { craftNativeLog(level, Array.from(arguments).map(String).join(' ')); };
@@ -88,6 +171,9 @@ final class CraftMacRuntime {
88
171
 
89
172
  func dispose() {
90
173
  guard !disposed else { return }
174
+ #if CRAFT_WEBKIT
175
+ CraftMacWebTabs.shared.closeAll(owner: self)
176
+ #endif
91
177
  context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
92
178
  disposed = true
93
179
  timers.values.forEach { $0.cancel() }; timers.removeAll()
@@ -138,6 +224,14 @@ final class CraftMacRuntime {
138
224
  }
139
225
  }
140
226
 
227
+ /// `craft.session.onChange`: the sign-in token moved, in any window.
228
+ private func sessionChanged(_ key: String, token: String?, previous: String?) {
229
+ guard key == "auth.token", token != previous else { return }
230
+ for scene in CraftMacApplication.shared.scenes.values {
231
+ scene.runtime.send("SESSION_CHANGED", ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()])
232
+ }
233
+ }
234
+
141
235
  func answer(_ id: String, _ value: Any = NSNull()) {
142
236
  tasks.removeValue(forKey: id)
143
237
  send("API_RESPONSE", ["version": 1, "requestId": id, "data": value], correlation: id)
@@ -168,6 +262,22 @@ final class CraftMacRuntime {
168
262
  }
169
263
  }
170
264
  tasks[id] = task; task.resume()
265
+ case "SecureStorage":
266
+ if method == "clear" { CraftMacKeychain.remove(nil); answer(id, true); return }
267
+ guard let key = args.first as? String, !key.isEmpty else { fail(id, "INVALID_ARGUMENT", "Secure storage needs a non-empty key"); return }
268
+ switch method {
269
+ case "get": answer(id, CraftMacKeychain.get(key) ?? NSNull())
270
+ case "set":
271
+ guard args.count == 2, let value = args[1] as? String else { fail(id, "INVALID_ARGUMENT", "SecureStorage.set needs a key and string value"); return }
272
+ let previous = CraftMacKeychain.get(key)
273
+ guard CraftMacKeychain.set(key, value) else { fail(id, "SECURE_STORAGE_ERROR", "Secure storage could not be written"); return }
274
+ sessionChanged(key, token: value, previous: previous); answer(id, true)
275
+ case "remove":
276
+ let previous = CraftMacKeychain.get(key)
277
+ guard CraftMacKeychain.remove(key) else { fail(id, "SECURE_STORAGE_ERROR", "Secure storage could not be removed"); return }
278
+ sessionChanged(key, token: nil, previous: previous); answer(id, true)
279
+ default: fail(id, "UNKNOWN_ACTION", "Unsupported SecureStorage method")
280
+ }
171
281
  case "Storage":
172
282
  let prefix = "craft.native."
173
283
  let key = prefix + (args.first as? String ?? "")
@@ -191,6 +301,9 @@ final class CraftMacRuntime {
191
301
  case "Linking":
192
302
  guard let text = args.first as? String, let url = URL(string: text), let scheme = url.scheme, ["https", "http", "imessage", "sms", "mailto", "x-apple.systempreferences"].contains(scheme) else { fail(id, "INVALID_ARGUMENT", "Unsupported URL scheme"); return }
193
303
  answer(id, method == "canOpenURL" ? NSWorkspace.shared.urlForApplication(toOpen: url) != nil : NSWorkspace.shared.open(url))
304
+ #if CRAFT_WEBKIT
305
+ case "Web": CraftMacWebTabs.shared.perform(method, args, owner: self, answer: { [weak self] value in self?.answer(id, value) }, fail: { [weak self] code, message in self?.fail(id, code, message) })
306
+ #endif
194
307
  case "Desktop": CraftMacApplication.shared.perform(method, args: args, scene: scene, reply: { [weak self] value in self?.answer(id, value) })
195
308
  case "Notifications": CraftMacApplication.shared.notification(method, options: options, reply: { [weak self] value in self?.answer(id, value) })
196
309
  default: fail(id, "CAPABILITY_DISABLED", "Unsupported native capability \(module).\(method)")
@@ -0,0 +1,416 @@
1
+ #if CRAFT_WEBKIT
2
+ import AppKit
3
+ import CryptoKit
4
+ import WebKit
5
+
6
+ // Web content for the AppKit host: one WKWebView per tab, for apps built with
7
+ // `buildMacOSNative({ webkit: true })` (a browser, a help viewer). The app's
8
+ // own screens still render as native controls through zig-js; only the pages
9
+ // a `<WebView>` shows are web content. Without the flag none of this is
10
+ // compiled and the host links no WebKit at all.
11
+ //
12
+ // Tabs belong to `CraftMacWebTabs`, not to the view showing them, so a tab
13
+ // keeps its page while the screen re-renders, moves between split panes, or
14
+ // is not on screen at all. A `<WebView tab="...">` decides where it appears.
15
+
16
+ /// One WKWebViewConfiguration per profile. Every tab of a profile shares its
17
+ /// website data store (cookies, storage, cache); profiles never share one.
18
+ final class CraftMacWebProfiles {
19
+ static let shared = CraftMacWebProfiles()
20
+ private var configurations: [String: WKWebViewConfiguration] = [:]
21
+
22
+ /// Safari's own suffix, so sites serve the desktop Safari experience rather than an unknown WebKit embedder's.
23
+ static let userAgentSuffix: String = {
24
+ let safari = Bundle(path: "/Applications/Safari.app")?.infoDictionary?["CFBundleShortVersionString"] as? String ?? "26.0"
25
+ return "Version/\(safari) Safari/605.1.15"
26
+ }()
27
+
28
+ /// `ephemeral` keeps nothing on disk (a private window); named profiles persist under their own identifier.
29
+ func configuration(profile: String, ephemeral: Bool) -> WKWebViewConfiguration {
30
+ let key = (ephemeral ? "ephemeral:" : "") + profile
31
+ if let existing = configurations[key] { return existing.copy() as! WKWebViewConfiguration }
32
+ let configuration = WKWebViewConfiguration()
33
+ configuration.websiteDataStore = store(profile: profile, ephemeral: ephemeral)
34
+ configuration.applicationNameForUserAgent = Self.userAgentSuffix
35
+ configuration.preferences.isElementFullscreenEnabled = true
36
+ configuration.preferences.isFraudulentWebsiteWarningEnabled = true
37
+ configuration.allowsAirPlayForMediaPlayback = true
38
+ configurations[key] = configuration
39
+ return configuration.copy() as! WKWebViewConfiguration
40
+ }
41
+
42
+ func store(profile: String, ephemeral: Bool) -> WKWebsiteDataStore {
43
+ if ephemeral { return .nonPersistent() }
44
+ if profile == "default" { return .default() }
45
+ if #available(macOS 14.0, *) { return WKWebsiteDataStore(forIdentifier: Self.identifier(profile)) }
46
+ return .default()
47
+ }
48
+
49
+ /// A stable UUID per profile name, so a profile finds its data again on every launch.
50
+ static func identifier(_ profile: String) -> UUID {
51
+ var bytes = Array(SHA256.hash(data: Data(("craft.web.profile:" + profile).utf8)).prefix(16))
52
+ bytes[6] = (bytes[6] & 0x0F) | 0x50; bytes[8] = (bytes[8] & 0x3F) | 0x80
53
+ return UUID(uuid: (bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]))
54
+ }
55
+ }
56
+
57
+ /// A tab: its WKWebView, the screen it reports to, and the state it reports.
58
+ final class CraftMacTab: NSObject, WKNavigationDelegate, WKUIDelegate {
59
+ let id: String
60
+ let profile: String
61
+ let ephemeral: Bool
62
+ let view: WKWebView
63
+ /// The window whose script hears this tab's events. Showing the tab in another window moves it there.
64
+ weak var owner: CraftMacRuntime?
65
+ private var observations: [NSKeyValueObservation] = []
66
+ private var statePending = false
67
+ private var lastFavicon = ""
68
+
69
+ init(id: String, profile: String, ephemeral: Bool, configuration: WKWebViewConfiguration, owner: CraftMacRuntime?) {
70
+ self.id = id; self.profile = profile; self.ephemeral = ephemeral; self.owner = owner
71
+ view = WKWebView(frame: .zero, configuration: configuration)
72
+ super.init()
73
+ view.navigationDelegate = self
74
+ view.uiDelegate = self
75
+ view.allowsBackForwardNavigationGestures = true
76
+ view.allowsMagnification = true
77
+ view.autoresizingMask = [.width, .height]
78
+ view.setAccessibilityIdentifier("web:\(id)")
79
+ // KVO fires once per property per change; a page load changes several at once.
80
+ // They are reported together, at most once per run-loop turn.
81
+ let report: (Any, Any) -> Void = { [weak self] _, _ in self?.scheduleState() }
82
+ observations = [
83
+ view.observe(\.title, changeHandler: report), view.observe(\.url, changeHandler: report),
84
+ view.observe(\.estimatedProgress, changeHandler: report), view.observe(\.isLoading, changeHandler: report),
85
+ view.observe(\.canGoBack, changeHandler: report), view.observe(\.canGoForward, changeHandler: report),
86
+ view.observe(\.hasOnlySecureContent, changeHandler: report), view.observe(\.themeColor, changeHandler: report),
87
+ view.observe(\.cameraCaptureState, changeHandler: report), view.observe(\.microphoneCaptureState, changeHandler: report),
88
+ ]
89
+ }
90
+
91
+ var state: [String: Any] {
92
+ var state: [String: Any] = [
93
+ "tab": id, "profile": profile, "private": ephemeral,
94
+ "title": view.title ?? "", "url": view.url?.absoluteString ?? "",
95
+ "progress": view.estimatedProgress, "loading": view.isLoading,
96
+ "canGoBack": view.canGoBack, "canGoForward": view.canGoForward,
97
+ "secure": view.hasOnlySecureContent, "zoom": view.pageZoom,
98
+ "camera": view.cameraCaptureState == .active, "microphone": view.microphoneCaptureState == .active,
99
+ ]
100
+ if let color = view.themeColor?.usingColorSpace(.sRGB) {
101
+ state["themeColor"] = String(format: "#%02x%02x%02x", Int(round(color.redComponent * 255)), Int(round(color.greenComponent * 255)), Int(round(color.blueComponent * 255)))
102
+ }
103
+ return state
104
+ }
105
+
106
+ func emit(_ type: String, _ detail: [String: Any] = [:]) {
107
+ var payload = detail; payload["tab"] = id; payload["type"] = type
108
+ owner?.send("CRAFT_EVENT", ["event": "craftWebView", "detail": payload])
109
+ }
110
+
111
+ private func scheduleState() {
112
+ guard !statePending else { return }
113
+ statePending = true
114
+ DispatchQueue.main.async { [weak self] in
115
+ guard let self else { return }
116
+ self.statePending = false
117
+ self.emit("state", self.state)
118
+ }
119
+ }
120
+
121
+ func close() {
122
+ observations.forEach { $0.invalidate() }; observations.removeAll()
123
+ view.stopLoading()
124
+ view.navigationDelegate = nil; view.uiDelegate = nil
125
+ view.removeFromSuperview()
126
+ }
127
+
128
+ // MARK: navigation
129
+
130
+ private static let pageSchemes: Set<String> = ["http", "https", "about", "data", "blob", "file"]
131
+
132
+ func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
133
+ guard let url = action.request.url else { decisionHandler(.cancel); return }
134
+ let scheme = url.scheme?.lowercased() ?? ""
135
+ if scheme == "javascript" { decisionHandler(.allow); return }
136
+ // mailto:, facetime:, zoommtg: and the like belong to other apps.
137
+ if !Self.pageSchemes.contains(scheme) {
138
+ if NSWorkspace.shared.urlForApplication(toOpen: url) != nil { NSWorkspace.shared.open(url) }
139
+ decisionHandler(.cancel); return
140
+ }
141
+ if action.shouldPerformDownload { decisionHandler(.download); return }
142
+ // ⌘-click (and middle click) opens the link in a new tab, as every browser does; the screen decides where.
143
+ if action.navigationType == .linkActivated, action.targetFrame?.isMainFrame ?? true,
144
+ action.modifierFlags.contains(.command) || action.buttonNumber == 2 {
145
+ emit("open", ["url": url.absoluteString, "background": !action.modifierFlags.contains(.shift), "reason": "modifier"])
146
+ decisionHandler(.cancel); return
147
+ }
148
+ decisionHandler(.allow)
149
+ }
150
+
151
+ func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
152
+ let attachment = (response.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition")?.lowercased().hasPrefix("attachment") == true
153
+ decisionHandler(response.isForMainFrame && (attachment || !response.canShowMIMEType) ? .download : .allow)
154
+ }
155
+
156
+ func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { CraftMacDownloads.shared.adopt(download, tab: self) }
157
+ func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { CraftMacDownloads.shared.adopt(download, tab: self) }
158
+
159
+ func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { emit("navigationStart", ["url": webView.url?.absoluteString ?? ""]) }
160
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
161
+ emit("navigationFinish", ["url": webView.url?.absoluteString ?? ""])
162
+ readFavicon()
163
+ }
164
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { failed(error) }
165
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { failed(error) }
166
+ private func failed(_ error: Error) {
167
+ let nsError = error as NSError
168
+ // A navigation replaced by another one, or turned into a download, is not a failure.
169
+ if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { return }
170
+ if nsError.domain == "WebKitErrorDomain" && nsError.code == 102 { return }
171
+ emit("navigationError", ["url": (nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String) ?? view.url?.absoluteString ?? "", "code": nsError.code, "domain": nsError.domain, "message": nsError.localizedDescription])
172
+ }
173
+
174
+ /// The page's renderer died (out of memory, a crash). Reload it rather than leave a blank tab.
175
+ func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
176
+ emit("crashed", ["url": webView.url?.absoluteString ?? ""])
177
+ webView.reload()
178
+ }
179
+
180
+ /// The icon the page declares, else `/favicon.ico`. Read in an isolated world the page's scripts cannot see.
181
+ private func readFavicon() {
182
+ let script = """
183
+ (() => { const links = [...document.querySelectorAll('link[rel~="icon"], link[rel="apple-touch-icon"]')]
184
+ .map(l => ({ href: l.href, size: parseInt((l.sizes && l.sizes.value || '').split('x')[0]) || (l.rel.includes('apple') ? 180 : 16) }))
185
+ .filter(l => l.href).sort((a, b) => Math.abs(a.size - 32) - Math.abs(b.size - 32));
186
+ return links.length ? links[0].href : (location.protocol.startsWith('http') ? location.origin + '/favicon.ico' : '') })()
187
+ """
188
+ view.evaluateJavaScript(script, in: nil, in: .defaultClient) { [weak self] result in
189
+ guard let self, case .success(let value) = result, let href = value as? String, !href.isEmpty, href != self.lastFavicon else { return }
190
+ self.lastFavicon = href
191
+ self.emit("favicon", ["url": href])
192
+ }
193
+ }
194
+
195
+ // MARK: windows, dialogs, files
196
+
197
+ /// `window.open` and `target=_blank`. WebKit needs the new view right now, so the tab is
198
+ /// created here and announced; the screen adopts it by id like any other tab.
199
+ func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
200
+ let child = CraftMacWebTabs.shared.make(profile: profile, ephemeral: ephemeral, configuration: configuration, owner: owner)
201
+ let popup = windowFeatures.width != nil || windowFeatures.height != nil
202
+ var detail: [String: Any] = ["child": child.id, "url": action.request.url?.absoluteString ?? "", "popup": popup, "reason": "script"]
203
+ if popup { detail["width"] = windowFeatures.width?.doubleValue ?? 0; detail["height"] = windowFeatures.height?.doubleValue ?? 0 }
204
+ emit("open", detail)
205
+ return child.view
206
+ }
207
+
208
+ func webViewDidClose(_ webView: WKWebView) { emit("close") }
209
+
210
+ func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
211
+ present(message, frame: frame, buttons: ["OK"]) { _, _ in completionHandler() }
212
+ }
213
+ func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
214
+ present(message, frame: frame, buttons: ["OK", "Cancel"]) { response, _ in completionHandler(response == .alertFirstButtonReturn) }
215
+ }
216
+ func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
217
+ present(prompt, frame: frame, buttons: ["OK", "Cancel"], input: defaultText ?? "") { response, text in completionHandler(response == .alertFirstButtonReturn ? text : nil) }
218
+ }
219
+ private func present(_ message: String, frame: WKFrameInfo, buttons: [String], input: String? = nil, done: @escaping (NSApplication.ModalResponse, String) -> Void) {
220
+ let alert = NSAlert()
221
+ alert.messageText = frame.securityOrigin.host.isEmpty ? "This page says" : "\(frame.securityOrigin.host) says"
222
+ alert.informativeText = message
223
+ buttons.forEach { alert.addButton(withTitle: $0) }
224
+ let field = input.map { value -> NSTextField in let field = NSTextField(string: value); field.frame = CGRect(x: 0, y: 0, width: 280, height: 24); return field }
225
+ alert.accessoryView = field
226
+ guard let window = view.window else { done(alert.runModal(), field?.stringValue ?? ""); return }
227
+ alert.beginSheetModal(for: window) { response in done(response, field?.stringValue ?? "") }
228
+ }
229
+
230
+ func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) {
231
+ let panel = NSOpenPanel()
232
+ panel.allowsMultipleSelection = parameters.allowsMultipleSelection
233
+ panel.canChooseDirectories = parameters.allowsDirectories
234
+ panel.canChooseFiles = true
235
+ guard let window = view.window else { completionHandler(panel.runModal() == .OK ? panel.urls : nil); return }
236
+ panel.beginSheetModal(for: window) { response in completionHandler(response == .OK ? panel.urls : nil) }
237
+ }
238
+ }
239
+
240
+ /// Downloads started by any tab, saved to ~/Downloads under a name that never overwrites.
241
+ final class CraftMacDownloads: NSObject, WKDownloadDelegate {
242
+ static let shared = CraftMacDownloads()
243
+ private struct Entry { let id: String; weak var tab: CraftMacTab?; var path: String; var observation: NSKeyValueObservation? }
244
+ private var entries: [ObjectIdentifier: Entry] = [:]
245
+ private var lastReport: [String: CFAbsoluteTime] = [:]
246
+
247
+ func adopt(_ download: WKDownload, tab: CraftMacTab) {
248
+ download.delegate = self
249
+ let id = UUID().uuidString
250
+ var entry = Entry(id: id, tab: tab, path: "")
251
+ entry.observation = download.progress.observe(\.fractionCompleted) { [weak self, weak download] _, _ in
252
+ guard let self, let download else { return }
253
+ DispatchQueue.main.async { self.report(download, state: "progress") }
254
+ }
255
+ entries[ObjectIdentifier(download)] = entry
256
+ }
257
+
258
+ func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) {
259
+ let folder = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0]
260
+ let name = (suggestedFilename as NSString).lastPathComponent.isEmpty ? "download" : (suggestedFilename as NSString).lastPathComponent
261
+ var destination = folder.appendingPathComponent(name), copy = 1
262
+ let stem = (name as NSString).deletingPathExtension, ext = (name as NSString).pathExtension
263
+ while FileManager.default.fileExists(atPath: destination.path) {
264
+ copy += 1
265
+ destination = folder.appendingPathComponent(ext.isEmpty ? "\(stem) \(copy)" : "\(stem) \(copy).\(ext)")
266
+ }
267
+ entries[ObjectIdentifier(download)]?.path = destination.path
268
+ report(download, state: "started")
269
+ completionHandler(destination)
270
+ }
271
+
272
+ func downloadDidFinish(_ download: WKDownload) { report(download, state: "finished"); finish(download) }
273
+ func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
274
+ report(download, state: "failed", extra: ["message": error.localizedDescription])
275
+ finish(download)
276
+ }
277
+
278
+ /// Progress is reported at most every 250ms; a start, finish or failure always is.
279
+ private func report(_ download: WKDownload, state: String, extra: [String: Any] = [:]) {
280
+ guard let entry = entries[ObjectIdentifier(download)] else { return }
281
+ let now = CFAbsoluteTimeGetCurrent()
282
+ if state == "progress", now - (lastReport[entry.id] ?? 0) < 0.25 { return }
283
+ lastReport[entry.id] = now
284
+ var detail: [String: Any] = ["id": entry.id, "state": state, "path": entry.path, "name": (entry.path as NSString).lastPathComponent,
285
+ "url": download.originalRequest?.url?.absoluteString ?? "",
286
+ "received": download.progress.completedUnitCount, "total": download.progress.totalUnitCount, "fraction": download.progress.fractionCompleted]
287
+ extra.forEach { detail[$0.key] = $0.value }
288
+ entry.tab?.emit("download", detail)
289
+ }
290
+
291
+ private func finish(_ download: WKDownload) {
292
+ if let entry = entries.removeValue(forKey: ObjectIdentifier(download)) { entry.observation?.invalidate(); lastReport.removeValue(forKey: entry.id) }
293
+ }
294
+ }
295
+
296
+ /// Every tab, by id, and the screens' `Web` capability module.
297
+ final class CraftMacWebTabs {
298
+ static let shared = CraftMacWebTabs()
299
+ private(set) var tabs: [String: CraftMacTab] = [:]
300
+ private let hosts = NSHashTable<CraftMacWebHost>.weakObjects()
301
+
302
+ @discardableResult
303
+ func make(id: String? = nil, profile: String = "default", ephemeral: Bool = false, configuration: WKWebViewConfiguration? = nil, owner: CraftMacRuntime?) -> CraftMacTab {
304
+ let id = id.flatMap { $0.isEmpty ? nil : $0 } ?? UUID().uuidString.lowercased()
305
+ if let existing = tabs[id] { return existing }
306
+ let tab = CraftMacTab(id: id, profile: profile, ephemeral: ephemeral,
307
+ configuration: configuration ?? CraftMacWebProfiles.shared.configuration(profile: profile, ephemeral: ephemeral), owner: owner)
308
+ tabs[id] = tab
309
+ // A `<WebView>` may have named this tab before it existed (the render and the create cross).
310
+ for host in hosts.allObjects where host.wanted == id { host.show(id) }
311
+ return tab
312
+ }
313
+
314
+ func register(_ host: CraftMacWebHost) { hosts.add(host) }
315
+
316
+ func close(_ id: String) { tabs.removeValue(forKey: id)?.close() }
317
+
318
+ /// A closed window takes its tabs with it.
319
+ func closeAll(owner: CraftMacRuntime) {
320
+ for (id, tab) in tabs where tab.owner == nil || tab.owner === owner { tabs.removeValue(forKey: id); tab.close() }
321
+ }
322
+
323
+ func perform(_ method: String, _ args: [Any], owner: CraftMacRuntime, answer: @escaping (Any) -> Void, fail: @escaping (String, String) -> Void) {
324
+ let options = args.first as? [String: Any] ?? [:]
325
+ if method == "create" {
326
+ let tab = make(id: options["id"] as? String, profile: options["profile"] as? String ?? "default", ephemeral: options["private"] as? Bool == true, owner: owner)
327
+ tab.view.isInspectable = options["inspectable"] as? Bool ?? true
328
+ if let address = options["url"] as? String, !address.isEmpty {
329
+ guard let url = URL(string: address), url.scheme != nil else { fail("INVALID_ARGUMENT", "Not an absolute URL: \(address)"); return }
330
+ tab.view.load(URLRequest(url: url))
331
+ }
332
+ answer(tab.state); return
333
+ }
334
+ if method == "list" { answer(tabs.values.map(\.state)); return }
335
+ guard let id = args.first as? String, let tab = tabs[id] else { fail("NOT_FOUND", "No tab \(String(describing: args.first ?? ""))"); return }
336
+ let view = tab.view
337
+ switch method {
338
+ case "navigate":
339
+ guard let address = args.dropFirst().first as? String, let url = URL(string: address), url.scheme != nil else { fail("INVALID_ARGUMENT", "navigate needs an absolute URL"); return }
340
+ view.load(URLRequest(url: url)); answer(true)
341
+ case "back": answer(view.goBack() != nil)
342
+ case "forward": answer(view.goForward() != nil)
343
+ case "reload":
344
+ let ignoreCache = (args.dropFirst().first as? [String: Any])?["ignoreCache"] as? Bool == true
345
+ if ignoreCache { view.reloadFromOrigin() } else { view.reload() }
346
+ answer(true)
347
+ case "stop": view.stopLoading(); answer(true)
348
+ case "close": close(id); answer(true)
349
+ case "state": answer(tab.state)
350
+ case "focus": view.window?.makeFirstResponder(view); answer(view.window != nil)
351
+ case "setZoom": view.pageZoom = number(args.dropFirst().first).map { max(0.25, min(5, $0)) } ?? 1; answer(view.pageZoom)
352
+ case "setInspectable": view.isInspectable = args.dropFirst().first as? Bool ?? true; answer(true)
353
+ case "evaluate":
354
+ let script = args.dropFirst().first as? String ?? ""
355
+ // The page's own world by default (to read what its scripts set up); `isolated` hides the call from them.
356
+ let world: WKContentWorld = (args.dropFirst(2).first as? [String: Any])?["isolated"] as? Bool == true ? .defaultClient : .page
357
+ view.evaluateJavaScript(script, in: nil, in: world) { result in
358
+ switch result {
359
+ case .success(let value): answer(JSONSerialization.isValidJSONObject(["v": value]) ? value : String(describing: value))
360
+ case .failure(let error): fail("SCRIPT_ERROR", error.localizedDescription)
361
+ }
362
+ }
363
+ case "find":
364
+ let text = args.dropFirst().first as? String ?? ""
365
+ let options = args.dropFirst(2).first as? [String: Any] ?? [:]
366
+ let configuration = WKFindConfiguration()
367
+ configuration.backwards = options["backwards"] as? Bool == true
368
+ configuration.caseSensitive = options["caseSensitive"] as? Bool == true
369
+ configuration.wraps = options["wraps"] as? Bool ?? true
370
+ view.find(text, configuration: configuration) { result in answer(["found": result.matchFound]) }
371
+ case "snapshot":
372
+ let configuration = WKSnapshotConfiguration()
373
+ if let width = number((args.dropFirst().first as? [String: Any])?["width"]) { configuration.snapshotWidth = NSNumber(value: Double(width)) }
374
+ view.takeSnapshot(with: configuration) { image, error in
375
+ guard let image, let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff),
376
+ let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.7]) else { fail("SNAPSHOT_FAILED", error?.localizedDescription ?? "No image"); return }
377
+ answer("data:image/jpeg;base64," + jpeg.base64EncodedString())
378
+ }
379
+ default: fail("UNSUPPORTED_METHOD", "Web.\(method) is not supported")
380
+ }
381
+ }
382
+ }
383
+
384
+ /// What a `<WebView tab="...">` renders as: the tab's WKWebView, filling the box.
385
+ /// Every tab it has shown stays attached and hidden, so switching back is a visibility change
386
+ /// (WebKit throttles hidden pages) rather than a reparent.
387
+ final class CraftMacWebHost: NSView {
388
+ weak var box: CraftMacBox?
389
+ private(set) var wanted: String?
390
+ override var isFlipped: Bool { true }
391
+
392
+ init(box: CraftMacBox) {
393
+ self.box = box
394
+ super.init(frame: .zero)
395
+ CraftMacWebTabs.shared.register(self)
396
+ }
397
+ required init?(coder: NSCoder) { fatalError("Created from STX") }
398
+
399
+ func show(_ id: String?) {
400
+ wanted = id
401
+ let tab = id.flatMap { CraftMacWebTabs.shared.tabs[$0] }
402
+ for case let view as WKWebView in subviews where view !== tab?.view { view.isHidden = true }
403
+ // Tabs closed elsewhere leave nothing behind.
404
+ for case let view as WKWebView in subviews where !CraftMacWebTabs.shared.tabs.values.contains(where: { $0.view === view }) { view.removeFromSuperview() }
405
+ guard let tab else { return }
406
+ if let runtime = box?.renderer?.runtime { tab.owner = runtime }
407
+ if tab.view.superview !== self { tab.view.frame = bounds; addSubview(tab.view) }
408
+ tab.view.isHidden = false
409
+ }
410
+
411
+ override func layout() {
412
+ super.layout()
413
+ for view in subviews { view.frame = bounds }
414
+ }
415
+ }
416
+ #endif
@@ -35,6 +35,9 @@ final class CraftNativeMutationDocument {
35
35
  var style: [String: Any]
36
36
  var events: [String: Any]
37
37
  var text: [String]
38
+ // Legacy snapshots may interleave bare text and native children.
39
+ // Keep each text fragment's slot among the child nodes on import.
40
+ var textPositions: [Int]? = nil
38
41
  var children: [String]
39
42
  var parent: String?
40
43
  }
@@ -43,6 +46,11 @@ final class CraftNativeMutationDocument {
43
46
  private var rootId: String?
44
47
  private(set) var revision = 0
45
48
 
49
+ /// The normalized committed tree, whose root need not be named "root".
50
+ var document: [String: Any]? {
51
+ rootId.flatMap { materialize($0, nodes: nodes) }
52
+ }
53
+
46
54
  func replace(with document: [String: Any]) {
47
55
  var replacement: [String: Node] = [:]
48
56
  let root = importNode(document, preferredId: "root", parent: nil, nodes: &replacement)
@@ -217,6 +225,7 @@ final class CraftNativeMutationDocument {
217
225
  if patch.keys.contains("events") { mergePatch(patch["events"], into: &node.events) }
218
226
  if patch.keys.contains("children") || patch.keys.contains("text") {
219
227
  node.text = try textChildren(patch["children"] ?? patch["text"])
228
+ node.textPositions = nil
220
229
  }
221
230
  nodes[id] = node
222
231
  }
@@ -422,9 +431,18 @@ final class CraftNativeMutationDocument {
422
431
  "events": node.events,
423
432
  ]
424
433
  if let key = node.key { value["key"] = key }
425
- let text: [Any] = node.text
426
434
  let children: [Any] = node.children.compactMap { materialize($0, nodes: nodes) }
427
- value["children"] = text + children
435
+ var content: [Any] = []
436
+ var textIndex = 0
437
+ for index in 0...children.count {
438
+ while textIndex < node.text.count,
439
+ min(node.textPositions?[textIndex] ?? 0, children.count) == index {
440
+ content.append(node.text[textIndex])
441
+ textIndex += 1
442
+ }
443
+ if index < children.count { content.append(children[index]) }
444
+ }
445
+ value["children"] = content
428
446
  return value
429
447
  }
430
448
 
@@ -440,6 +458,12 @@ final class CraftNativeMutationDocument {
440
458
  var id = explicit ?? key.map { "\(parent ?? "root")/key:\($0)" } ?? preferredId
441
459
  if nodes[id] != nil { id = "\(preferredId)#\(nodes.count)" }
442
460
  let children = value["children"] as? [Any] ?? []
461
+ var textPositions: [Int] = []
462
+ var childCount = 0
463
+ for child in children {
464
+ if child is String { textPositions.append(childCount) }
465
+ else if child is [String: Any] { childCount += 1 }
466
+ }
443
467
  nodes[id] = Node(
444
468
  id: id,
445
469
  key: value["key"] as? String,
@@ -448,6 +472,7 @@ final class CraftNativeMutationDocument {
448
472
  style: dictionary(value["style"]),
449
473
  events: dictionary(value["events"]),
450
474
  text: children.compactMap { $0 as? String },
475
+ textPositions: textPositions,
451
476
  children: [],
452
477
  parent: parent
453
478
  )