craft-native 0.0.411 → 0.0.413
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/dist/cli.js
CHANGED
|
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3047
3047
|
`);
|
|
3048
3048
|
}
|
|
3049
3049
|
// package.json
|
|
3050
|
-
var version = "0.0.
|
|
3050
|
+
var version = "0.0.413";
|
|
3051
3051
|
|
|
3052
3052
|
// bin/cli.ts
|
|
3053
3053
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -40,6 +40,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
|
|
|
40
40
|
window.titleVisibility = .hidden
|
|
41
41
|
}
|
|
42
42
|
window.minSize = CGSize(width: number(options["minWidth"]) ?? 640, height: number(options["minHeight"]) ?? 400)
|
|
43
|
+
// A small window that stays above others (a link opened from another app, a quick look).
|
|
44
|
+
if options["floating"] as? Bool == true { window.level = .floating; window.collectionBehavior.insert(.fullScreenAuxiliary) }
|
|
43
45
|
window.isReleasedWhenClosed = false
|
|
44
46
|
renderer.root.autoresizingMask = [.width, .height]
|
|
45
47
|
window.contentView = renderer.root
|
|
@@ -94,6 +96,10 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
94
96
|
/// endpoint and starts with `--no-session`.
|
|
95
97
|
var requiresSession = true
|
|
96
98
|
var mainWindowOptions: [String: Any] = [:]
|
|
99
|
+
/// The Dock icon's menu (`craft-app.json` `dockMenu`): each item sends its action as a desktop command.
|
|
100
|
+
var dockMenu: [[String: Any]] = []
|
|
101
|
+
/// Links and files other apps opened with this one, waiting for a screen to take them (`takeOpenedURLs`).
|
|
102
|
+
var openedURLs: [String] = []
|
|
97
103
|
var network: CraftMacNetwork!
|
|
98
104
|
var scenes: [String: CraftMacScene] = [:]
|
|
99
105
|
var panels: [NSWindow] = []
|
|
@@ -101,6 +107,9 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
101
107
|
var testDirectory: URL?
|
|
102
108
|
var testTimer: Timer?
|
|
103
109
|
var processedActions = Set<String>()
|
|
110
|
+
/// The last context menu a screen asked for, and the entry the next one answers, under the test driver.
|
|
111
|
+
var testMenu: [[String: Any]] = []
|
|
112
|
+
var testMenuChoice: String?
|
|
104
113
|
var appearanceObservation: NSKeyValueObservation?
|
|
105
114
|
/// A quit waiting for screens to finish (`craft.desktop.onQuit`), for at most `quitGrace` seconds.
|
|
106
115
|
private var pendingQuit: (token: String, waiting: Set<String>, timeout: Timer)?
|
|
@@ -132,6 +141,28 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
132
141
|
}.resume()
|
|
133
142
|
}
|
|
134
143
|
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { scenes["main"]?.window.makeKeyAndOrderFront(nil); return true }
|
|
144
|
+
/// Links from other apps (this app as the default browser) and files dropped on the Dock icon.
|
|
145
|
+
/// A screen hears `craftDesktopOpenURL`; ones that arrive before any screen listens wait for `takeOpenedURLs`.
|
|
146
|
+
func application(_ application: NSApplication, open urls: [URL]) {
|
|
147
|
+
let list = urls.map(\.absoluteString)
|
|
148
|
+
openedURLs.append(contentsOf: list)
|
|
149
|
+
for scene in scenes.values { scene.runtime.send("CRAFT_EVENT", ["event": "craftDesktopOpenURL", "detail": ["urls": list]]) }
|
|
150
|
+
}
|
|
151
|
+
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
|
|
152
|
+
guard !dockMenu.isEmpty else { return nil }
|
|
153
|
+
let menu = NSMenu()
|
|
154
|
+
for raw in dockMenu {
|
|
155
|
+
let item = NSMenuItem(title: raw["title"] as? String ?? "", action: #selector(command(_:)), keyEquivalent: "")
|
|
156
|
+
item.target = self; item.representedObject = raw["action"] as? String
|
|
157
|
+
menu.addItem(item)
|
|
158
|
+
}
|
|
159
|
+
return menu
|
|
160
|
+
}
|
|
161
|
+
/// Is this app the one that opens web links?
|
|
162
|
+
var isDefaultBrowser: Bool {
|
|
163
|
+
guard let probe = URL(string: "https://example.com"), let handler = NSWorkspace.shared.urlForApplication(toOpen: probe) else { return false }
|
|
164
|
+
return handler.standardizedFileURL == Bundle.main.bundleURL.standardizedFileURL
|
|
165
|
+
}
|
|
135
166
|
func applicationWillTerminate(_ notification: Notification) { scenes.values.forEach { $0.runtime.dispose() }; testTimer?.invalidate() }
|
|
136
167
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
|
|
137
168
|
/// Screens holding the quit hear `craftDesktopQuit` and answer `quitReady` once their work is saved.
|
|
@@ -267,6 +298,9 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
267
298
|
bar.insertItem(item, at: min(index, bar.items.count))
|
|
268
299
|
}; reply(true)
|
|
269
300
|
case "contextMenu":
|
|
301
|
+
// Under the test driver no menu pops up (it would wait for a click): the items are recorded
|
|
302
|
+
// and the entry a `menu` action chose comes back.
|
|
303
|
+
if testDirectory != nil { testMenu = args.first as? [[String: Any]] ?? []; reply(testMenuChoice ?? NSNull()); testMenuChoice = nil; return }
|
|
270
304
|
let target = CraftMacMenuTarget(), menu = NSMenu(); menu.autoenablesItems = false
|
|
271
305
|
for item in args.first as? [[String: Any]] ?? [] { menu.addItem(menuItem(item, target: target, action: #selector(CraftMacMenuTarget.choose(_:)))) }
|
|
272
306
|
menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil); reply(target.chosen ?? NSNull())
|
|
@@ -286,6 +320,16 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
286
320
|
let filter = CIFilter(name: "CIQRCodeGenerator")!; filter.setValue(Data(text.utf8), forKey: "inputMessage"); filter.setValue("M", forKey: "inputCorrectionLevel")
|
|
287
321
|
guard let image = filter.outputImage?.transformed(by: CGAffineTransform(scaleX: 8, y: 8)), let cg = CIContext().createCGImage(image, from: image.extent), let data = NSBitmapImageRep(cgImage: cg).representation(using: .png, properties: [:]) else { reply(NSNull()); return }
|
|
288
322
|
reply("data:image/png;base64," + data.base64EncodedString())
|
|
323
|
+
case "takeOpenedURLs": reply(openedURLs); openedURLs.removeAll()
|
|
324
|
+
case "isDefaultBrowser": reply(isDefaultBrowser)
|
|
325
|
+
case "setDefaultBrowser":
|
|
326
|
+
// macOS asks the person to confirm; the answer is whether this app opens links afterwards.
|
|
327
|
+
let app = Bundle.main.bundleURL
|
|
328
|
+
NSWorkspace.shared.setDefaultApplication(at: app, toOpenURLsWithScheme: "http") { _ in
|
|
329
|
+
NSWorkspace.shared.setDefaultApplication(at: app, toOpenURLsWithScheme: "https") { _ in
|
|
330
|
+
DispatchQueue.main.async { reply(self.isDefaultBrowser) }
|
|
331
|
+
}
|
|
332
|
+
}
|
|
289
333
|
case "setBadge": let count = (args.first as? NSNumber)?.intValue ?? 0; NSApp.dockTile.badgeLabel = count > 0 ? String(count) : nil; reply(true)
|
|
290
334
|
case "holdQuit": scene?.holdsQuit = args.first as? Bool != false; reply(true)
|
|
291
335
|
case "quitReady": if let scene { quitReady(scene: scene.name, token: args.first as? String) }; reply(true)
|
|
@@ -360,6 +404,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
360
404
|
if kind == "command", let id = action["id"] as? String { broadcast(id); return ["ok": true] }
|
|
361
405
|
// After this result is written, from the run loop rather than a main-queue block (see applicationShouldTerminate).
|
|
362
406
|
if kind == "quit" { NSApp.perform(#selector(NSApplication.terminate(_:)), with: nil, afterDelay: 0); return ["ok": true] }
|
|
407
|
+
if kind == "openURL", let url = (action["url"] as? String).flatMap(URL.init(string:)) { application(NSApp, open: [url]); return ["ok": true] }
|
|
363
408
|
if kind == "appearance" {
|
|
364
409
|
NSApp.appearance = NSAppearance(named: action["value"] as? String == "dark" ? .darkAqua : .aqua)
|
|
365
410
|
appearanceChanged(); return ["ok": true]
|
|
@@ -373,7 +418,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
373
418
|
return ["ok": true]
|
|
374
419
|
}
|
|
375
420
|
// A control by its testID, driven the way a person would: pressed, typed into, submitted, a key, the pointer over it.
|
|
376
|
-
if ["press", "type", "submit", "key", "hover"].contains(kind) {
|
|
421
|
+
if ["press", "type", "submit", "key", "hover", "menu"].contains(kind) {
|
|
377
422
|
guard let target = action["target"] as? String,
|
|
378
423
|
let box = scene.renderer.boxes.values.first(where: { ($0.props["testID"] as? String ?? $0.props["testId"] as? String ?? $0.id) == target }) else { return ["ok": false, "error": "No control \(action["target"] ?? "")"] }
|
|
379
424
|
let text = action["text"] as? String ?? ""
|
|
@@ -385,6 +430,8 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
385
430
|
box.input(text)
|
|
386
431
|
case "submit": box.fire("onSubmitEditing", ["text": text, "value": text, "nativeEvent": ["text": text]])
|
|
387
432
|
case "key": box.fire("onKeyPress", ["key": action["key"] as? String ?? "", "shiftKey": action["shift"] as? Bool == true, "text": text])
|
|
433
|
+
// A right-click, answered with `choose` (or nothing) when the screen opens its context menu.
|
|
434
|
+
case "menu": testMenuChoice = action["choose"] as? String; box.fire("onLongPress", ["x": 0, "y": 0])
|
|
388
435
|
default:
|
|
389
436
|
let hovered = action["value"] as? Bool ?? true
|
|
390
437
|
box.hovering = hovered; box.applyAppearance(animated: false)
|
|
@@ -460,7 +507,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
460
507
|
return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: type(of: scene.runtime.context)), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "errors": scene.runtime.errors]
|
|
461
508
|
}
|
|
462
509
|
let webViews = windows.reduce(0) { count, window in count + ((window["viewClasses"] as? [String] ?? []).filter { $0.contains("WKWebView") || $0 == "WebView" }.count) }
|
|
463
|
-
return ["renderer": "appkit", "scriptEngine": "zig-js", "appearance": CraftMacColor.isDark ? "dark" : "light", "windows": windows, "webViews": webViews]
|
|
510
|
+
return ["renderer": "appkit", "scriptEngine": "zig-js", "appearance": CraftMacColor.isDark ? "dark" : "light", "windows": windows, "webViews": webViews, "menu": testMenu]
|
|
464
511
|
}
|
|
465
512
|
}
|
|
466
513
|
|
|
@@ -488,6 +535,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
488
535
|
application.initialRoute = argument("--route", key: "route", fallback: "Main")
|
|
489
536
|
application.titlebarStyle = argument("--titlebar-style", key: "titlebarStyle", fallback: "standard")
|
|
490
537
|
// `18,18` on the command line; `[18, 18]` in craft-app.json.
|
|
538
|
+
application.dockMenu = packaged["dockMenu"] as? [[String: Any]] ?? []
|
|
491
539
|
let lights = argument("--traffic-lights", key: "trafficLights").split(separator: ",").compactMap { Double($0.trimmingCharacters(in: CharacterSet(charactersIn: " []"))) }
|
|
492
540
|
if lights.count == 2 { application.trafficLights = lights }
|
|
493
541
|
application.requiresSession = !CommandLine.arguments.contains("--no-session") && packaged["session"] as? Bool != false
|
|
@@ -280,7 +280,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
280
280
|
var componentRoot: CraftMacBox? { props["data-stx-component-wrapper"] != nil && flow?.boxes.count == 1 && flow?.boxes.first?.kind != "Modal" ? flow?.boxes.first : nil }
|
|
281
281
|
var layoutStyle: [String: Any] {
|
|
282
282
|
guard let child = componentRoot else { return style }
|
|
283
|
-
let keys = ["width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight", "flex", "flexGrow", "flexShrink", "flexBasis", "alignSelf", "position", "left", "right", "top", "bottom", "margin", "marginTop", "marginRight", "marginBottom", "marginLeft", "marginHorizontal", "marginVertical"]
|
|
283
|
+
let keys = ["width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight", "flex", "flexGrow", "flexShrink", "flexBasis", "alignSelf", "position", "zIndex", "left", "right", "top", "bottom", "margin", "marginTop", "marginRight", "marginBottom", "marginLeft", "marginHorizontal", "marginVertical"]
|
|
284
284
|
var result = style
|
|
285
285
|
for key in keys where result[key] == nil { result[key] = child.layoutStyle[key] }
|
|
286
286
|
return result
|
|
@@ -952,6 +952,12 @@ final class CraftMacRenderer {
|
|
|
952
952
|
for child in flow.boxes where !children.contains(where: { $0 === child }) { child.removeFromSuperview() }
|
|
953
953
|
flow.boxes = children
|
|
954
954
|
for child in children where child.superview !== flow && sheets[child.id] == nil { flow.addSubview(child) }
|
|
955
|
+
// `zIndex` draws a child above its siblings (an overlay over a web page), highest last.
|
|
956
|
+
let raised = children.filter { (number($0.layoutStyle["zIndex"]) ?? 0) > 0 && $0.superview === flow }
|
|
957
|
+
.sorted { (number($0.layoutStyle["zIndex"]) ?? 0) < (number($1.layoutStyle["zIndex"]) ?? 0) }
|
|
958
|
+
if !raised.isEmpty, Array(flow.subviews.suffix(raised.count)) != raised {
|
|
959
|
+
for child in raised { flow.addSubview(child, positioned: .above, relativeTo: nil) }
|
|
960
|
+
}
|
|
955
961
|
}
|
|
956
962
|
return box
|
|
957
963
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#if CRAFT_WEBKIT
|
|
2
2
|
import AppKit
|
|
3
3
|
import CryptoKit
|
|
4
|
+
import UniformTypeIdentifiers
|
|
4
5
|
import WebKit
|
|
5
6
|
|
|
6
7
|
// Web content for the AppKit host: one WKWebView per tab, for apps built with
|
|
@@ -66,8 +67,11 @@ final class CraftMacTab: NSObject, WKNavigationDelegate, WKUIDelegate {
|
|
|
66
67
|
private var statePending = false
|
|
67
68
|
private var lastFavicon = ""
|
|
68
69
|
|
|
69
|
-
init(id: String, profile: String, ephemeral: Bool, configuration: WKWebViewConfiguration, owner: CraftMacRuntime
|
|
70
|
+
init(id: String, profile: String, ephemeral: Bool, configuration: WKWebViewConfiguration, owner: CraftMacRuntime?, ownContent: Bool = true) {
|
|
70
71
|
self.id = id; self.profile = profile; self.ephemeral = ephemeral; self.owner = owner
|
|
72
|
+
// Its own content controller, so a site allowed past the blockers is allowed in this tab only.
|
|
73
|
+
// A `window.open` child must take WebKit's configuration as given, and shares its opener's.
|
|
74
|
+
if ownContent { configuration.userContentController = WKUserContentController() }
|
|
71
75
|
view = WKWebView(frame: .zero, configuration: configuration)
|
|
72
76
|
super.init()
|
|
73
77
|
view.navigationDelegate = self
|
|
@@ -86,8 +90,15 @@ final class CraftMacTab: NSObject, WKNavigationDelegate, WKUIDelegate {
|
|
|
86
90
|
view.observe(\.hasOnlySecureContent, changeHandler: report), view.observe(\.themeColor, changeHandler: report),
|
|
87
91
|
view.observe(\.cameraCaptureState, changeHandler: report), view.observe(\.microphoneCaptureState, changeHandler: report),
|
|
88
92
|
]
|
|
93
|
+
if ownContent {
|
|
94
|
+
CraftMacContentRules.shared.apply(to: self, host: nil)
|
|
95
|
+
CraftMacUserScripts.shared.apply(to: view.configuration.userContentController)
|
|
96
|
+
}
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
/// The site on screen is allowed past the content blockers (or no longer is).
|
|
100
|
+
private(set) var blockingHost: String?
|
|
101
|
+
|
|
91
102
|
var state: [String: Any] {
|
|
92
103
|
var state: [String: Any] = [
|
|
93
104
|
"tab": id, "profile": profile, "private": ephemeral,
|
|
@@ -139,6 +150,11 @@ final class CraftMacTab: NSObject, WKNavigationDelegate, WKUIDelegate {
|
|
|
139
150
|
decisionHandler(.cancel); return
|
|
140
151
|
}
|
|
141
152
|
if action.shouldPerformDownload { decisionHandler(.download); return }
|
|
153
|
+
// The blockers follow the site in the main frame: a site the person allowed loads without them.
|
|
154
|
+
if action.targetFrame?.isMainFrame ?? true, let host = url.host?.lowercased(), host != blockingHost {
|
|
155
|
+
blockingHost = host
|
|
156
|
+
CraftMacContentRules.shared.apply(to: self, host: host)
|
|
157
|
+
}
|
|
142
158
|
// ⌘-click (and middle click) opens the link in a new tab, as every browser does; the screen decides where.
|
|
143
159
|
if action.navigationType == .linkActivated, action.targetFrame?.isMainFrame ?? true,
|
|
144
160
|
action.modifierFlags.contains(.command) || action.buttonNumber == 2 {
|
|
@@ -197,7 +213,7 @@ final class CraftMacTab: NSObject, WKNavigationDelegate, WKUIDelegate {
|
|
|
197
213
|
/// `window.open` and `target=_blank`. WebKit needs the new view right now, so the tab is
|
|
198
214
|
/// created here and announced; the screen adopts it by id like any other tab.
|
|
199
215
|
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)
|
|
216
|
+
let child = CraftMacWebTabs.shared.make(profile: profile, ephemeral: ephemeral, configuration: configuration, owner: owner, ownContent: false)
|
|
201
217
|
let popup = windowFeatures.width != nil || windowFeatures.height != nil
|
|
202
218
|
var detail: [String: Any] = ["child": child.id, "url": action.request.url?.absoluteString ?? "", "popup": popup, "reason": "script"]
|
|
203
219
|
if popup { detail["width"] = windowFeatures.width?.doubleValue ?? 0; detail["height"] = windowFeatures.height?.doubleValue ?? 0 }
|
|
@@ -235,6 +251,112 @@ final class CraftMacTab: NSObject, WKNavigationDelegate, WKUIDelegate {
|
|
|
235
251
|
guard let window = view.window else { completionHandler(panel.runModal() == .OK ? panel.urls : nil); return }
|
|
236
252
|
panel.beginSheetModal(for: window) { response in completionHandler(response == .OK ? panel.urls : nil) }
|
|
237
253
|
}
|
|
254
|
+
|
|
255
|
+
// MARK: permissions
|
|
256
|
+
|
|
257
|
+
/// A site asks for the camera or microphone. The screen decides (`permission`, answered with
|
|
258
|
+
/// `answerPermission`), so it can remember a site's answer; macOS still asks for the app once.
|
|
259
|
+
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void) {
|
|
260
|
+
let kind: String
|
|
261
|
+
switch type {
|
|
262
|
+
case .camera: kind = "camera"
|
|
263
|
+
case .microphone: kind = "microphone"
|
|
264
|
+
default: kind = "camera+microphone"
|
|
265
|
+
}
|
|
266
|
+
let request = CraftMacWebTabs.shared.ask(tab: self, decide: decisionHandler)
|
|
267
|
+
emit("permission", ["request": request, "kind": kind, "origin": origin.host, "protocol": origin.protocol, "port": origin.port, "mainFrame": frame.isMainFrame])
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/// Content blockers (an ad blocker's lists): compiled once by WebKit, kept on disk by identifier and
|
|
272
|
+
/// version, and applied to every tab except on the sites the person allowed.
|
|
273
|
+
final class CraftMacContentRules {
|
|
274
|
+
static let shared = CraftMacContentRules()
|
|
275
|
+
private(set) var lists: [String: WKContentRuleList] = [:]
|
|
276
|
+
/// Sites where no list applies; a host here covers its subdomains.
|
|
277
|
+
var allowed: Set<String> = []
|
|
278
|
+
|
|
279
|
+
func isAllowed(_ host: String?) -> Bool {
|
|
280
|
+
guard var host = host?.lowercased() else { return false }
|
|
281
|
+
while true {
|
|
282
|
+
if allowed.contains(host) { return true }
|
|
283
|
+
guard let dot = host.firstIndex(of: ".") else { return false }
|
|
284
|
+
host = String(host[host.index(after: dot)...])
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
func apply(to tab: CraftMacTab, host: String?) {
|
|
289
|
+
let controller = tab.view.configuration.userContentController
|
|
290
|
+
controller.removeAllContentRuleLists()
|
|
291
|
+
guard !isAllowed(host ?? tab.view.url?.host) else { return }
|
|
292
|
+
for list in lists.values { controller.add(list) }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func reapply() { for tab in CraftMacWebTabs.shared.tabs.values { apply(to: tab, host: tab.blockingHost) } }
|
|
296
|
+
|
|
297
|
+
/// A list by identifier and version: the compiled copy on disk when there is one, else compiled
|
|
298
|
+
/// from `json` (WebKit's content-blocker format). Without `json` and nothing on disk, `missing`.
|
|
299
|
+
func set(_ identifier: String, version: String, json: String?, answer: @escaping (Any) -> Void, fail: @escaping (String, String) -> Void) {
|
|
300
|
+
guard let store = WKContentRuleListStore.default() else { fail("UNAVAILABLE", "WebKit has no content rule list store"); return }
|
|
301
|
+
let key = "\(identifier)@\(version)"
|
|
302
|
+
store.lookUpContentRuleList(forIdentifier: key) { [weak self] found, _ in
|
|
303
|
+
guard let self else { return }
|
|
304
|
+
if let found {
|
|
305
|
+
self.lists[identifier] = found; self.reapply(); answer(["identifier": identifier, "cached": true]); return
|
|
306
|
+
}
|
|
307
|
+
guard let json, !json.isEmpty else { answer(["identifier": identifier, "missing": true]); return }
|
|
308
|
+
store.compileContentRuleList(forIdentifier: key, encodedContentRuleList: json) { list, error in
|
|
309
|
+
guard let list else { fail("COMPILE_FAILED", error?.localizedDescription ?? "WebKit did not compile \(identifier)"); return }
|
|
310
|
+
self.lists[identifier] = list; self.reapply()
|
|
311
|
+
// Older versions of the same list are only disk space now.
|
|
312
|
+
store.getAvailableContentRuleListIdentifiers { keys in
|
|
313
|
+
for old in keys ?? [] where old.hasPrefix("\(identifier)@") && old != key { store.removeContentRuleList(forIdentifier: old) { _ in } }
|
|
314
|
+
}
|
|
315
|
+
answer(["identifier": identifier, "cached": false])
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
func remove(_ identifier: String) { lists.removeValue(forKey: identifier); reapply() }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/// Scripts and styles for sites: a person's Boosts, or an ad blocker's page scripts. Each names the
|
|
324
|
+
/// hosts it runs on (a host covers its subdomains; none, or `*`, is every site). By default they run in
|
|
325
|
+
/// an isolated world the page's scripts cannot see; `world: 'page'` runs one in the page's own world,
|
|
326
|
+
/// for a script that must wrap the page's `fetch` before the site does. CSS lands at document start,
|
|
327
|
+
/// JS at document end unless `at: 'start'`.
|
|
328
|
+
final class CraftMacUserScripts {
|
|
329
|
+
static let shared = CraftMacUserScripts()
|
|
330
|
+
private var scripts: [WKUserScript] = []
|
|
331
|
+
|
|
332
|
+
func set(_ entries: [[String: Any]]) {
|
|
333
|
+
scripts = entries.flatMap { entry -> [WKUserScript] in
|
|
334
|
+
let hosts = (entry["hosts"] as? [String] ?? []).map { $0.lowercased() }.filter { !$0.isEmpty }
|
|
335
|
+
let everySite = hosts.isEmpty || hosts.contains("*")
|
|
336
|
+
guard let hostsJSON = try? JSONSerialization.data(withJSONObject: hosts), let hostList = String(data: hostsJSON, encoding: .utf8) else { return [] }
|
|
337
|
+
let guardLine = everySite ? "" : "if (!\(hostList).some(h => location.hostname === h || location.hostname.endsWith('.' + h))) return;"
|
|
338
|
+
let world: WKContentWorld = entry["world"] as? String == "page" ? .page : .defaultClient
|
|
339
|
+
let scriptTime: WKUserScriptInjectionTime = entry["at"] as? String == "start" ? .atDocumentStart : .atDocumentEnd
|
|
340
|
+
let allFrames = entry["allFrames"] as? Bool == true
|
|
341
|
+
var made: [WKUserScript] = []
|
|
342
|
+
if let css = entry["css"] as? String, !css.isEmpty, let cssJSON = try? JSONSerialization.data(withJSONObject: [css]), let cssLiteral = String(data: cssJSON, encoding: .utf8) {
|
|
343
|
+
let id = (entry["id"] as? String ?? "boost").replacingOccurrences(of: "\"", with: "")
|
|
344
|
+
let source = "(() => { \(guardLine) const s = document.createElement('style'); s.dataset.craftBoost = \"\(id)\"; s.textContent = \(cssLiteral)[0]; (document.head || document.documentElement).appendChild(s) })()"
|
|
345
|
+
made.append(WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: !allFrames, in: world))
|
|
346
|
+
}
|
|
347
|
+
if let js = entry["js"] as? String, !js.isEmpty {
|
|
348
|
+
made.append(WKUserScript(source: "(() => { \(guardLine)\n\(js)\n})()", injectionTime: scriptTime, forMainFrameOnly: !allFrames, in: world))
|
|
349
|
+
}
|
|
350
|
+
return made
|
|
351
|
+
}
|
|
352
|
+
// Pages pick the new set up on their next load.
|
|
353
|
+
for tab in CraftMacWebTabs.shared.tabs.values { apply(to: tab.view.configuration.userContentController) }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
func apply(to controller: WKUserContentController) {
|
|
357
|
+
controller.removeAllUserScripts()
|
|
358
|
+
scripts.forEach(controller.addUserScript)
|
|
359
|
+
}
|
|
238
360
|
}
|
|
239
361
|
|
|
240
362
|
/// Downloads started by any tab, saved to ~/Downloads under a name that never overwrites.
|
|
@@ -242,10 +364,12 @@ final class CraftMacDownloads: NSObject, WKDownloadDelegate {
|
|
|
242
364
|
static let shared = CraftMacDownloads()
|
|
243
365
|
private struct Entry { let id: String; weak var tab: CraftMacTab?; var path: String; var observation: NSKeyValueObservation? }
|
|
244
366
|
private var entries: [ObjectIdentifier: Entry] = [:]
|
|
367
|
+
private var downloads: [WKDownload] = []
|
|
245
368
|
private var lastReport: [String: CFAbsoluteTime] = [:]
|
|
246
369
|
|
|
247
370
|
func adopt(_ download: WKDownload, tab: CraftMacTab) {
|
|
248
371
|
download.delegate = self
|
|
372
|
+
downloads.append(download)
|
|
249
373
|
let id = UUID().uuidString
|
|
250
374
|
var entry = Entry(id: id, tab: tab, path: "")
|
|
251
375
|
entry.observation = download.progress.observe(\.fractionCompleted) { [weak self, weak download] _, _ in
|
|
@@ -288,7 +412,25 @@ final class CraftMacDownloads: NSObject, WKDownloadDelegate {
|
|
|
288
412
|
entry.tab?.emit("download", detail)
|
|
289
413
|
}
|
|
290
414
|
|
|
415
|
+
/// Stop a download that is still running; what arrived is kept as resume data no one uses yet.
|
|
416
|
+
func cancel(_ id: String) -> Bool {
|
|
417
|
+
guard let download = downloads.first(where: { entries[ObjectIdentifier($0)]?.id == id }) else { return false }
|
|
418
|
+
download.cancel { _ in }
|
|
419
|
+
report(download, state: "failed", extra: ["message": "Cancelled", "cancelled": true])
|
|
420
|
+
finish(download)
|
|
421
|
+
return true
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/// A file in ~/Downloads (after resolving links), or nil: the screen may only point at downloads.
|
|
425
|
+
static func downloadedFile(_ path: String) -> URL? {
|
|
426
|
+
let folder = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0].resolvingSymlinksInPath().standardizedFileURL.path
|
|
427
|
+
let file = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL
|
|
428
|
+
guard file.path.hasPrefix(folder + "/"), FileManager.default.fileExists(atPath: file.path) else { return nil }
|
|
429
|
+
return file
|
|
430
|
+
}
|
|
431
|
+
|
|
291
432
|
private func finish(_ download: WKDownload) {
|
|
433
|
+
downloads.removeAll { $0 === download }
|
|
292
434
|
if let entry = entries.removeValue(forKey: ObjectIdentifier(download)) { entry.observation?.invalidate(); lastReport.removeValue(forKey: entry.id) }
|
|
293
435
|
}
|
|
294
436
|
}
|
|
@@ -300,11 +442,11 @@ final class CraftMacWebTabs {
|
|
|
300
442
|
private let hosts = NSHashTable<CraftMacWebHost>.weakObjects()
|
|
301
443
|
|
|
302
444
|
@discardableResult
|
|
303
|
-
func make(id: String? = nil, profile: String = "default", ephemeral: Bool = false, configuration: WKWebViewConfiguration? = nil, owner: CraftMacRuntime
|
|
445
|
+
func make(id: String? = nil, profile: String = "default", ephemeral: Bool = false, configuration: WKWebViewConfiguration? = nil, owner: CraftMacRuntime?, ownContent: Bool = true) -> CraftMacTab {
|
|
304
446
|
let id = id.flatMap { $0.isEmpty ? nil : $0 } ?? UUID().uuidString.lowercased()
|
|
305
447
|
if let existing = tabs[id] { return existing }
|
|
306
448
|
let tab = CraftMacTab(id: id, profile: profile, ephemeral: ephemeral,
|
|
307
|
-
configuration: configuration ?? CraftMacWebProfiles.shared.configuration(profile: profile, ephemeral: ephemeral), owner: owner)
|
|
449
|
+
configuration: configuration ?? CraftMacWebProfiles.shared.configuration(profile: profile, ephemeral: ephemeral), owner: owner, ownContent: ownContent)
|
|
308
450
|
tabs[id] = tab
|
|
309
451
|
// A `<WebView>` may have named this tab before it existed (the render and the create cross).
|
|
310
452
|
for host in hosts.allObjects where host.wanted == id { host.show(id) }
|
|
@@ -313,7 +455,44 @@ final class CraftMacWebTabs {
|
|
|
313
455
|
|
|
314
456
|
func register(_ host: CraftMacWebHost) { hosts.add(host) }
|
|
315
457
|
|
|
316
|
-
|
|
458
|
+
/// Permission requests waiting for the screen, by request id. Unanswered ones are denied after two minutes.
|
|
459
|
+
private var pending: [String: (tab: String, decide: (WKPermissionDecision) -> Void)] = [:]
|
|
460
|
+
func ask(tab: CraftMacTab, decide: @escaping (WKPermissionDecision) -> Void) -> String {
|
|
461
|
+
let request = UUID().uuidString
|
|
462
|
+
pending[request] = (tab.id, decide)
|
|
463
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 120) { [weak self] in self?.answer(request, .deny) }
|
|
464
|
+
return request
|
|
465
|
+
}
|
|
466
|
+
@discardableResult
|
|
467
|
+
func answer(_ request: String, _ decision: WKPermissionDecision) -> Bool {
|
|
468
|
+
guard let waiting = pending.removeValue(forKey: request) else { return false }
|
|
469
|
+
waiting.decide(decision); return true
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
func close(_ id: String) {
|
|
473
|
+
for (request, waiting) in pending where waiting.tab == id { answer(request, .deny) }
|
|
474
|
+
tabs.removeValue(forKey: id)?.close()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/// Cookies, storage and caches of a profile: one site's (`host`, matched by its registrable
|
|
478
|
+
/// domain as WebKit names records) or everything.
|
|
479
|
+
func clearData(_ options: [String: Any], answer: @escaping (Any) -> Void) {
|
|
480
|
+
let store = CraftMacWebProfiles.shared.store(profile: options["profile"] as? String ?? "default", ephemeral: options["private"] as? Bool == true)
|
|
481
|
+
let kinds: [String: [String]] = [
|
|
482
|
+
"cookies": [WKWebsiteDataTypeCookies],
|
|
483
|
+
"storage": [WKWebsiteDataTypeLocalStorage, WKWebsiteDataTypeSessionStorage, WKWebsiteDataTypeIndexedDBDatabases, WKWebsiteDataTypeServiceWorkerRegistrations],
|
|
484
|
+
"cache": [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache, WKWebsiteDataTypeFetchCache, WKWebsiteDataTypeOfflineWebApplicationCache],
|
|
485
|
+
]
|
|
486
|
+
let asked = options["types"] as? [String] ?? Array(kinds.keys)
|
|
487
|
+
let types = Set(asked.flatMap { kinds[$0] ?? [] })
|
|
488
|
+
guard let host = (options["host"] as? String)?.lowercased(), !host.isEmpty else {
|
|
489
|
+
store.removeData(ofTypes: types, modifiedSince: .distantPast) { answer(["cleared": true]) }; return
|
|
490
|
+
}
|
|
491
|
+
store.fetchDataRecords(ofTypes: types) { records in
|
|
492
|
+
let matching = records.filter { host == $0.displayName || host.hasSuffix("." + $0.displayName) }
|
|
493
|
+
store.removeData(ofTypes: types, for: matching) { answer(["cleared": true, "records": matching.count]) }
|
|
494
|
+
}
|
|
495
|
+
}
|
|
317
496
|
|
|
318
497
|
/// A closed window takes its tabs with it.
|
|
319
498
|
func closeAll(owner: CraftMacRuntime) {
|
|
@@ -332,6 +511,30 @@ final class CraftMacWebTabs {
|
|
|
332
511
|
answer(tab.state); return
|
|
333
512
|
}
|
|
334
513
|
if method == "list" { answer(tabs.values.map(\.state)); return }
|
|
514
|
+
switch method {
|
|
515
|
+
case "setContentRules":
|
|
516
|
+
guard let identifier = args.first as? String, !identifier.isEmpty else { fail("INVALID_ARGUMENT", "setContentRules needs an identifier"); return }
|
|
517
|
+
let list = args.dropFirst().first as? [String: Any] ?? [:]
|
|
518
|
+
CraftMacContentRules.shared.set(identifier, version: list["version"] as? String ?? "1", json: list["json"] as? String, answer: answer, fail: fail); return
|
|
519
|
+
case "removeContentRules": CraftMacContentRules.shared.remove(args.first as? String ?? ""); answer(true); return
|
|
520
|
+
case "allowContent":
|
|
521
|
+
CraftMacContentRules.shared.allowed = Set((args.first as? [String] ?? []).map { $0.lowercased() })
|
|
522
|
+
CraftMacContentRules.shared.reapply(); answer(true); return
|
|
523
|
+
case "setUserScripts": CraftMacUserScripts.shared.set(args.first as? [[String: Any]] ?? []); answer(true); return
|
|
524
|
+
case "answerPermission":
|
|
525
|
+
let decision: WKPermissionDecision = { switch args.dropFirst().first as? String { case "grant": return .grant; case "prompt": return .prompt; default: return .deny } }()
|
|
526
|
+
answer(self.answer(args.first as? String ?? "", decision)); return
|
|
527
|
+
case "clearData": clearData(options, answer: answer); return
|
|
528
|
+
case "cancelDownload": answer(CraftMacDownloads.shared.cancel(args.first as? String ?? "")); return
|
|
529
|
+
case "revealDownload", "openDownload":
|
|
530
|
+
guard let file = CraftMacDownloads.downloadedFile(args.first as? String ?? "") else { fail("NOT_FOUND", "Not a file in Downloads"); return }
|
|
531
|
+
// An app or a script is revealed, never run: opening it is the person's call in Finder.
|
|
532
|
+
let runnable = (try? file.resourceValues(forKeys: [.contentTypeKey]))?.contentType.map { $0.conforms(to: .application) || $0.conforms(to: .executable) || $0.conforms(to: .script) } ?? true
|
|
533
|
+
if method == "openDownload" && !runnable { answer(NSWorkspace.shared.open(file)) }
|
|
534
|
+
else { NSWorkspace.shared.activateFileViewerSelecting([file]); answer(true) }
|
|
535
|
+
return
|
|
536
|
+
default: break
|
|
537
|
+
}
|
|
335
538
|
guard let id = args.first as? String, let tab = tabs[id] else { fail("NOT_FOUND", "No tab \(String(describing: args.first ?? ""))"); return }
|
|
336
539
|
let view = tab.view
|
|
337
540
|
switch method {
|
|
@@ -368,6 +571,11 @@ final class CraftMacWebTabs {
|
|
|
368
571
|
configuration.caseSensitive = options["caseSensitive"] as? Bool == true
|
|
369
572
|
configuration.wraps = options["wraps"] as? Bool ?? true
|
|
370
573
|
view.find(text, configuration: configuration) { result in answer(["found": result.matchFound]) }
|
|
574
|
+
case "print":
|
|
575
|
+
let operation = view.printOperation(with: .shared)
|
|
576
|
+
operation.view?.frame = view.bounds
|
|
577
|
+
if let window = view.window { operation.runModal(for: window, delegate: nil, didRun: nil, contextInfo: nil) } else { operation.run() }
|
|
578
|
+
answer(true)
|
|
371
579
|
case "snapshot":
|
|
372
580
|
let configuration = WKSnapshotConfiguration()
|
|
373
581
|
if let width = number((args.dropFirst().first as? [String: Any])?["width"]) { configuration.snapshotWidth = NSNumber(value: Double(width)) }
|