craft-native 0.0.436 → 0.0.442

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE.md +30 -0
  2. package/README.md +35 -0
  3. package/dist/android/src/index.d.ts +8 -0
  4. package/dist/android/src/index.js +17 -2
  5. package/dist/android/templates/CraftHealthServices.kt.template +2 -0
  6. package/dist/android/templates/CraftNativeCapabilities.kt.template +42 -1
  7. package/dist/android/templates/CraftNativeMotion.kt.template +269 -0
  8. package/dist/android/templates/CraftNativeServices.kt.template +6 -1
  9. package/dist/android/templates/CraftPassiveSteps.kt.template +159 -0
  10. package/dist/android/templates/CraftRecordingPressure.kt.template +156 -0
  11. package/dist/android/templates/LocationRecordingService.kt.template +77 -9
  12. package/dist/android/templates/MainActivityNative.kt.template +129 -1
  13. package/dist/cli.js +289 -108
  14. package/dist/components/index.d.ts +25 -0
  15. package/dist/index.cjs +207 -27
  16. package/dist/index.d.cts +2 -2
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +321 -140
  19. package/dist/installer-background.d.ts +2 -0
  20. package/dist/installer-layout.d.ts +1 -0
  21. package/dist/ios/src/index.d.ts +10 -0
  22. package/dist/ios/src/index.js +28 -2
  23. package/dist/ios/templates/CraftApp.swift +14 -0
  24. package/dist/ios/templates/CraftHealth.swift +2 -1
  25. package/dist/ios/templates/CraftHealthBackground.swift +308 -0
  26. package/dist/ios/templates/CraftNativeActions.swift +153 -0
  27. package/dist/ios/templates/CraftNativePedometer.swift +181 -0
  28. package/dist/ios/templates/CraftNativeScreen.swift +76 -0
  29. package/dist/macos/src/index.js +1 -1
  30. package/dist/macos/src/toolchain.d.ts +1 -1
  31. package/dist/macos/templates/CraftMacApplication.swift +124 -39
  32. package/dist/macos/templates/CraftMacRenderer.swift +111 -33
  33. package/dist/macos/templates/CraftMacRuntime.swift +270 -81
  34. package/dist/package.d.ts +40 -0
  35. package/dist/types.d.ts +26 -0
  36. package/package.json +1 -1
@@ -0,0 +1,181 @@
1
+ import Foundation
2
+ #if CRAFT_PERMISSION_MOTION
3
+ import CoreMotion
4
+ #endif
5
+
6
+ struct CraftPedometerSample {
7
+ let startDate: Double
8
+ let endDate: Double
9
+ let steps: Int
10
+ var distanceMeters: Double?
11
+ var cadenceStepsPerSecond: Double?
12
+ var paceSecondsPerMeter: Double?
13
+ var averageActivePaceSecondsPerMeter: Double?
14
+ var floorsAscended: Int?
15
+ var floorsDescended: Int?
16
+
17
+ var payload: [String: Any] {
18
+ var value: [String: Any] = ["state": "sample", "kind": "interval", "startDate": startDate, "endDate": endDate, "steps": steps]
19
+ if let distanceMeters { value["distanceMeters"] = distanceMeters }
20
+ if let cadenceStepsPerSecond { value["cadenceStepsPerSecond"] = cadenceStepsPerSecond }
21
+ if let paceSecondsPerMeter { value["paceSecondsPerMeter"] = paceSecondsPerMeter }
22
+ if let averageActivePaceSecondsPerMeter { value["averageActivePaceSecondsPerMeter"] = averageActivePaceSecondsPerMeter }
23
+ if let floorsAscended { value["floorsAscended"] = floorsAscended }
24
+ if let floorsDescended { value["floorsDescended"] = floorsDescended }
25
+ return value
26
+ }
27
+ }
28
+
29
+ protocol CraftPedometerSource: AnyObject {
30
+ var availability: [String: Bool] { get }
31
+ var permission: String { get }
32
+ func start(from: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void)
33
+ func query(from: Date, to: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void)
34
+ func stop()
35
+ }
36
+
37
+ #if CRAFT_PERMISSION_MOTION
38
+ private final class CraftCoreMotionPedometer: CraftPedometerSource {
39
+ private let pedometer = CMPedometer()
40
+ var availability: [String: Bool] {
41
+ ["steps": CMPedometer.isStepCountingAvailable(), "distance": CMPedometer.isDistanceAvailable(),
42
+ "floors": CMPedometer.isFloorCountingAvailable(), "cadence": CMPedometer.isCadenceAvailable(),
43
+ "pace": CMPedometer.isPaceAvailable(), "history": CMPedometer.isStepCountingAvailable(), "background": false]
44
+ }
45
+ var permission: String {
46
+ switch CMPedometer.authorizationStatus() {
47
+ case .authorized: return "granted"
48
+ case .denied: return "denied"
49
+ case .restricted: return "restricted"
50
+ case .notDetermined: return "notDetermined"
51
+ @unknown default: return "unavailable"
52
+ }
53
+ }
54
+ private func handler(_ receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) -> CMPedometerHandler {
55
+ { data, error in
56
+ let result: Result<CraftPedometerSample, Error>
57
+ if let error { result = .failure(error) }
58
+ else if let data {
59
+ result = .success(CraftPedometerSample(startDate: data.startDate.timeIntervalSince1970 * 1000,
60
+ endDate: data.endDate.timeIntervalSince1970 * 1000, steps: data.numberOfSteps.intValue,
61
+ distanceMeters: data.distance?.doubleValue, cadenceStepsPerSecond: data.currentCadence?.doubleValue,
62
+ paceSecondsPerMeter: data.currentPace?.doubleValue, averageActivePaceSecondsPerMeter: data.averageActivePace?.doubleValue,
63
+ floorsAscended: data.floorsAscended?.intValue, floorsDescended: data.floorsDescended?.intValue))
64
+ } else { result = .failure(NSError(domain: "CraftPedometer", code: 1, userInfo: [NSLocalizedDescriptionKey: "Pedometer returned no data"])) }
65
+ DispatchQueue.main.async { receive(result) }
66
+ }
67
+ }
68
+ func start(from: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {
69
+ pedometer.startUpdates(from: from, withHandler: handler(receive))
70
+ }
71
+ func query(from: Date, to: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {
72
+ pedometer.queryPedometerData(from: from, to: to, withHandler: handler(receive))
73
+ }
74
+ func stop() { pedometer.stopUpdates() }
75
+ }
76
+ #endif
77
+
78
+ private final class CraftUnavailablePedometer: CraftPedometerSource {
79
+ var availability: [String: Bool] { ["steps": false, "distance": false, "floors": false, "cadence": false, "pace": false, "history": false, "background": false] }
80
+ var permission: String { "unavailable" }
81
+ func start(from: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {}
82
+ func query(from: Date, to: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {}
83
+ func stop() {}
84
+ }
85
+
86
+ /// Interval counts are never relabelled as Android's counter since boot.
87
+ final class CraftNativePedometer {
88
+ typealias Completion = (Result<Any, CraftNativeActionError>) -> Void
89
+ private let makeSource: () -> CraftPedometerSource
90
+ private let now: () -> Date
91
+ private var live: CraftPedometerSource?
92
+ private var pending = [String: CraftPedometerSource]()
93
+ private var closed = false
94
+ var emit: ([String: Any]) -> Void = { _ in }
95
+
96
+ init(makeSource: (() -> CraftPedometerSource)? = nil, now: @escaping () -> Date = Date.init) {
97
+ self.now = now
98
+ self.makeSource = makeSource ?? {
99
+ #if CRAFT_PERMISSION_MOTION
100
+ return CraftCoreMotionPedometer()
101
+ #else
102
+ return CraftUnavailablePedometer()
103
+ #endif
104
+ }
105
+ }
106
+
107
+ func perform(method: String, args: [Any], token: String, completion: @escaping Completion) {
108
+ guard !closed else { return completion(failure("HOST_CLOSED", "Pedometer host is closed")) }
109
+ if method == "stop" { completion(.success(stop(reason: "requested"))); return }
110
+ let source = makeSource()
111
+ if method == "getAvailability" { completion(.success(source.availability)); return }
112
+ if method == "getPermissionStatus" { completion(.success(source.permission)); return }
113
+ guard ["start", "query", "requestPermission"].contains(method) else { return completion(failure("UNKNOWN_ACTION", "Unsupported Pedometer method")) }
114
+ guard source.availability["steps"] == true else { return completion(failure("UNAVAILABLE", "Step counting is not present")) }
115
+ if method == "requestPermission", ["granted", "denied", "restricted"].contains(source.permission) {
116
+ completion(.success(source.permission)); return
117
+ }
118
+ guard !["denied", "restricted"].contains(source.permission) else { return completion(failure("PERMISSION_DENIED", "Motion access was denied or restricted")) }
119
+ guard args.isEmpty || args.first is [String: Any] else { return completion(failure("INVALID_ARGUMENT", "Pedometer options must be an object")) }
120
+ let options = args.first as? [String: Any] ?? [:]
121
+ if let background = options["background"] {
122
+ guard let flag = background as? NSNumber, CFGetTypeID(flag) == CFBooleanGetTypeID() else { return completion(failure("INVALID_ARGUMENT", "background must be boolean")) }
123
+ if flag.boolValue { return completion(failure("UNSUPPORTED", "Background pedometer streaming is not supported")) }
124
+ }
125
+ let current = now().timeIntervalSince1970 * 1000
126
+ let first: Double, last: Double
127
+ if method == "requestPermission" { first = current - 1000; last = current }
128
+ else {
129
+ guard let start = date(options["startDate"] ?? (method == "start" ? current : NSNull())),
130
+ let end = date(options["endDate"] ?? current), start >= current - 7 * 86400 * 1000,
131
+ start <= current, end <= current, (method == "start" ? start <= end : start < end) else {
132
+ return completion(failure("INVALID_ARGUMENT", "Dates must describe an interval within the last seven days"))
133
+ }
134
+ first = start; last = end
135
+ }
136
+ if method == "start" {
137
+ let previous = live
138
+ live = source
139
+ source.start(from: Date(timeIntervalSince1970: first / 1000)) { [weak self, weak source] result in
140
+ guard let self, let source, !self.closed, self.live === source else { return }
141
+ switch result {
142
+ case .success(let sample): self.emit(sample.payload)
143
+ case .failure: self.stop(reason: ["denied", "restricted"].contains(source.permission) ? "permissionDenied" : "sensorError")
144
+ }
145
+ }
146
+ previous?.stop()
147
+ completion(.success(true))
148
+ } else {
149
+ pending[token] = source
150
+ source.query(from: Date(timeIntervalSince1970: first / 1000), to: Date(timeIntervalSince1970: last / 1000)) { [weak self, weak source] result in
151
+ guard let self, let source, !self.closed, self.pending[token] === source else { return }
152
+ self.pending.removeValue(forKey: token)
153
+ if method == "requestPermission", ["granted", "denied", "restricted"].contains(source.permission) {
154
+ completion(.success(source.permission)); return
155
+ }
156
+ switch result {
157
+ case .success(let sample): completion(.success(method == "requestPermission" ? source.permission as Any : sample.payload))
158
+ case .failure(let error): completion(self.failure(["denied", "restricted"].contains(source.permission) ? "PERMISSION_DENIED" : "SENSOR_ERROR", error.localizedDescription))
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ private func date(_ value: Any) -> Double? {
165
+ guard let value = value as? NSNumber, CFGetTypeID(value) != CFBooleanGetTypeID(), value.doubleValue.isFinite else { return nil }
166
+ return value.doubleValue
167
+ }
168
+ func cancel(_ token: String) { pending.removeValue(forKey: token) }
169
+ @discardableResult func stop(reason: String) -> Bool {
170
+ guard let previous = live else { return false }
171
+ live = nil
172
+ previous.stop()
173
+ emit(["state": "stopped", "reason": reason])
174
+ return true
175
+ }
176
+ func close() { closed = true; pending.removeAll(); stop(reason: "disposed"); emit = { _ in } }
177
+ deinit { live?.stop() }
178
+ private func failure(_ code: String, _ message: String) -> Result<Any, CraftNativeActionError> {
179
+ .failure(CraftNativeActionError(code: code, message: message))
180
+ }
181
+ }
@@ -6191,6 +6191,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6191
6191
  private var inputIdentities: [ObjectIdentifier: String] = [:]
6192
6192
  private var inputDrafts: [String: String] = [:]
6193
6193
  private var longPressHandlers: [ObjectIdentifier: String] = [:]
6194
+ private var accessibilityActionHandlers: [ObjectIdentifier: String] = [:]
6194
6195
  private var textMaxLengths: [ObjectIdentifier: Int] = [:]
6195
6196
  private var scrollHandlers: [ObjectIdentifier: String] = [:]
6196
6197
  private var scrollBeginHandlers: [ObjectIdentifier: String] = [:]
@@ -6259,6 +6260,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6259
6260
  private let audio = CraftWebAudio()
6260
6261
  /// `craft.audio`'s sounds and the streaming responses this screen reads.
6261
6262
  private let sounds = CraftNativeSounds()
6263
+ private let barometer = CraftNativeBarometer()
6264
+ private let pedometer = CraftNativePedometer()
6262
6265
  private let streams = CraftNativeEventStreams()
6263
6266
  /// A request a screen-owned module answers, by request token, and how to
6264
6267
  /// call it off when it times out or is cancelled.
@@ -6595,6 +6598,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6595
6598
  }
6596
6599
  audio.close()
6597
6600
  sounds.close()
6601
+ barometer.close()
6602
+ pedometer.close()
6598
6603
  streams.close()
6599
6604
  screenRequestCancellers.values.forEach { $0() }
6600
6605
  screenRequestCancellers.removeAll()
@@ -8037,6 +8042,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8037
8042
  guard let name = notification.userInfo?["event"] as? String else { return }
8038
8043
  self?.send(type: "CRAFT_EVENT", payload: ["event": name, "detail": notification.userInfo?["detail"] ?? [:]])
8039
8044
  })
8045
+ pedometer.emit = { [weak self] detail in self?.send(type: "CRAFT_EVENT", payload: ["event": "craftPedometerUpdate", "detail": detail]) }
8046
+ barometer.emit = { [weak self] detail in self?.send(type: "CRAFT_EVENT", payload: ["event": "craftBarometerUpdate", "detail": detail]) }
8040
8047
  sounds.emit = { [weak self] payload in self?.send(type: "SOUND_EVENT", payload: payload) }
8041
8048
  streams.emit = { [weak self] payload in self?.send(type: "NETWORK_STREAM", payload: payload) }
8042
8049
  sendAppState(CraftNativeActions.currentAppState())
@@ -8201,6 +8208,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8201
8208
  }
8202
8209
 
8203
8210
  private func sendAppState(_ state: String) {
8211
+ if state != "active" { barometer.stop(reason: "background"); pedometer.stop(reason: "background") }
8204
8212
  // A screen hears it while it is on top of its stack; the app script,
8205
8213
  // which is on no stack, always.
8206
8214
  guard isAppScript || navigationController?.topViewController === self else { return }
@@ -8412,6 +8420,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8412
8420
  }
8413
8421
  let id = args.first as? String ?? ""
8414
8422
  switch (module, method) {
8423
+ case ("Pedometer", _):
8424
+ if !config.enablePedometer {
8425
+ reply(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Pedometer is disabled")))
8426
+ } else if isAppScript {
8427
+ reply(.failure(CraftNativeActionError(code: "NEEDS_ACTIVITY", message: "Pedometer needs a visible app")))
8428
+ } else if ["start", "requestPermission"].contains(method) && UIApplication.shared.applicationState != .active {
8429
+ reply(.failure(CraftNativeActionError(code: "APP_INACTIVE", message: "Pedometer needs a foreground app")))
8430
+ } else {
8431
+ screenRequestCancellers[requestToken] = { [weak self] in self?.pedometer.cancel(requestToken) }
8432
+ pedometer.perform(method: method, args: args, token: requestToken, completion: reply)
8433
+ }
8434
+ return true
8435
+ case ("Barometer", _):
8436
+ if !config.enableBarometer {
8437
+ reply(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Barometer is disabled")))
8438
+ } else if isAppScript {
8439
+ reply(.failure(CraftNativeActionError(code: "NEEDS_ACTIVITY", message: "Barometer needs a visible app")))
8440
+ } else if method == "start" && UIApplication.shared.applicationState != .active {
8441
+ reply(.failure(CraftNativeActionError(code: "APP_INACTIVE", message: "Barometer needs a foreground app")))
8442
+ } else {
8443
+ reply(barometer.perform(method: method, args: args))
8444
+ }
8445
+ return true
8415
8446
  case ("AudioRecording", _):
8416
8447
  guard config.enableAudioRecording else {
8417
8448
  reply(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Audio recording is disabled")))
@@ -9400,6 +9431,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9400
9431
  }
9401
9432
  result.craftPointerEvents = CraftNativePointerEvents.resolve(props: props, style: style)
9402
9433
  applyAccessibility(props, type: type, to: result)
9434
+ // After applyAccessibility: it settles whether the view is an element
9435
+ // at all, and an action on a non-element is unreachable.
9436
+ updateAccessibilityActions(props["accessibilityActions"], handler: props["disabled"] as? Bool == true ? nil : events["onAccessibilityAction"], for: result)
9403
9437
  (result as? CraftNativeFlowView)?.configureCelebration(props["celebration"])
9404
9438
  return current
9405
9439
  }
@@ -9882,6 +9916,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9882
9916
  inputEventCounts.removeValue(forKey: id)
9883
9917
  if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
9884
9918
  longPressHandlers.removeValue(forKey: id)
9919
+ accessibilityActionHandlers.removeValue(forKey: id)
9885
9920
  textMaxLengths.removeValue(forKey: id)
9886
9921
  scrollHandlers.removeValue(forKey: id)
9887
9922
  scrollBeginHandlers.removeValue(forKey: id)
@@ -10099,6 +10134,47 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10099
10134
  }
10100
10135
  }
10101
10136
 
10137
+ /// What a hold offers, published where the rotor can reach it.
10138
+ ///
10139
+ /// A long press is invisible to VoiceOver: nothing announces that it is
10140
+ /// there and nothing can perform it, so an action living only behind a
10141
+ /// hold does not exist for anyone driving the screen by assistive
10142
+ /// technology. A thread that holds a message for Copy and Report is the
10143
+ /// case this was written for; without this the honest mitigation is a
10144
+ /// permanently visible button under every message.
10145
+ ///
10146
+ /// Shape follows the rest of the accessibility props: `name` identifies
10147
+ /// the action to the handler, `label` is what a person hears, and the
10148
+ /// event says which one ran so one handler can serve them all.
10149
+ private func updateAccessibilityActions(_ raw: Any?, handler: String?, for view: UIView) {
10150
+ let id = ObjectIdentifier(view)
10151
+ let entries = (raw as? [[String: Any]] ?? []).compactMap { entry -> (name: String, label: String)? in
10152
+ guard let name = entry["name"] as? String, !name.isEmpty else { return nil }
10153
+ return (name, (entry["label"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? name)
10154
+ }
10155
+ guard let handler, !handler.isEmpty, !entries.isEmpty else {
10156
+ accessibilityActionHandlers.removeValue(forKey: id)
10157
+ view.accessibilityCustomActions = nil
10158
+ return
10159
+ }
10160
+ accessibilityActionHandlers[id] = handler
10161
+ view.accessibilityCustomActions = entries.map { entry in
10162
+ UIAccessibilityCustomAction(name: entry.label) { [weak self, weak view] _ in
10163
+ // Read the handler at firing time: a reconcile can rename it
10164
+ // while the rotor sits on the element.
10165
+ guard let self, let view, let current = self.accessibilityActionHandlers[ObjectIdentifier(view)] else { return false }
10166
+ self.send(type: "EVENT", payload: ["handlerName": current, "nativeEvent": ["actionName": entry.name]])
10167
+ return true
10168
+ }
10169
+ }
10170
+ // The rotor only lands on an element, and actions on a view it walks
10171
+ // straight through are actions nobody can get to. A bubble whose own
10172
+ // label is its text is exactly that case.
10173
+ if !view.isAccessibilityElement && view.accessibilityElements == nil && view.accessibilityLabel != nil {
10174
+ view.isAccessibilityElement = true
10175
+ }
10176
+ }
10177
+
10102
10178
  /// Legacy runtimes echo values without a revision. Keep their bounded
10103
10179
  /// history on either script thread; bridge delivery may still be delayed.
10104
10180
  private var unechoedText: [ObjectIdentifier: [String]] = [:]
@@ -43,7 +43,7 @@ import process from "process";
43
43
  var macosToolchain = {
44
44
  zig: "0.18.0-dev.252+86dc5ce25",
45
45
  sources: {
46
- "zig-js": { repository: "zig-utils/zig-js", revision: "44a0a2ba814a911ac701c44073bd8d7d8ff6a22d", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
46
+ "zig-js": { repository: "zig-utils/zig-js", revision: "95d835edcbad55a7456cff76a9bb7ef6cdf265fc", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
47
47
  "zig-regex": { repository: "zig-utils/zig-regex", revision: "fdd9c2e333ea6ac2fd06d080d939ef7f11d61b1b", paths: ["src"] },
48
48
  "zig-gc": { repository: "zig-utils/zig-gc", revision: "73c802e05768a729614e9c4296abb118aa06faf9", paths: ["src"] }
49
49
  }
@@ -9,7 +9,7 @@ export declare const macosToolchain: {
9
9
  readonly sources: {
10
10
  readonly 'zig-js': {
11
11
  readonly repository: 'zig-utils/zig-js';
12
- readonly revision: '44a0a2ba814a911ac701c44073bd8d7d8ff6a22d';
12
+ readonly revision: '95d835edcbad55a7456cff76a9bb7ef6cdf265fc';
13
13
  readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
14
14
  };
15
15
  readonly 'zig-regex': {
@@ -1,5 +1,6 @@
1
1
  import AppKit
2
2
  import AVKit
3
+ import ZigJS
3
4
  import CoreImage
4
5
  import UserNotifications
5
6
  import UniformTypeIdentifiers
@@ -23,19 +24,31 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
23
24
  var holdsQuit = false
24
25
  /// The content width the screen last heard, so a resize that changes nothing sends nothing.
25
26
  var reportedViewportWidth: CGFloat = -1
26
- init(name: String, route: String, options: [String: Any], application: CraftMacApplication) {
27
+ /// The content size the screen started with, which the window corrects once it shows.
28
+ let startedViewport: CGSize
29
+ private var shown = false
30
+ /// How long a window waits for its screen's first tree before it opens without it.
31
+ static let firstTreeGrace: TimeInterval = 0.5
32
+ /// `runtime` is the screen already started for this window (the main one starts before AppKit has
33
+ /// launched); without one the screen starts here, and evaluates its bundle while the window is made.
34
+ init(name: String, route: String, options: [String: Any], application: CraftMacApplication, runtime prepared: CraftMacRuntime? = nil) {
35
+ CraftTrace.mark("scene \(name)")
27
36
  self.name = name
28
- runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: application.network)
29
- renderer = CraftMacRenderer(runtime: runtime)
30
37
  let width = number(options["width"]) ?? 1120, height = number(options["height"]) ?? 740
31
38
  let transparent = (options["titlebarStyle"] as? String ?? application.titlebarStyle) == "transparent"
39
+ let viewport = Self.contentSize(options: options, application: application, name: name)
40
+ runtime = prepared ?? application.startScreen(route: route, options: options, viewport: viewport, colorScheme: CraftMacColor.isDark ? "dark" : "light")
41
+ reportedViewportWidth = viewport.width
42
+ startedViewport = viewport
43
+ renderer = CraftMacRenderer(runtime: runtime)
32
44
  let lights = options["trafficLights"] as? [Any] ?? (name == "main" ? application.trafficLights : nil)
33
45
  trafficLights = lights.flatMap { values in values.count == 2 ? number(values[0]).flatMap { x in number(values[1]).map { CGPoint(x: x, y: $0) } } : nil }
34
- let panel = CraftMacWindow(contentRect: CGRect(x: 0, y: 0, width: width, height: height), styleMask: transparent ? [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView] : [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false)
46
+ let panel = CraftMacWindow(contentRect: CGRect(x: 0, y: 0, width: width, height: height), styleMask: transparent ? [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView] : [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: true)
35
47
  panel.runtime = runtime
36
48
  window = panel
49
+ CraftTrace.mark("window created")
37
50
  super.init()
38
- runtime.scene = self; renderer.window = window
51
+ renderer.window = window
39
52
  window.title = options["title"] as? String ?? application.appName
40
53
  if (options["titlebarStyle"] as? String ?? application.titlebarStyle) == "transparent" {
41
54
  window.titlebarAppearsTransparent = true
@@ -48,14 +61,54 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
48
61
  renderer.root.autoresizingMask = [.width, .height]
49
62
  window.contentView = renderer.root
50
63
  window.delegate = self; window.center()
51
- if name == "main" && application.testDirectory == nil { window.setFrameAutosaveName("\(application.appName)NativeMain") }
52
- window.makeKeyAndOrderFront(nil)
64
+ if let autosave = Self.autosaveName(name: name, application: application) { window.setFrameAutosaveName(autosave) }
65
+ // Whatever the screen drew while the window was made is in it before it shows. A screen that has not
66
+ // drawn yet gets a moment to, so the window opens with its content rather than blank.
67
+ runtime.attach(self)
68
+ if runtime.hasDrawn { show() } else {
69
+ runtime.whenDrawn { [weak self] in self?.show() }
70
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.firstTreeGrace) { [weak self] in self?.show() }
71
+ }
72
+ }
73
+
74
+ /// Orders the window front the first time. The window is made deferred, so the window server's side
75
+ /// of it is made here, once, rather than updated for each property set while it was configured; and
76
+ /// it opens without the zoom AppKit plays for a new window, which runs a display link and draws it in
77
+ /// over several frames. Later showings animate as usual.
78
+ func show() {
79
+ guard !shown else { return }
80
+ shown = true
81
+ let behavior = window.animationBehavior
82
+ window.animationBehavior = .none
83
+ CraftTrace.span("window ordered front") { window.makeKeyAndOrderFront(nil) }
84
+ window.animationBehavior = behavior
53
85
  placeTrafficLights()
54
- // The screen starts knowing its width, so `md:`/`lg:` classes and `matchMedia` are right on the first frame.
86
+ // The screen started with the size this window was expected to open at; a saved frame the
87
+ // screen cannot fit, or a smaller display, makes it another, which the screen now hears.
55
88
  let size = window.contentLayoutRect.size
56
- reportedViewportWidth = size.width
57
- runtime.context.evaluateScript("__stxNativeBridge.viewportWidth = \(Double(size.width)); __stxNativeBridge.viewportHeight = \(Double(size.height));")
58
- runtime.load(application.bundle)
89
+ if abs(size.width - startedViewport.width) >= 0.5 || abs(size.height - startedViewport.height) >= 0.5 {
90
+ reportedViewportWidth = size.width
91
+ runtime.send("VIEWPORT", ["width": Double(size.width), "height": Double(size.height)])
92
+ }
93
+ }
94
+
95
+ static func autosaveName(name: String, application: CraftMacApplication) -> String? {
96
+ name == "main" && application.testDirectory == nil ? "\(application.appName)NativeMain" : nil
97
+ }
98
+
99
+ /// The content size (below the title bar) the window will open at, known before it exists: the size
100
+ /// asked for, or the frame AppKit saved for it last time. Its screen starts with it.
101
+ static func contentSize(options: [String: Any], application: CraftMacApplication, name: String) -> CGSize {
102
+ let transparent = (options["titlebarStyle"] as? String ?? application.titlebarStyle) == "transparent"
103
+ let titlebar = NSWindow.frameRect(forContentRect: CGRect(x: 0, y: 0, width: 100, height: 100), styleMask: [.titled]).height - 100
104
+ let width = number(options["width"]) ?? 1120, height = number(options["height"]) ?? 740
105
+ // `x y width height` of the frame, then the screen's.
106
+ if let autosave = autosaveName(name: name, application: application),
107
+ let saved = UserDefaults.standard.string(forKey: "NSWindow Frame \(autosave)")?.split(separator: " ").compactMap({ Double($0) }), saved.count >= 4 {
108
+ return CGSize(width: saved[2], height: max(0, saved[3] - titlebar))
109
+ }
110
+ // A full-size content view runs under the title bar, which its layout rect leaves out.
111
+ return CGSize(width: width, height: transparent ? max(0, height - titlebar) : height)
59
112
  }
60
113
 
61
114
  /// Tells the screen its new width (VIEWPORT, as a phone does on rotation): only when the width changed,
@@ -109,8 +162,8 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
109
162
  var titlebarStyle = "standard"
110
163
  var trafficLights: [Any]?
111
164
  /// Apps that ship their own agent (Attic) hand out a session cookie at
112
- /// `/native/session`. An app that talks to a hosted API has no such
113
- /// endpoint and starts with `--no-session`.
165
+ /// `/native/session` (`authorize`). An app that talks to a hosted API has
166
+ /// no such endpoint and starts with `--no-session`.
114
167
  var requiresSession = true
115
168
  var mainWindowOptions: [String: Any] = [:]
116
169
  /// The Dock icon's menu (`craft-app.json` `dockMenu`): each item sends its action as a desktop command.
@@ -128,34 +181,46 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
128
181
  var testMenu: [[String: Any]] = []
129
182
  var testMenuChoice: String?
130
183
  var appearanceObservation: NSKeyValueObservation?
184
+ /// The main window's screen, started before AppKit launched, waiting for its window.
185
+ var preparedMain: CraftMacRuntime?
186
+ /// The appearance it started with, which AppKit could not be asked yet.
187
+ var preparedColorScheme = ""
188
+ var mainOptions: [String: Any] { mainWindowOptions.merging(["title": appName]) { _, new in new } }
189
+ /// Launch finished, and why the agent issued no session, if it did not (shown once both are known).
190
+ var launched = false
191
+ var sessionFailure: String?
131
192
  /// A quit waiting for screens to finish (`craft.desktop.onQuit`), for at most `quitGrace` seconds.
132
193
  private var pendingQuit: (token: String, waiting: Set<String>, timeout: Timer)?
133
194
  static let quitGrace: TimeInterval = 3
134
195
 
196
+ func applicationWillFinishLaunching(_ notification: Notification) { CraftTrace.mark("will finish launching") }
135
197
  func applicationDidFinishLaunching(_ notification: Notification) {
136
- installMenus()
198
+ CraftTrace.mark("did finish launching")
199
+ CraftTrace.span("menus installed") { installMenus() }
137
200
  appearanceObservation = NSApp.observe(\.effectiveAppearance, options: [.new]) { [weak self] _, _ in
138
201
  DispatchQueue.main.async { self?.appearanceChanged() }
139
202
  }
140
203
  NSApp.activate(ignoringOtherApps: true)
141
- if !requiresSession {
142
- open(name: "main", route: initialRoute, options: mainWindowOptions.merging(["title": appName]) { _, new in new })
143
- startTestDriver()
144
- return
145
- }
146
- // The agent issues an HttpOnly cookie. It stays in URLSession, shared
147
- // by native windows, and is never handed to the JavaScript context.
148
- let request = URLRequest(url: network.base.appendingPathComponent("native/session"))
149
- network.session.dataTask(with: request) { [weak self] _, response, error in
204
+ open(name: "main", route: initialRoute, options: mainOptions)
205
+ if preparedColorScheme != (CraftMacColor.isDark ? "dark" : "light") { appearanceChanged() }
206
+ startTestDriver()
207
+ launched = true
208
+ if let failure = sessionFailure { sessionFailed(failure) }
209
+ }
210
+ /// Asks the agent for the session cookie (`CraftMacNetwork.authorize`) as the app starts, before
211
+ /// AppKit has launched; the screen's requests wait for it. Without one the app says so and quits.
212
+ func authorize() {
213
+ network.authorize { failure in
150
214
  DispatchQueue.main.async {
151
- guard let self else { return }
152
- guard error == nil, (response as? HTTPURLResponse)?.statusCode == 200 else {
153
- let alert = NSAlert(); alert.messageText = "Could not connect to the app"; alert.informativeText = error?.localizedDescription ?? "The local agent did not issue a native session."; alert.runModal(); NSApp.terminate(nil); return
154
- }
155
- self.open(name: "main", route: self.initialRoute, options: self.mainWindowOptions.merging(["title": self.appName]) { _, new in new })
156
- self.startTestDriver()
215
+ CraftTrace.mark("native session answered")
216
+ guard let failure else { return }
217
+ self.sessionFailure = failure
218
+ if self.launched { self.sessionFailed(failure) }
157
219
  }
158
- }.resume()
220
+ }
221
+ }
222
+ private func sessionFailed(_ message: String) {
223
+ let alert = NSAlert(); alert.messageText = "Could not connect to the app"; alert.informativeText = message; alert.runModal(); NSApp.terminate(nil)
159
224
  }
160
225
  func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { scenes["main"]?.window.makeKeyAndOrderFront(nil); return true }
161
226
  /// Links from other apps (this app as the default browser) and files dropped on the Dock icon.
@@ -212,7 +277,24 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
212
277
  }
213
278
  func open(name: String, route: String, options: [String: Any]) {
214
279
  if let scene = scenes[name] { scene.window.makeKeyAndOrderFront(nil); return }
215
- scenes[name] = CraftMacScene(name: name, route: route, options: options, application: self)
280
+ let prepared = name == "main" ? preparedMain : nil
281
+ if prepared != nil { preparedMain = nil }
282
+ scenes[name] = CraftMacScene(name: name, route: route, options: options, application: self, runtime: prepared)
283
+ }
284
+ /// A screen's thread and context, its bundle evaluated as soon as the thread can.
285
+ func startScreen(route: String, options: [String: Any], viewport: CGSize, colorScheme: String) -> CraftMacRuntime {
286
+ let runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: network, colorScheme: colorScheme)
287
+ runtime.load(bundle, viewport: viewport)
288
+ return runtime
289
+ }
290
+ /// Starts the main window's screen before AppKit launches: connecting to the window server and
291
+ /// launching take a few hundred milliseconds, most of them waiting, and the screen evaluates its
292
+ /// bundle, mounts and sends its first requests meanwhile. Its tree is drawn when the window opens.
293
+ func prepareMainScreen() {
294
+ // NSApp does not exist yet, so the system's setting stands in for AppKit's appearance;
295
+ // launching checks it against AppKit's.
296
+ preparedColorScheme = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" ? "dark" : "light"
297
+ preparedMain = startScreen(route: initialRoute, options: mainOptions, viewport: CraftMacScene.contentSize(options: mainOptions, application: self, name: "main"), colorScheme: preparedColorScheme)
216
298
  }
217
299
  /// A menu command goes to the window it was chosen in (the key window), else the main one: with
218
300
  /// two windows of the same screen open, cmd-T opens one tab, not one in each.
@@ -231,7 +313,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
231
313
  // AppKit lays the title bar out again for the new appearance, after this returns.
232
314
  scene.placeTrafficLights()
233
315
  DispatchQueue.main.async { [weak scene] in scene?.placeTrafficLights() }
234
- scene.runtime.context.evaluateScript("__stxNativeBridge.colorScheme = '\(scheme)';")
316
+ scene.runtime.evaluate("__stxNativeBridge.colorScheme = '\(scheme)';")
235
317
  // The protocol's own message: the screen restyles its `dark:` classes and tells `craft.appearance.onChange`.
236
318
  scene.runtime.send("APPEARANCE", ["colorScheme": scheme])
237
319
  scene.window.effectiveAppearance.performAsCurrentDrawingAppearance {
@@ -293,7 +375,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
293
375
  if parts.contains("alt") { item.keyEquivalentModifierMask.insert(.option) }
294
376
  item.isEnabled = raw["disabled"] as? Bool != true
295
377
  item.state = raw["checked"] as? Bool == true ? .on : .off
296
- if let symbol = raw["icon"] as? String { item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) }
378
+ if let symbol = raw["icon"] as? String { item.image = CraftMacSymbols.image(symbol) }
297
379
  return item
298
380
  }
299
381
  func perform(_ method: String, args: [Any], scene: CraftMacScene?, reply: @escaping (Any) -> Void) {
@@ -373,10 +455,10 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
373
455
  guard let path = file["path"], chosenFiles.contains(path), let data = try? Data(contentsOf: URL(fileURLWithPath: path)), data.count <= 256 * 1024 * 1024,
374
456
  var request = network.request(["url": endpoint, "method": "POST", "headers": ["x-filename": file["name"] ?? "Attachment", "content-type": file["type"] ?? "application/octet-stream"]]) else { reply(["error": "Could not read a picked attachment"]); return }
375
457
  request.httpBody = data
376
- network.session.dataTask(with: request) { data, response, error in DispatchQueue.main.async {
458
+ network.resume(network.session.dataTask(with: request) { data, response, error in DispatchQueue.main.async {
377
459
  guard error == nil, (response as? HTTPURLResponse)?.statusCode == 200, let data, let value = try? JSONSerialization.jsonObject(with: data) else { reply(["error": error?.localizedDescription ?? "Attachment upload failed"]); return }
378
460
  results.append(value); next(index + 1)
379
- } }.resume()
461
+ } })
380
462
  }; next(0)
381
463
  }
382
464
  private func preview(_ options: [String: Any], scene: CraftMacScene?) {
@@ -390,18 +472,18 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
390
472
  player.player?.play()
391
473
  } else {
392
474
  let image = NSImageView(); image.imageScaling = .scaleProportionallyUpOrDown; panel.contentView = image
393
- network.session.dataTask(with: request) { data, _, _ in DispatchQueue.main.async { image.image = data.flatMap(NSImage.init(data:)) } }.resume()
475
+ network.resume(network.session.dataTask(with: request) { data, _, _ in DispatchQueue.main.async { image.image = data.flatMap(NSImage.init(data:)) } })
394
476
  }
395
477
  panel.center(); panel.makeKeyAndOrderFront(nil)
396
478
  }
397
479
  private func export(_ options: [String: Any], reply: @escaping (Any) -> Void) {
398
480
  let panel = NSSavePanel(); panel.nameFieldStringValue = options["name"] as? String ?? "Attachment"
399
481
  guard panel.runModal() == .OK, let destination = panel.url, let request = network.request(["url": options["url"] ?? ""]) else { reply(false); return }
400
- network.session.downloadTask(with: request) { location, response, error in
482
+ network.resume(network.session.downloadTask(with: request) { location, response, error in
401
483
  var saved = false
402
484
  if error == nil, (response as? HTTPURLResponse)?.statusCode == 200, let location { do { if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) }; try FileManager.default.moveItem(at: location, to: destination); saved = true } catch {} }
403
485
  DispatchQueue.main.async { reply(saved) }
404
- }.resume()
486
+ })
405
487
  }
406
488
  func notification(_ method: String, options: [String: Any], reply: @escaping (Any) -> Void) {
407
489
  guard method == "schedule" else { reply(false); return }
@@ -554,7 +636,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
554
636
  return node
555
637
  }
556
638
  func classes(_ view: NSView) -> [String] { [String(describing: type(of: view))] + view.subviews.flatMap(classes) }
557
- 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), "viewportWidth": Double(scene.reportedViewportWidth), "errors": scene.runtime.errors]
639
+ return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: ZigJSContext.self), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "viewportWidth": Double(scene.reportedViewportWidth), "errors": scene.runtime.errors]
558
640
  }
559
641
  let webViews = windows.reduce(0) { count, window in count + ((window["viewClasses"] as? [String] ?? []).filter { $0.contains("WKWebView") || $0 == "WebView" }.count) }
560
642
  return ["renderer": "appkit", "scriptEngine": "zig-js", "appearance": CraftMacColor.isDark ? "dark" : "light", "windows": windows, "webViews": webViews, "menu": testMenu]
@@ -563,6 +645,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
563
645
 
564
646
  @main struct CraftMacMain {
565
647
  static func main() throws {
648
+ CraftTrace.mark("main")
566
649
  // A bundled app is launched by Finder with no arguments: its settings are
567
650
  // `Contents/Resources/craft-app.json`, written by the build. A flag on the
568
651
  // command line still wins, so a development run can point elsewhere.
@@ -595,6 +678,8 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
595
678
  application.network = CraftMacNetwork(base: api)
596
679
  let testDirectory = argument("--test-dir")
597
680
  if !testDirectory.isEmpty { application.testDirectory = URL(fileURLWithPath: testDirectory, isDirectory: true) }
681
+ if application.requiresSession { application.authorize() }
682
+ application.prepareMainScreen()
598
683
  let app = NSApplication.shared
599
684
  app.setActivationPolicy(.regular); app.delegate = application; app.run()
600
685
  }