craft-native 0.0.396 → 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.
- package/README.md +7 -0
- package/dist/android/templates/CraftBridge.kt.template +2 -0
- package/dist/android/templates/CraftNative.kt.template +7 -0
- package/dist/android/templates/CraftNativeServices.kt.template +2 -1
- package/dist/android/templates/CraftNativeShell.kt.template +12 -2
- package/dist/android/templates/LocationRecordingService.kt.template +125 -6
- package/dist/android/templates/MainActivityNative.kt.template +21 -1
- package/dist/api/mobile.d.ts +59 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +54 -7
- package/dist/index.js +54 -7
- package/dist/ios/src/index.d.ts +7 -0
- package/dist/ios/templates/CraftApp.swift +268 -12
- package/dist/ios/templates/CraftHybrid.swift +1 -0
- package/dist/ios/templates/CraftNativeActions.swift +3 -1
- package/dist/ios/templates/CraftNativeScreen.swift +1 -0
- package/dist/ios/templates/CraftNativeUI.swift +89 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +17 -1
- package/dist/macos/src/index.d.ts +14 -3
- package/dist/macos/src/index.js +124 -27
- package/dist/macos/src/toolchain.d.ts +56 -0
- package/dist/macos/templates/CraftMacApplication.swift +54 -9
- package/dist/macos/templates/CraftMacRenderer.swift +376 -18
- package/dist/macos/templates/CraftMacRuntime.swift +114 -1
- package/dist/macos/templates/CraftMacWebView.swift +416 -0
- package/dist/mobile.js +53 -6
- 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":
|
|
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
|