craft-native 0.0.410 → 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.410";
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
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.410";
378
+ var version = "0.0.413";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.410";
120
+ var version = "0.0.413";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -11133,6 +11133,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11133
11133
  if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
11134
11134
  guard let scroll = self.enclosingVerticalScroll(of: textView),
11135
11135
  let rect = self.caretRect(of: textView, in: scroll) else { return }
11136
+ // UIKit's text view scrolls this scroll view itself, animated, when
11137
+ // the keyboard finishes appearing. Arriving after an edit and
11138
+ // before this pass, it aims at the caret of the stale layout (the
11139
+ // input's top), and while it runs it carries any offset set here
11140
+ // back toward that target. This pass measured the current layout:
11141
+ // stop that animation so the caret stays where it can be seen. A
11142
+ // finger's drag or fling is the person's, and is left alone.
11143
+ if !scroll.isTracking && !scroll.isDecelerating {
11144
+ scroll.setContentOffset(scroll.contentOffset, animated: false)
11145
+ }
11136
11146
  let visible = scroll.bounds.inset(by: scroll.adjustedContentInset)
11137
11147
  guard !visible.contains(rect) else { return }
11138
11148
  scroll.scrollRectToVisible(rect, animated: false)
@@ -19,6 +19,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
19
19
  /// Where the close button's top-left corner sits, from the window's top-left (`--traffic-lights 18,18`):
20
20
  /// a browser lines the buttons up with its own toolbar row. Nil keeps AppKit's place.
21
21
  let trafficLights: CGPoint?
22
+ /// The screen has work to finish before the app quits (`craft.desktop.onQuit`).
23
+ var holdsQuit = false
22
24
  init(name: String, route: String, options: [String: Any], application: CraftMacApplication) {
23
25
  self.name = name
24
26
  runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: application.network)
@@ -38,6 +40,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
38
40
  window.titleVisibility = .hidden
39
41
  }
40
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) }
41
45
  window.isReleasedWhenClosed = false
42
46
  renderer.root.autoresizingMask = [.width, .height]
43
47
  window.contentView = renderer.root
@@ -69,6 +73,7 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
69
73
  func windowWillClose(_ notification: Notification) {
70
74
  runtime.dispose()
71
75
  CraftMacApplication.shared.scenes.removeValue(forKey: name)
76
+ CraftMacApplication.shared.quitReady(scene: name)
72
77
  }
73
78
  func windowDidBecomeKey(_ notification: Notification) { runtime.send("SCREEN_FOCUS", ["focused": true]) }
74
79
  func windowDidResignKey(_ notification: Notification) { runtime.send("SCREEN_FOCUS", ["focused": false]) }
@@ -91,6 +96,10 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
91
96
  /// endpoint and starts with `--no-session`.
92
97
  var requiresSession = true
93
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] = []
94
103
  var network: CraftMacNetwork!
95
104
  var scenes: [String: CraftMacScene] = [:]
96
105
  var panels: [NSWindow] = []
@@ -98,7 +107,13 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
98
107
  var testDirectory: URL?
99
108
  var testTimer: Timer?
100
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?
101
113
  var appearanceObservation: NSKeyValueObservation?
114
+ /// A quit waiting for screens to finish (`craft.desktop.onQuit`), for at most `quitGrace` seconds.
115
+ private var pendingQuit: (token: String, waiting: Set<String>, timeout: Timer)?
116
+ static let quitGrace: TimeInterval = 3
102
117
 
103
118
  func applicationDidFinishLaunching(_ notification: Notification) {
104
119
  installMenus()
@@ -126,8 +141,58 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
126
141
  }.resume()
127
142
  }
128
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
+ }
129
166
  func applicationWillTerminate(_ notification: Notification) { scenes.values.forEach { $0.runtime.dispose() }; testTimer?.invalidate() }
130
167
  func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
168
+ /// Screens holding the quit hear `craftDesktopQuit` and answer `quitReady` once their work is saved.
169
+ /// The main queue keeps running while AppKit waits, so their timers and requests still complete,
170
+ /// unless `terminate` was called from inside a main-queue block: GCD runs no other main-queue
171
+ /// block until that one returns. So the host never calls it from one, and the timeout is a run
172
+ /// loop timer, which fires either way. A second quit while waiting quits at once.
173
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
174
+ let holding = scenes.values.filter(\.holdsQuit)
175
+ guard !holding.isEmpty, pendingQuit == nil else { return .terminateNow }
176
+ let token = UUID().uuidString
177
+ let timeout = Timer(timeInterval: Self.quitGrace, repeats: false) { [weak self] _ in self?.finishQuit(token) }
178
+ RunLoop.main.add(timeout, forMode: .common)
179
+ pendingQuit = (token, Set(holding.map(\.name)), timeout)
180
+ for scene in holding { scene.runtime.send("CRAFT_EVENT", ["event": "craftDesktopQuit", "detail": ["token": token]]) }
181
+ return .terminateLater
182
+ }
183
+ /// A screen finished its quit work, or went away. `token` nil matches the quit in progress.
184
+ func quitReady(scene: String, token: String? = nil) {
185
+ guard var pending = pendingQuit, token == nil || token == pending.token else { return }
186
+ pending.waiting.remove(scene)
187
+ pendingQuit = pending
188
+ if pending.waiting.isEmpty { finishQuit(pending.token) }
189
+ }
190
+ private func finishQuit(_ token: String) {
191
+ guard let pending = pendingQuit, pending.token == token else { return }
192
+ pending.timeout.invalidate()
193
+ pendingQuit = nil
194
+ NSApp.reply(toApplicationShouldTerminate: true)
195
+ }
131
196
  func open(name: String, route: String, options: [String: Any]) {
132
197
  if let scene = scenes[name] { scene.window.makeKeyAndOrderFront(nil); return }
133
198
  scenes[name] = CraftMacScene(name: name, route: route, options: options, application: self)
@@ -233,6 +298,9 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
233
298
  bar.insertItem(item, at: min(index, bar.items.count))
234
299
  }; reply(true)
235
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 }
236
304
  let target = CraftMacMenuTarget(), menu = NSMenu(); menu.autoenablesItems = false
237
305
  for item in args.first as? [[String: Any]] ?? [] { menu.addItem(menuItem(item, target: target, action: #selector(CraftMacMenuTarget.choose(_:)))) }
238
306
  menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil); reply(target.chosen ?? NSNull())
@@ -252,7 +320,19 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
252
320
  let filter = CIFilter(name: "CIQRCodeGenerator")!; filter.setValue(Data(text.utf8), forKey: "inputMessage"); filter.setValue("M", forKey: "inputCorrectionLevel")
253
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 }
254
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
+ }
255
333
  case "setBadge": let count = (args.first as? NSNumber)?.intValue ?? 0; NSApp.dockTile.badgeLabel = count > 0 ? String(count) : nil; reply(true)
334
+ case "holdQuit": scene?.holdsQuit = args.first as? Bool != false; reply(true)
335
+ case "quitReady": if let scene { quitReady(scene: scene.name, token: args.first as? String) }; reply(true)
256
336
  default: reply(["error": "Unsupported desktop operation: \(method)"])
257
337
  }
258
338
  }
@@ -322,7 +402,9 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
322
402
  private func testAction(_ action: [String: Any]) -> [String: Any] {
323
403
  let kind = action["action"] as? String ?? ""
324
404
  if kind == "command", let id = action["id"] as? String { broadcast(id); return ["ok": true] }
325
- if kind == "quit" { DispatchQueue.main.async { NSApp.terminate(nil) }; return ["ok": true] }
405
+ // After this result is written, from the run loop rather than a main-queue block (see applicationShouldTerminate).
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] }
326
408
  if kind == "appearance" {
327
409
  NSApp.appearance = NSAppearance(named: action["value"] as? String == "dark" ? .darkAqua : .aqua)
328
410
  appearanceChanged(); return ["ok": true]
@@ -336,7 +418,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
336
418
  return ["ok": true]
337
419
  }
338
420
  // A control by its testID, driven the way a person would: pressed, typed into, submitted, a key, the pointer over it.
339
- if ["press", "type", "submit", "key", "hover"].contains(kind) {
421
+ if ["press", "type", "submit", "key", "hover", "menu"].contains(kind) {
340
422
  guard let target = action["target"] as? String,
341
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"] ?? "")"] }
342
424
  let text = action["text"] as? String ?? ""
@@ -348,6 +430,8 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
348
430
  box.input(text)
349
431
  case "submit": box.fire("onSubmitEditing", ["text": text, "value": text, "nativeEvent": ["text": text]])
350
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])
351
435
  default:
352
436
  let hovered = action["value"] as? Bool ?? true
353
437
  box.hovering = hovered; box.applyAppearance(animated: false)
@@ -423,7 +507,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
423
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]
424
508
  }
425
509
  let webViews = windows.reduce(0) { count, window in count + ((window["viewClasses"] as? [String] ?? []).filter { $0.contains("WKWebView") || $0 == "WebView" }.count) }
426
- 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]
427
511
  }
428
512
  }
429
513
 
@@ -451,6 +535,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
451
535
  application.initialRoute = argument("--route", key: "route", fallback: "Main")
452
536
  application.titlebarStyle = argument("--titlebar-style", key: "titlebarStyle", fallback: "standard")
453
537
  // `18,18` on the command line; `[18, 18]` in craft-app.json.
538
+ application.dockMenu = packaged["dockMenu"] as? [[String: Any]] ?? []
454
539
  let lights = argument("--traffic-lights", key: "trafficLights").split(separator: ",").compactMap { Double($0.trimmingCharacters(in: CharacterSet(charactersIn: " []"))) }
455
540
  if lights.count == 2 { application.trafficLights = lights }
456
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?) -> CraftMacTab {
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
- func close(_ id: String) { tabs.removeValue(forKey: id)?.close() }
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)) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.410",
3
+ "version": "0.0.413",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",