craft-native 0.0.276 → 0.0.290
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/android/src/emulator.d.ts +4 -0
- package/dist/android/src/index.js +15 -6
- package/dist/android/templates/CraftNativeMutation.kt.template +35 -0
- package/dist/android/templates/CraftNativeShell.kt.template +12 -5
- package/dist/android/templates/CraftSnapshotFile.kt.template +26 -0
- package/dist/android/templates/MainActivityNative.kt.template +16 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/mobile.d.ts +25 -0
- package/dist/browser/storage.d.ts +5 -3
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +31 -7
- package/dist/cli.js +131 -28
- package/dist/index.cjs +177 -27
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +181 -28
- package/dist/ios/src/index.js +7 -3
- package/dist/ios/templates/CraftApp.swift +44 -5
- package/dist/ios/templates/CraftHybrid.swift +21 -0
- package/dist/ios/templates/CraftNativeActions.swift +6 -0
- package/dist/ios/templates/CraftNativeFiles.swift +136 -0
- package/dist/ios/templates/CraftNativeMutation.swift +51 -0
- package/dist/ios/templates/CraftNativeScreen.swift +116 -8
- package/dist/ios/templates/CraftWatchApp.swift.template +303 -9
- package/dist/ios/templates/WatchExtension.Info.plist.template +1 -0
- package/dist/macos/templates/CraftNativeMutation.swift +51 -0
- package/dist/mobile.js +50 -0
- package/dist/package.d.ts +29 -3
- package/dist/types.d.ts +32 -0
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
|
+
import HealthKit
|
|
2
3
|
import WatchConnectivity
|
|
3
4
|
|
|
4
5
|
@main
|
|
@@ -15,14 +16,28 @@ struct {{APP_NAME}}WatchApp: App {
|
|
|
15
16
|
|
|
16
17
|
@MainActor
|
|
17
18
|
final class CraftWatchSession: NSObject, ObservableObject {
|
|
19
|
+
private static let protocolVersion = 1
|
|
20
|
+
private static let persistedStateKey = "craft.watch.state.v1"
|
|
21
|
+
private static let healthKitEnabled = {{HEALTH_KIT_ENABLED}}
|
|
22
|
+
|
|
18
23
|
@Published private(set) var isPhoneReachable = false
|
|
19
24
|
@Published private(set) var isRecording = false
|
|
20
25
|
@Published private(set) var isPaused = false
|
|
21
26
|
@Published private(set) var distanceMeters = 0.0
|
|
22
27
|
@Published private(set) var durationSeconds = 0.0
|
|
23
28
|
@Published private(set) var status = "Ready"
|
|
29
|
+
@Published private(set) var plannedSession: [String: Any]?
|
|
30
|
+
@Published private(set) var heartRateBpm: Double?
|
|
31
|
+
@Published private(set) var heartRateZone: Int?
|
|
32
|
+
@Published private(set) var lastWorkout: [String: Any]?
|
|
24
33
|
|
|
25
34
|
private let session: WCSession?
|
|
35
|
+
private let healthStore = HKHealthStore()
|
|
36
|
+
private var stateVersion = 0
|
|
37
|
+
private var workoutSession: HKWorkoutSession?
|
|
38
|
+
private var workoutBuilder: HKLiveWorkoutBuilder?
|
|
39
|
+
private var workoutId: String?
|
|
40
|
+
private var workoutStartedAt: Date?
|
|
26
41
|
|
|
27
42
|
override init() {
|
|
28
43
|
if WCSession.isSupported() {
|
|
@@ -32,49 +47,320 @@ final class CraftWatchSession: NSObject, ObservableObject {
|
|
|
32
47
|
}
|
|
33
48
|
super.init()
|
|
34
49
|
session?.delegate = self
|
|
50
|
+
restoreState()
|
|
35
51
|
session?.activate()
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
func toggleRecording() {
|
|
39
|
-
let action: String
|
|
40
55
|
if !isRecording {
|
|
41
|
-
|
|
56
|
+
if Self.healthKitEnabled {
|
|
57
|
+
startHealthWorkout { [weak self] started in
|
|
58
|
+
if started { self?.send(action: "start") }
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
send(action: "start")
|
|
62
|
+
}
|
|
42
63
|
} else if isPaused {
|
|
43
|
-
|
|
64
|
+
resumeHealthWorkout()
|
|
65
|
+
send(action: "resume")
|
|
44
66
|
} else {
|
|
45
|
-
|
|
67
|
+
pauseHealthWorkout()
|
|
68
|
+
send(action: "pause")
|
|
46
69
|
}
|
|
47
|
-
send(action: action)
|
|
48
70
|
}
|
|
49
71
|
|
|
50
72
|
func finishRecording() {
|
|
51
|
-
|
|
73
|
+
if Self.healthKitEnabled, workoutSession != nil {
|
|
74
|
+
endHealthWorkout { [weak self] _ in self?.send(action: "finish") }
|
|
75
|
+
} else {
|
|
76
|
+
send(action: "finish")
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// The phone sends a planned session through application context. Context
|
|
81
|
+
/// is the right transport here: it replaces stale plans while the watch is
|
|
82
|
+
/// offline and is replayed when the app launches again.
|
|
83
|
+
func clearPlannedSession() {
|
|
84
|
+
plannedSession = nil
|
|
85
|
+
persistState()
|
|
86
|
+
publishState()
|
|
52
87
|
}
|
|
53
88
|
|
|
54
89
|
private func send(action: String) {
|
|
55
90
|
guard let session, session.activationState == .activated else {
|
|
56
91
|
status = "Open WildLoop on iPhone"
|
|
92
|
+
persistState()
|
|
57
93
|
return
|
|
58
94
|
}
|
|
59
|
-
let message: [String: Any] = [
|
|
95
|
+
let message: [String: Any] = [
|
|
96
|
+
"protocolVersion": Self.protocolVersion,
|
|
97
|
+
"requestId": UUID().uuidString,
|
|
98
|
+
"type": "recording-control",
|
|
99
|
+
"action": action,
|
|
100
|
+
]
|
|
60
101
|
if session.isReachable {
|
|
61
102
|
session.sendMessage(message, replyHandler: { [weak self] reply in
|
|
62
103
|
Task { @MainActor in self?.apply(reply) }
|
|
63
104
|
}, errorHandler: { [weak self] _ in
|
|
64
|
-
Task { @MainActor in
|
|
105
|
+
Task { @MainActor in
|
|
106
|
+
self?.status = "Could not reach iPhone"
|
|
107
|
+
self?.persistState()
|
|
108
|
+
self?.publishState()
|
|
109
|
+
}
|
|
65
110
|
})
|
|
66
111
|
} else {
|
|
67
112
|
session.transferUserInfo(message)
|
|
68
113
|
status = "Command queued"
|
|
114
|
+
persistState()
|
|
115
|
+
publishState()
|
|
69
116
|
}
|
|
70
117
|
}
|
|
71
118
|
|
|
72
119
|
private func apply(_ payload: [String: Any]) {
|
|
120
|
+
if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" {
|
|
121
|
+
plannedSession = plan
|
|
122
|
+
status = "Plan ready"
|
|
123
|
+
} else if let plan = payload["plannedSession"] as? [String: Any] {
|
|
124
|
+
plannedSession = plan
|
|
125
|
+
}
|
|
73
126
|
if let recording = payload["recording"] as? Bool { isRecording = recording }
|
|
74
127
|
if let paused = payload["paused"] as? Bool { isPaused = paused }
|
|
75
128
|
if let distance = payload["distanceMeters"] as? Double { distanceMeters = max(distance, 0) }
|
|
76
129
|
if let duration = payload["durationSeconds"] as? Double { durationSeconds = max(duration, 0) }
|
|
77
130
|
if let value = payload["status"] as? String { status = value }
|
|
131
|
+
if let heartRate = payload["heartRateBpm"] as? Double, heartRate.isFinite { heartRateBpm = max(heartRate, 0) }
|
|
132
|
+
if let zone = payload["heartRateZone"] as? Int { heartRateZone = min(max(zone, 1), 5) }
|
|
133
|
+
if let workout = payload["workout"] as? [String: Any] { lastWorkout = workout }
|
|
134
|
+
persistState()
|
|
135
|
+
publishState()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private func restoreState() {
|
|
139
|
+
guard let state = UserDefaults.standard.dictionary(forKey: Self.persistedStateKey) else { return }
|
|
140
|
+
if let recording = state["recording"] as? Bool { isRecording = recording }
|
|
141
|
+
if let paused = state["paused"] as? Bool { isPaused = paused }
|
|
142
|
+
if let distance = state["distanceMeters"] as? Double { distanceMeters = max(distance, 0) }
|
|
143
|
+
if let duration = state["durationSeconds"] as? Double { durationSeconds = max(duration, 0) }
|
|
144
|
+
if let value = state["status"] as? String { status = value }
|
|
145
|
+
if let version = state["stateVersion"] as? Int { stateVersion = max(version, stateVersion) }
|
|
146
|
+
plannedSession = state["plannedSession"] as? [String: Any]
|
|
147
|
+
if let heartRate = state["heartRateBpm"] as? Double { heartRateBpm = max(heartRate, 0) }
|
|
148
|
+
if let zone = state["heartRateZone"] as? Int { heartRateZone = min(max(zone, 1), 5) }
|
|
149
|
+
lastWorkout = state["lastWorkout"] as? [String: Any]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private func persistState() {
|
|
153
|
+
stateVersion += 1
|
|
154
|
+
var state: [String: Any] = [
|
|
155
|
+
"protocolVersion": Self.protocolVersion,
|
|
156
|
+
"stateVersion": stateVersion,
|
|
157
|
+
"recording": isRecording,
|
|
158
|
+
"paused": isPaused,
|
|
159
|
+
"distanceMeters": distanceMeters,
|
|
160
|
+
"durationSeconds": durationSeconds,
|
|
161
|
+
"status": status,
|
|
162
|
+
]
|
|
163
|
+
if let plannedSession { state["plannedSession"] = plannedSession }
|
|
164
|
+
if let heartRateBpm { state["heartRateBpm"] = heartRateBpm }
|
|
165
|
+
if let heartRateZone { state["heartRateZone"] = heartRateZone }
|
|
166
|
+
if let lastWorkout { state["lastWorkout"] = lastWorkout }
|
|
167
|
+
UserDefaults.standard.set(state, forKey: Self.persistedStateKey)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private func publishState() {
|
|
171
|
+
guard let session, session.activationState == .activated else { return }
|
|
172
|
+
var state: [String: Any] = [
|
|
173
|
+
"protocolVersion": Self.protocolVersion,
|
|
174
|
+
"stateVersion": stateVersion,
|
|
175
|
+
"type": "watch-state",
|
|
176
|
+
"recording": isRecording,
|
|
177
|
+
"paused": isPaused,
|
|
178
|
+
"distanceMeters": distanceMeters,
|
|
179
|
+
"durationSeconds": durationSeconds,
|
|
180
|
+
"status": status,
|
|
181
|
+
]
|
|
182
|
+
if let plannedSession { state["plannedSession"] = plannedSession }
|
|
183
|
+
if let heartRateBpm { state["heartRateBpm"] = heartRateBpm }
|
|
184
|
+
if let heartRateZone { state["heartRateZone"] = heartRateZone }
|
|
185
|
+
if let lastWorkout { state["workout"] = lastWorkout }
|
|
186
|
+
try? session.updateApplicationContext(state)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private func startHealthWorkout(completion: @escaping (Bool) -> Void) {
|
|
190
|
+
guard HKHealthStore.isHealthDataAvailable() else {
|
|
191
|
+
status = "HealthKit unavailable"
|
|
192
|
+
persistState()
|
|
193
|
+
completion(false)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
let shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()]
|
|
197
|
+
var readTypes: Set<HKObjectType> = [HKObjectType.workoutType()]
|
|
198
|
+
if let heartRate = HKQuantityType.quantityType(forIdentifier: .heartRate) { readTypes.insert(heartRate) }
|
|
199
|
+
if let distance = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning) { readTypes.insert(distance) }
|
|
200
|
+
if let energy = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) { readTypes.insert(energy) }
|
|
201
|
+
healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] success, error in
|
|
202
|
+
DispatchQueue.main.async {
|
|
203
|
+
guard let self else { return completion(false) }
|
|
204
|
+
guard success else {
|
|
205
|
+
self.status = error?.localizedDescription ?? "HealthKit authorization denied"
|
|
206
|
+
self.persistState()
|
|
207
|
+
return completion(false)
|
|
208
|
+
}
|
|
209
|
+
self.beginHealthWorkout(completion: completion)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private func beginHealthWorkout(completion: @escaping (Bool) -> Void) {
|
|
215
|
+
guard workoutSession == nil else { completion(true); return }
|
|
216
|
+
let configuration = HKWorkoutConfiguration()
|
|
217
|
+
configuration.activityType = .running
|
|
218
|
+
configuration.locationType = .outdoor
|
|
219
|
+
do {
|
|
220
|
+
let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
|
|
221
|
+
let builder = session.associatedWorkoutBuilder()
|
|
222
|
+
session.delegate = self
|
|
223
|
+
builder.delegate = self
|
|
224
|
+
builder.shouldCollectWorkoutEvents = true
|
|
225
|
+
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
|
|
226
|
+
workoutSession = session
|
|
227
|
+
workoutBuilder = builder
|
|
228
|
+
workoutId = UUID().uuidString.lowercased()
|
|
229
|
+
let start = Date()
|
|
230
|
+
workoutStartedAt = start
|
|
231
|
+
session.startActivity(with: start)
|
|
232
|
+
builder.beginCollection(withStart: start) { [weak self] success, error in
|
|
233
|
+
DispatchQueue.main.async {
|
|
234
|
+
guard let self else { return completion(false) }
|
|
235
|
+
guard success else {
|
|
236
|
+
self.clearHealthWorkout()
|
|
237
|
+
self.status = error?.localizedDescription ?? "Workout could not start"
|
|
238
|
+
self.persistState()
|
|
239
|
+
return completion(false)
|
|
240
|
+
}
|
|
241
|
+
self.isRecording = true
|
|
242
|
+
self.isPaused = false
|
|
243
|
+
self.status = "Recording"
|
|
244
|
+
self.persistState()
|
|
245
|
+
self.publishState()
|
|
246
|
+
completion(true)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
status = error.localizedDescription
|
|
251
|
+
persistState()
|
|
252
|
+
completion(false)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private func pauseHealthWorkout() {
|
|
257
|
+
guard let workoutSession else { return }
|
|
258
|
+
workoutSession.pause()
|
|
259
|
+
isPaused = true
|
|
260
|
+
status = "Paused"
|
|
261
|
+
persistState()
|
|
262
|
+
publishState()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private func resumeHealthWorkout() {
|
|
266
|
+
guard let workoutSession else { return }
|
|
267
|
+
workoutSession.resume()
|
|
268
|
+
isPaused = false
|
|
269
|
+
status = "Recording"
|
|
270
|
+
persistState()
|
|
271
|
+
publishState()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private func endHealthWorkout(completion: @escaping ([String: Any]?) -> Void) {
|
|
275
|
+
guard let workoutSession, let workoutBuilder, let id = workoutId else {
|
|
276
|
+
completion(nil)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
let end = Date()
|
|
280
|
+
workoutSession.end()
|
|
281
|
+
workoutBuilder.endCollection(withEnd: end) { [weak self] success, error in
|
|
282
|
+
guard let self else { return }
|
|
283
|
+
guard success else {
|
|
284
|
+
DispatchQueue.main.async {
|
|
285
|
+
self.status = error?.localizedDescription ?? "Workout could not finish"
|
|
286
|
+
self.persistState()
|
|
287
|
+
completion(nil)
|
|
288
|
+
}
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
workoutBuilder.finishWorkout { [weak self] workout, finishError in
|
|
292
|
+
DispatchQueue.main.async {
|
|
293
|
+
guard let self else { return completion(nil) }
|
|
294
|
+
guard let workout else {
|
|
295
|
+
self.status = finishError?.localizedDescription ?? "Workout could not be saved"
|
|
296
|
+
self.persistState()
|
|
297
|
+
return completion(nil)
|
|
298
|
+
}
|
|
299
|
+
let result: [String: Any] = [
|
|
300
|
+
"id": id,
|
|
301
|
+
"workoutId": workout.uuid.uuidString,
|
|
302
|
+
"startDate": (self.workoutStartedAt ?? workout.startDate).timeIntervalSince1970 * 1000,
|
|
303
|
+
"endDate": end.timeIntervalSince1970 * 1000,
|
|
304
|
+
"durationSeconds": workout.duration,
|
|
305
|
+
"distanceMeters": self.distanceMeters,
|
|
306
|
+
]
|
|
307
|
+
self.lastWorkout = result
|
|
308
|
+
self.isRecording = false
|
|
309
|
+
self.isPaused = false
|
|
310
|
+
self.status = "Saved to Apple Health"
|
|
311
|
+
self.clearHealthWorkout()
|
|
312
|
+
self.persistState()
|
|
313
|
+
self.publishState()
|
|
314
|
+
completion(result)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private func clearHealthWorkout() {
|
|
321
|
+
workoutSession = nil
|
|
322
|
+
workoutBuilder = nil
|
|
323
|
+
workoutId = nil
|
|
324
|
+
workoutStartedAt = nil
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private func updateHealthMetrics(_ builder: HKLiveWorkoutBuilder) {
|
|
328
|
+
if let heartRate = HKQuantityType.quantityType(forIdentifier: .heartRate),
|
|
329
|
+
let value = builder.statistics(for: heartRate)?.mostRecentQuantity()?.doubleValue(for: HKUnit.count().unitDivided(by: .minute())),
|
|
330
|
+
value.isFinite {
|
|
331
|
+
heartRateBpm = max(value, 0)
|
|
332
|
+
let maximum = (plannedSession?["maxHeartRateBpm"] as? Double) ?? 190
|
|
333
|
+
heartRateZone = min(max(Int(ceil(value / maximum * 5)), 1), 5)
|
|
334
|
+
}
|
|
335
|
+
if let distance = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning),
|
|
336
|
+
let value = builder.statistics(for: distance)?.sumQuantity()?.doubleValue(for: .meter()),
|
|
337
|
+
value.isFinite {
|
|
338
|
+
distanceMeters = max(value, 0)
|
|
339
|
+
}
|
|
340
|
+
if let start = workoutStartedAt { durationSeconds = max(Date().timeIntervalSince(start), 0) }
|
|
341
|
+
persistState()
|
|
342
|
+
publishState()
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
extension CraftWatchSession: HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate {
|
|
347
|
+
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {}
|
|
348
|
+
|
|
349
|
+
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
|
|
350
|
+
Task { @MainActor in
|
|
351
|
+
status = error.localizedDescription
|
|
352
|
+
isRecording = false
|
|
353
|
+
isPaused = false
|
|
354
|
+
clearHealthWorkout()
|
|
355
|
+
persistState()
|
|
356
|
+
publishState()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
|
|
361
|
+
|
|
362
|
+
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf types: Set<HKSampleType>) {
|
|
363
|
+
Task { @MainActor in updateHealthMetrics(workoutBuilder) }
|
|
78
364
|
}
|
|
79
365
|
}
|
|
80
366
|
|
|
@@ -84,11 +370,15 @@ extension CraftWatchSession: WCSessionDelegate {
|
|
|
84
370
|
isPhoneReachable = session.isReachable
|
|
85
371
|
status = error == nil ? "Ready" : "Connection unavailable"
|
|
86
372
|
apply(session.receivedApplicationContext)
|
|
373
|
+
publishState()
|
|
87
374
|
}
|
|
88
375
|
}
|
|
89
376
|
|
|
90
377
|
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
|
91
|
-
Task { @MainActor in
|
|
378
|
+
Task { @MainActor in
|
|
379
|
+
isPhoneReachable = session.isReachable
|
|
380
|
+
if session.isReachable { publishState() }
|
|
381
|
+
}
|
|
92
382
|
}
|
|
93
383
|
|
|
94
384
|
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
|
@@ -129,6 +419,10 @@ struct CraftWatchRecordingView: View {
|
|
|
129
419
|
metric(distance, label: "Distance")
|
|
130
420
|
metric(duration, label: "Time")
|
|
131
421
|
}
|
|
422
|
+
if let heartRate = session.heartRateBpm {
|
|
423
|
+
metric(String(format: "%.0f bpm", heartRate), label: "Heart rate")
|
|
424
|
+
if let zone = session.heartRateZone { Text("Zone \(zone) of 5").font(.caption2).foregroundStyle(.secondary) }
|
|
425
|
+
}
|
|
132
426
|
Button(session.isRecording && !session.isPaused ? "Pause" : session.isPaused ? "Resume" : "Start") {
|
|
133
427
|
session.toggleRecording()
|
|
134
428
|
}
|
|
@@ -115,6 +115,9 @@ final class CraftNativeMutationDocument {
|
|
|
115
115
|
// A child crossing parents needs the committed tree to be
|
|
116
116
|
// reconciled so the host can move its existing native view.
|
|
117
117
|
requiresFullRender = true
|
|
118
|
+
case "replaceChildren":
|
|
119
|
+
let parentId = try replaceChildren(operation, nodes: &candidateNodes)
|
|
120
|
+
appendUnique(parentId, to: &affectedNodeIds)
|
|
118
121
|
case "removeNode":
|
|
119
122
|
if let id = operation["id"] as? String, let parent = candidateNodes[id]?.parent {
|
|
120
123
|
appendUnique(parent, to: &affectedNodeIds)
|
|
@@ -287,6 +290,54 @@ final class CraftNativeMutationDocument {
|
|
|
287
290
|
return previousParentId
|
|
288
291
|
}
|
|
289
292
|
|
|
293
|
+
/// Replaces a container's ordered child snapshot in one atomic mutation.
|
|
294
|
+
///
|
|
295
|
+
/// The list protocol uses this operation when a keyed `:for` expansion
|
|
296
|
+
/// produces a new row set. Existing nodes may stay attached to the same
|
|
297
|
+
/// parent; removed children become detached and can be reused or removed
|
|
298
|
+
/// by later operations in the same batch.
|
|
299
|
+
private func replaceChildren(_ operation: [String: Any], nodes: inout [String: Node]) throws -> String {
|
|
300
|
+
let parentId = try requiredId(operation, field: "parentId", name: "replaceChildren")
|
|
301
|
+
guard var parent = nodes[parentId] else {
|
|
302
|
+
throw CraftNativeMutationFailure("UNKNOWN_PARENT", "node \(parentId) does not exist")
|
|
303
|
+
}
|
|
304
|
+
guard acceptsChildren(parent.type) else {
|
|
305
|
+
throw CraftNativeMutationFailure("INVALID_PARENT", "node \(parentId) cannot contain child nodes")
|
|
306
|
+
}
|
|
307
|
+
guard let values = operation["children"] as? [Any] else {
|
|
308
|
+
throw CraftNativeMutationFailure("INVALID_CHILDREN", "replaceChildren.children must be an array")
|
|
309
|
+
}
|
|
310
|
+
var childIds: [String] = []
|
|
311
|
+
for value in values {
|
|
312
|
+
guard let childId = value as? String, !childId.isEmpty else {
|
|
313
|
+
throw CraftNativeMutationFailure("INVALID_CHILDREN", "replaceChildren.children must contain non-empty ids")
|
|
314
|
+
}
|
|
315
|
+
guard !childIds.contains(childId) else {
|
|
316
|
+
throw CraftNativeMutationFailure("DUPLICATE_CHILD", "child \(childId) appears more than once")
|
|
317
|
+
}
|
|
318
|
+
guard let child = nodes[childId] else {
|
|
319
|
+
throw CraftNativeMutationFailure("UNKNOWN_NODE", "node \(childId) does not exist")
|
|
320
|
+
}
|
|
321
|
+
guard childId != parentId, !descendants(of: childId, nodes: nodes).contains(parentId) else {
|
|
322
|
+
throw CraftNativeMutationFailure("CYCLE", "attaching node \(childId) would create a cycle")
|
|
323
|
+
}
|
|
324
|
+
guard child.parent == nil || child.parent == parentId else {
|
|
325
|
+
throw CraftNativeMutationFailure("NODE_ATTACHED", "node \(childId) already has a parent")
|
|
326
|
+
}
|
|
327
|
+
childIds.append(childId)
|
|
328
|
+
}
|
|
329
|
+
let removedChildIds = parent.children.filter { !childIds.contains($0) }
|
|
330
|
+
for oldChildId in removedChildIds {
|
|
331
|
+
for descendant in descendants(of: oldChildId, nodes: nodes).union([oldChildId]) {
|
|
332
|
+
nodes.removeValue(forKey: descendant)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
parent.children = childIds
|
|
336
|
+
nodes[parentId] = parent
|
|
337
|
+
for childId in childIds { nodes[childId]?.parent = parentId }
|
|
338
|
+
return parentId
|
|
339
|
+
}
|
|
340
|
+
|
|
290
341
|
private func removeNode(
|
|
291
342
|
_ operation: [String: Any],
|
|
292
343
|
nodes: inout [String: Node],
|
package/dist/mobile.js
CHANGED
|
@@ -223,6 +223,55 @@ var permissions = {
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
};
|
|
226
|
+
var files = {
|
|
227
|
+
async pickFile(options = {}) {
|
|
228
|
+
const craft = getCraftMobile();
|
|
229
|
+
if (typeof window !== "undefined" && craft?.files) {
|
|
230
|
+
return craft.files.pickFile(options);
|
|
231
|
+
}
|
|
232
|
+
if (typeof document === "undefined") {
|
|
233
|
+
throw new Error("File picker not available. Must run in Craft or a browser.");
|
|
234
|
+
}
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
const input = document.createElement("input");
|
|
237
|
+
input.type = "file";
|
|
238
|
+
if (options.contentTypes?.length)
|
|
239
|
+
input.accept = options.contentTypes.join(",");
|
|
240
|
+
const requested = Number(options.maxSelections);
|
|
241
|
+
const maxSelections = Number.isFinite(requested) && requested > 0 ? Math.min(20, Math.floor(requested)) : 1;
|
|
242
|
+
input.multiple = maxSelections > 1;
|
|
243
|
+
input.style.display = "none";
|
|
244
|
+
let settled = false;
|
|
245
|
+
const finish = (result) => {
|
|
246
|
+
if (settled)
|
|
247
|
+
return;
|
|
248
|
+
settled = true;
|
|
249
|
+
input.remove();
|
|
250
|
+
result();
|
|
251
|
+
};
|
|
252
|
+
input.addEventListener("cancel", () => finish(() => resolve([])), { once: true });
|
|
253
|
+
input.onchange = () => {
|
|
254
|
+
const chosen = Array.from(input.files ?? []).slice(0, maxSelections);
|
|
255
|
+
const limit = Number(options.maxBytes);
|
|
256
|
+
if (Number.isFinite(limit) && limit > 0) {
|
|
257
|
+
const oversized = chosen.find((file) => file.size > limit);
|
|
258
|
+
if (oversized) {
|
|
259
|
+
finish(() => reject(Object.assign(new Error("The selected file is too large"), { code: "FILE_TOO_LARGE" })));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
finish(() => resolve(chosen.map((file) => ({
|
|
264
|
+
path: URL.createObjectURL(file),
|
|
265
|
+
name: file.name,
|
|
266
|
+
type: file.type || "application/octet-stream",
|
|
267
|
+
size: file.size
|
|
268
|
+
}))));
|
|
269
|
+
};
|
|
270
|
+
document.body.append(input);
|
|
271
|
+
input.click();
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
};
|
|
226
275
|
var camera = {
|
|
227
276
|
async takePicture(options = {}) {
|
|
228
277
|
const craft = getCraftMobile();
|
|
@@ -1877,6 +1926,7 @@ export {
|
|
|
1877
1926
|
deepLinks,
|
|
1878
1927
|
device,
|
|
1879
1928
|
dialog,
|
|
1929
|
+
files,
|
|
1880
1930
|
haptics,
|
|
1881
1931
|
health,
|
|
1882
1932
|
heartRate,
|
package/dist/package.d.ts
CHANGED
|
@@ -29,6 +29,16 @@ export interface PackageConfig {
|
|
|
29
29
|
platforms?: Array<'macos' | 'windows' | 'linux'>;
|
|
30
30
|
/** macOS-specific options */
|
|
31
31
|
macos?: {
|
|
32
|
+
/** Preserve a prebuilt .app, including its resources and Info.plist. */
|
|
33
|
+
appBundlePath?: string;
|
|
34
|
+
/** Use existing notarytool credentials stored in the macOS keychain. */
|
|
35
|
+
notaryKeychainProfile?: string;
|
|
36
|
+
/** App Store Connect API-key authentication for notarization. */
|
|
37
|
+
notaryApiKey?: {
|
|
38
|
+
path: string;
|
|
39
|
+
keyId: string;
|
|
40
|
+
issuer: string;
|
|
41
|
+
};
|
|
32
42
|
/** Create DMG installer */
|
|
33
43
|
dmg?: boolean;
|
|
34
44
|
/** Create PKG installer */
|
|
@@ -46,6 +56,12 @@ export interface PackageConfig {
|
|
|
46
56
|
installerIdentity?: string;
|
|
47
57
|
/** Path to the entitlements plist applied when signing the app */
|
|
48
58
|
entitlements?: string;
|
|
59
|
+
/** Entitlements for bundled helper executables, separate from the main app. */
|
|
60
|
+
helperEntitlements?: string;
|
|
61
|
+
/** Keychain containing the application and installer signing identities. */
|
|
62
|
+
signingKeychain?: string;
|
|
63
|
+
/** Privacy manifest copied to `Contents/Resources/PrivacyInfo.xcprivacy`. */
|
|
64
|
+
privacyManifest?: string;
|
|
49
65
|
/** Path to a `.provisionprofile` embedded as `Contents/embedded.provisionprofile` */
|
|
50
66
|
provisioningProfile?: string;
|
|
51
67
|
/** Build number (`CFBundleVersion`). Defaults to `version`. */
|
|
@@ -198,19 +214,29 @@ export declare function codesignArguments(opts: {
|
|
|
198
214
|
identity: string;
|
|
199
215
|
entitlements?: string;
|
|
200
216
|
hardenedRuntime?: boolean;
|
|
217
|
+
deep?: boolean;
|
|
218
|
+
identifier?: string;
|
|
219
|
+
keychain?: string;
|
|
201
220
|
}): string[];
|
|
202
221
|
/** Build the `productbuild` argument list for a Mac App Store submission package. */
|
|
203
222
|
export declare function productbuildArguments(opts: {
|
|
204
223
|
appBundlePath: string;
|
|
205
224
|
outputPath: string;
|
|
206
225
|
identity: string;
|
|
226
|
+
keychain?: string;
|
|
207
227
|
}): string[];
|
|
208
228
|
/** Build the `xcrun notarytool submit` argument list for a DMG or PKG. */
|
|
209
229
|
export declare function notarytoolArguments(opts: {
|
|
210
230
|
artifactPath: string;
|
|
211
|
-
appleId
|
|
212
|
-
applePassword
|
|
213
|
-
teamId
|
|
231
|
+
appleId?: string;
|
|
232
|
+
applePassword?: string;
|
|
233
|
+
teamId?: string;
|
|
234
|
+
keychainProfile?: string;
|
|
235
|
+
apiKey?: {
|
|
236
|
+
path: string;
|
|
237
|
+
keyId: string;
|
|
238
|
+
issuer: string;
|
|
239
|
+
};
|
|
214
240
|
}): string[];
|
|
215
241
|
export declare function dmgCapacityMegabytes(contentBytes: number): number;
|
|
216
242
|
export declare function dmgCreateArguments(opts: {
|
package/dist/types.d.ts
CHANGED
|
@@ -569,6 +569,8 @@ export interface AppConfig {
|
|
|
569
569
|
* Craft System Tray API (available as window.craft.tray in WebView)
|
|
570
570
|
*/
|
|
571
571
|
export interface CraftTrayAPI {
|
|
572
|
+
/** SF Symbol name, image path, or base64 image data URL. */
|
|
573
|
+
setIcon(icon: string): Promise<void>;
|
|
572
574
|
/**
|
|
573
575
|
* Update the system tray title/text
|
|
574
576
|
* Updates in real-time (60fps max)
|
|
@@ -723,6 +725,11 @@ export interface AppInfo {
|
|
|
723
725
|
* Platform (macos, linux, windows)
|
|
724
726
|
*/
|
|
725
727
|
platform: string;
|
|
728
|
+
/** Whether platform file access is restricted to explicit user grants. */
|
|
729
|
+
sandboxed?: boolean;
|
|
730
|
+
bundleId?: string;
|
|
731
|
+
bundlePath?: string;
|
|
732
|
+
executablePath?: string;
|
|
726
733
|
}
|
|
727
734
|
/**
|
|
728
735
|
* Notification options
|
|
@@ -1163,6 +1170,23 @@ export interface CraftBridgeAPI {
|
|
|
1163
1170
|
* System tray control (desktop only)
|
|
1164
1171
|
*/
|
|
1165
1172
|
tray?: CraftTrayAPI;
|
|
1173
|
+
/** Native status-item arrangement and collapse controls (macOS). */
|
|
1174
|
+
menubar?: {
|
|
1175
|
+
init(): Promise<void>;
|
|
1176
|
+
collapse(): Promise<void>;
|
|
1177
|
+
expand(): Promise<void>;
|
|
1178
|
+
toggle(): Promise<void>;
|
|
1179
|
+
getState(): Promise<{
|
|
1180
|
+
collapsed: boolean;
|
|
1181
|
+
initialized: boolean;
|
|
1182
|
+
separatorHidden: boolean;
|
|
1183
|
+
}>;
|
|
1184
|
+
setAutoCollapse(seconds: number): Promise<void>;
|
|
1185
|
+
setSeparatorHidden(hidden: boolean): Promise<void>;
|
|
1186
|
+
onStateChange(callback: (state: {
|
|
1187
|
+
collapsed: boolean;
|
|
1188
|
+
}) => void): () => void;
|
|
1189
|
+
};
|
|
1166
1190
|
/**
|
|
1167
1191
|
* Window control (desktop)
|
|
1168
1192
|
*/
|
|
@@ -1207,6 +1231,7 @@ export interface CraftBridgeAPI {
|
|
|
1207
1231
|
/**
|
|
1208
1232
|
* Screen-sharing and screen-recording detection (macOS)
|
|
1209
1233
|
*/
|
|
1234
|
+
timers?: CraftTimersAPI;
|
|
1210
1235
|
screenSharing?: CraftScreenSharingAPI;
|
|
1211
1236
|
/**
|
|
1212
1237
|
* Trigger one haptic tap on a Force Touch trackpad (macOS), as
|
|
@@ -2326,3 +2351,10 @@ declare global {
|
|
|
2326
2351
|
craft?: CraftBridgeAPI & CraftEventEmitter;
|
|
2327
2352
|
}
|
|
2328
2353
|
}
|
|
2354
|
+
/** Native background deadlines, independent of hidden WebKit DOM timers. */
|
|
2355
|
+
export interface CraftTimersAPI {
|
|
2356
|
+
/** Wait up to one day. Aborting rejects with AbortError and releases native resources. */
|
|
2357
|
+
sleep(delayMs: number, options?: {
|
|
2358
|
+
signal?: AbortSignal;
|
|
2359
|
+
}): Promise<void>;
|
|
2360
|
+
}
|