craft-native 0.0.146 → 0.0.148

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.
@@ -0,0 +1,567 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import UIKit
4
+ import UserNotifications
5
+
6
+ // Services native screens reach through `craft.speech`, `craft.audio`,
7
+ // `craft.push` and a streaming `fetch` (CraftNativeScreen.swift). Speech and
8
+ // push are the app's, shared by every screen; sounds and streams belong to the
9
+ // screen that started them and end when it closes.
10
+
11
+ // MARK: - Speech
12
+
13
+ /// `craft.speech` on a native screen: AVSpeechSynthesizer, set up the way the
14
+ /// page's own speech is (CraftApp.swift). The music ducks rather than stops,
15
+ /// the silent switch does not mute a cue, and the session is handed back once
16
+ /// nothing is left to say.
17
+ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
18
+ static let shared = CraftNativeSpeech()
19
+
20
+ private let synthesizer = AVSpeechSynthesizer()
21
+ private var pending: [(utterance: AVSpeechUtterance, done: (Bool) -> Void)] = []
22
+ private var sessionToRestore: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?
23
+
24
+ private override init() {
25
+ super.init()
26
+ synthesizer.delegate = self
27
+ }
28
+
29
+ /// Says `text`; `done` hears true once it was spoken, false when it was
30
+ /// stopped or interrupted. Main thread.
31
+ func speak(_ text: String, language: String?, rate: Double?, interrupt: Bool, done: @escaping (Bool) -> Void) {
32
+ if interrupt { cancelPending() }
33
+ let utterance = AVSpeechUtterance(string: text)
34
+ utterance.rate = Self.utteranceRate(rate ?? 1)
35
+ // An unknown language finds no voice, and a nil voice is the device's own.
36
+ if let language, !language.isEmpty, let voice = AVSpeechSynthesisVoice(language: language) {
37
+ utterance.voice = voice
38
+ }
39
+ pending.append((utterance, done))
40
+ claimSession()
41
+ synthesizer.speak(utterance)
42
+ }
43
+
44
+ func stop() {
45
+ // When something was sounding, its didCancel hands the session back
46
+ // once the audio has stopped; deactivating before that fails as busy.
47
+ if !cancelPending() { releaseSessionIfSilent() }
48
+ }
49
+
50
+ /// Settles every waiting call false. Returns whether anything was sounding.
51
+ @discardableResult
52
+ private func cancelPending() -> Bool {
53
+ let waiting = pending
54
+ pending.removeAll()
55
+ let sounding = synthesizer.isSpeaking || synthesizer.isPaused
56
+ if sounding { synthesizer.stopSpeaking(at: .immediate) }
57
+ waiting.forEach { $0.done(false) }
58
+ return sounding
59
+ }
60
+
61
+ /// The page's rate, where 1 is normal, on AVSpeechUtterance's scale: the
62
+ /// same mapping as the page's speech, so a cue sounds alike in both.
63
+ static func utteranceRate(_ rate: Double) -> Float {
64
+ let requested = Float(min(max(rate.isFinite ? rate : 1, 0.5), 2))
65
+ let normal = AVSpeechUtteranceDefaultSpeechRate
66
+ let mapped = requested <= 1
67
+ ? normal * requested
68
+ : normal + (AVSpeechUtteranceMaximumSpeechRate - normal) * (requested - 1) / 2
69
+ return min(max(mapped, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate)
70
+ }
71
+
72
+ private func claimSession() {
73
+ guard sessionToRestore == nil else { return }
74
+ let session = AVAudioSession.sharedInstance()
75
+ let previous = (session.category, session.mode, session.categoryOptions)
76
+ do {
77
+ try session.setCategory(.playback, mode: .voicePrompt, options: [.duckOthers, .interruptSpokenAudioAndMixWithOthers])
78
+ try session.setActive(true)
79
+ sessionToRestore = previous
80
+ } catch {
81
+ // Still speak, through whatever session the app has.
82
+ NSLog("[craft native] speech audio session failed: %@", error.localizedDescription)
83
+ }
84
+ }
85
+
86
+ private func releaseSessionIfSilent(retry: Bool = true) {
87
+ guard pending.isEmpty, let previous = sessionToRestore else { return }
88
+ let session = AVAudioSession.sharedInstance()
89
+ do {
90
+ try session.setActive(false, options: .notifyOthersOnDeactivation)
91
+ } catch {
92
+ // Busy: the last of the audio has not drained, or the app plays
93
+ // something of its own. Once more shortly covers the first.
94
+ if retry {
95
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.releaseSessionIfSilent(retry: false) }
96
+ return
97
+ }
98
+ }
99
+ sessionToRestore = nil
100
+ try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
101
+ CraftNativeSounds.reclaimSessionIfPlaying()
102
+ }
103
+
104
+ private func finished(_ utterance: AVSpeechUtterance, spoken: Bool) {
105
+ if let index = pending.firstIndex(where: { $0.utterance === utterance }) {
106
+ pending.remove(at: index).done(spoken)
107
+ }
108
+ releaseSessionIfSilent()
109
+ }
110
+
111
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
112
+ DispatchQueue.main.async { self.finished(utterance, spoken: true) }
113
+ }
114
+
115
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
116
+ DispatchQueue.main.async { self.finished(utterance, spoken: false) }
117
+ }
118
+ }
119
+
120
+ // MARK: - Background sound
121
+
122
+ /// `craft.audio` on a native screen: sounds that play under the screen, such
123
+ /// as rain under a breathing exercise. Each loops without a gap when asked
124
+ /// (AVPlayerLooper), fades in and out, and stops when its screen closes.
125
+ ///
126
+ /// The session is `.ambient`: it mixes with the person's music and the silent
127
+ /// switch mutes it, as a game's background sound is muted. A sound that must
128
+ /// be heard through the switch asks with `respectSilentSwitch: false`.
129
+ final class CraftNativeSounds {
130
+ private final class Sound {
131
+ let player: AVQueuePlayer
132
+ var looper: AVPlayerLooper?
133
+ var observation: NSKeyValueObservation?
134
+ var endObserver: NSObjectProtocol?
135
+ var fade: Timer?
136
+ /// The volume asked for; a fade in or out moves toward or away from it.
137
+ var volume: Float
138
+ var paused = false
139
+
140
+ init(player: AVQueuePlayer, volume: Float) {
141
+ self.player = player
142
+ self.volume = volume
143
+ }
144
+
145
+ func end() {
146
+ fade?.invalidate()
147
+ fade = nil
148
+ observation?.invalidate()
149
+ observation = nil
150
+ if let endObserver { NotificationCenter.default.removeObserver(endObserver) }
151
+ endObserver = nil
152
+ looper?.disableLooping()
153
+ looper = nil
154
+ player.pause()
155
+ player.removeAllItems()
156
+ }
157
+ }
158
+
159
+ /// Every screen's sounds, so the session is handed back when the last one
160
+ /// anywhere stops, and taken again after speech handed it back.
161
+ private static var playing = 0
162
+ private static var sessionToRestore: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?
163
+ private static var category: AVAudioSession.Category = .ambient
164
+
165
+ private var sounds: [String: Sound] = [:]
166
+ /// `{ id, event: "ended" | "error", message? }` to the screen's script.
167
+ var emit: (([String: Any]) -> Void)?
168
+
169
+ /// Starts `url`. `answer` hears once it plays (or could not be loaded).
170
+ func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
171
+ stop(id: id, fadeOut: 0)
172
+ let loop = options["loop"] as? Bool ?? false
173
+ let volume = Float(min(max((options["volume"] as? NSNumber)?.doubleValue ?? 1, 0), 1))
174
+ let fadeIn = max(0, (options["fadeIn"] as? NSNumber)?.doubleValue ?? 0) / 1000
175
+ Self.claimSession(respectSilentSwitch: options["respectSilentSwitch"] as? Bool ?? true)
176
+ let item = AVPlayerItem(url: url)
177
+ let player = AVQueuePlayer()
178
+ let sound = Sound(player: player, volume: volume)
179
+ sounds[id] = sound
180
+ var answered = false
181
+ let settle: (Result<Any, CraftNativeActionError>) -> Void = { result in
182
+ guard !answered else { return }
183
+ answered = true
184
+ answer(result)
185
+ }
186
+ let failed: (String) -> Void = { [weak self, weak sound] message in
187
+ guard let self, let sound, self.sounds[id] === sound else { return }
188
+ self.remove(id)
189
+ settle(.failure(CraftNativeActionError(code: "AUDIO_ERROR", message: message)))
190
+ self.emit?(["id": id, "event": "error", "message": message])
191
+ }
192
+ if loop {
193
+ let looper = AVPlayerLooper(player: player, templateItem: item)
194
+ sound.looper = looper
195
+ sound.observation = looper.observe(\.status, options: [.initial, .new]) { looper, _ in
196
+ DispatchQueue.main.async {
197
+ if looper.status == .ready { settle(.success(true)) }
198
+ else if looper.status == .failed { failed(looper.error?.localizedDescription ?? "The sound could not be played") }
199
+ }
200
+ }
201
+ } else {
202
+ player.insert(item, after: nil)
203
+ sound.observation = item.observe(\.status, options: [.initial, .new]) { item, _ in
204
+ DispatchQueue.main.async {
205
+ if item.status == .readyToPlay { settle(.success(true)) }
206
+ else if item.status == .failed { failed(item.error?.localizedDescription ?? "The sound could not be played") }
207
+ }
208
+ }
209
+ sound.endObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main) { [weak self, weak sound] _ in
210
+ guard let self, let sound, self.sounds[id] === sound else { return }
211
+ self.remove(id)
212
+ self.emit?(["id": id, "event": "ended"])
213
+ }
214
+ }
215
+ player.volume = fadeIn > 0 ? 0 : volume
216
+ player.play()
217
+ if fadeIn > 0 { ramp(sound, to: volume, over: fadeIn) }
218
+ }
219
+
220
+ func pause(id: String) {
221
+ guard let sound = sounds[id] else { return }
222
+ sound.paused = true
223
+ sound.fade?.invalidate()
224
+ sound.player.pause()
225
+ }
226
+
227
+ func resume(id: String) {
228
+ guard let sound = sounds[id], sound.paused else { return }
229
+ sound.paused = false
230
+ sound.player.volume = sound.volume
231
+ sound.player.play()
232
+ }
233
+
234
+ /// Changes the volume, over `fade` seconds when given.
235
+ func setVolume(id: String, volume: Double, fade: Double) {
236
+ guard let sound = sounds[id] else { return }
237
+ sound.volume = Float(min(max(volume, 0), 1))
238
+ if fade > 0, !sound.paused { ramp(sound, to: sound.volume, over: fade) }
239
+ else { sound.fade?.invalidate(); sound.player.volume = sound.volume }
240
+ }
241
+
242
+ /// Stops the sound, fading it out over `fadeOut` seconds first.
243
+ func stop(id: String, fadeOut: Double) {
244
+ guard let sound = sounds[id] else { return }
245
+ guard fadeOut > 0, !sound.paused else { remove(id); return }
246
+ ramp(sound, to: 0, over: fadeOut) { [weak self, weak sound] in
247
+ guard let self, let sound, self.sounds[id] === sound else { return }
248
+ self.remove(id)
249
+ }
250
+ }
251
+
252
+ func stopAll(fadeOut: Double) {
253
+ for id in Array(sounds.keys) { stop(id: id, fadeOut: fadeOut) }
254
+ }
255
+
256
+ /// The screen closed: everything stops at once.
257
+ func close() {
258
+ for id in Array(sounds.keys) { remove(id) }
259
+ }
260
+
261
+ private func remove(_ id: String) {
262
+ guard let sound = sounds.removeValue(forKey: id) else { return }
263
+ sound.end()
264
+ Self.released()
265
+ }
266
+
267
+ /// Moves the volume in steps on the main run loop, a 30th of a second
268
+ /// apart: AVPlayer has no fade of its own for a playing item.
269
+ private func ramp(_ sound: Sound, to target: Float, over seconds: Double, then done: (() -> Void)? = nil) {
270
+ sound.fade?.invalidate()
271
+ let start = sound.player.volume
272
+ let began = CACurrentMediaTime()
273
+ let timer = Timer(timeInterval: 1.0 / 30, repeats: true) { [weak sound] timer in
274
+ guard let sound else { timer.invalidate(); return }
275
+ let progress = Float(min(1, (CACurrentMediaTime() - began) / seconds))
276
+ sound.player.volume = start + (target - start) * progress
277
+ if progress >= 1 {
278
+ timer.invalidate()
279
+ sound.fade = nil
280
+ done?()
281
+ }
282
+ }
283
+ sound.fade = timer
284
+ RunLoop.main.add(timer, forMode: .common)
285
+ }
286
+
287
+ private static func claimSession(respectSilentSwitch: Bool) {
288
+ playing += 1
289
+ category = respectSilentSwitch ? .ambient : .playback
290
+ let session = AVAudioSession.sharedInstance()
291
+ if sessionToRestore == nil { sessionToRestore = (session.category, session.mode, session.categoryOptions) }
292
+ do {
293
+ try session.setCategory(category, mode: .default, options: [.mixWithOthers])
294
+ try session.setActive(true)
295
+ } catch {
296
+ NSLog("[craft native] sound audio session failed: %@", error.localizedDescription)
297
+ }
298
+ }
299
+
300
+ private static func released() {
301
+ playing = max(0, playing - 1)
302
+ guard playing == 0, let previous = sessionToRestore else { return }
303
+ sessionToRestore = nil
304
+ try? AVAudioSession.sharedInstance().setCategory(previous.0, mode: previous.1, options: previous.2)
305
+ }
306
+
307
+ /// Speech handed the session back with the category it found, which may
308
+ /// predate a sound started while it spoke: take it again for the sound.
309
+ static func reclaimSessionIfPlaying() {
310
+ guard playing > 0 else { return }
311
+ try? AVAudioSession.sharedInstance().setCategory(category, mode: .default, options: [.mixWithOthers])
312
+ }
313
+ }
314
+
315
+ // MARK: - Streaming responses
316
+
317
+ /// A `fetch` whose response is read as it arrives, such as a server-sent
318
+ /// event stream (`Accept: text/event-stream`). The head answers the screen's
319
+ /// request; every piece of the body after it goes to the script as text, and
320
+ /// the end or the failure last. Streams belong to their screen and close with
321
+ /// it.
322
+ final class CraftNativeEventStreams: NSObject, URLSessionDataDelegate {
323
+ private lazy var session: URLSession = {
324
+ let configuration = URLSessionConfiguration.default
325
+ // A quiet stream is still open: the idle limit is generous, and the
326
+ // whole response has none worth the name.
327
+ configuration.timeoutIntervalForRequest = 300
328
+ configuration.timeoutIntervalForResource = 7 * 24 * 60 * 60
329
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
330
+ return URLSession(configuration: configuration, delegate: self, delegateQueue: .main)
331
+ }()
332
+ private var started = false
333
+ private var tasks: [String: URLSessionDataTask] = [:]
334
+ private var streamIds: [Int: String] = [:]
335
+ private var heads: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
336
+ /// The bytes of a character split across two pieces, until the rest comes.
337
+ private var carry: [String: Data] = [:]
338
+ /// `{ id, chunk }`, then `{ id, done: true }` or `{ id, error }`.
339
+ var emit: (([String: Any]) -> Void)?
340
+
341
+ func open(id: String, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
342
+ guard !id.isEmpty, tasks[id] == nil,
343
+ let address = options["url"] as? String, let url = URL(string: address),
344
+ let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http", url.host?.isEmpty == false else {
345
+ answer(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A streaming fetch needs an id and an absolute http or https URL")))
346
+ return
347
+ }
348
+ var request = URLRequest(url: url)
349
+ request.httpMethod = ((options["method"] as? String) ?? "GET").uppercased()
350
+ for (name, value) in options["headers"] as? [String: Any] ?? [:] {
351
+ if let value = value as? String { request.setValue(value, forHTTPHeaderField: name) }
352
+ }
353
+ if let body = options["body"] as? String { request.httpBody = Data(body.utf8) }
354
+ started = true
355
+ let task = session.dataTask(with: request)
356
+ tasks[id] = task
357
+ streamIds[task.taskIdentifier] = id
358
+ heads[id] = answer
359
+ task.resume()
360
+ }
361
+
362
+ /// The script stopped reading: the transfer ends, and nothing more is sent.
363
+ func cancel(id: String) {
364
+ guard let task = tasks.removeValue(forKey: id) else { return }
365
+ streamIds.removeValue(forKey: task.taskIdentifier)
366
+ heads.removeValue(forKey: id)?(.failure(CraftNativeActionError(code: "CANCELLED", message: "The request was aborted")))
367
+ carry.removeValue(forKey: id)
368
+ task.cancel()
369
+ }
370
+
371
+ /// The screen closed. The session holds its delegate until invalidated.
372
+ func close() {
373
+ tasks.removeAll()
374
+ streamIds.removeAll()
375
+ heads.removeAll()
376
+ carry.removeAll()
377
+ if started { session.invalidateAndCancel() }
378
+ }
379
+
380
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
381
+ guard let id = streamIds[dataTask.taskIdentifier], let answer = heads.removeValue(forKey: id) else {
382
+ completionHandler(.cancel)
383
+ return
384
+ }
385
+ guard let http = response as? HTTPURLResponse else {
386
+ completionHandler(.cancel)
387
+ answer(.failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "fetch received a response that is not HTTP")))
388
+ return
389
+ }
390
+ var headers: [String: String] = [:]
391
+ for (name, value) in http.allHeaderFields { headers[String(describing: name).lowercased()] = String(describing: value) }
392
+ completionHandler(.allow)
393
+ answer(.success([
394
+ "url": http.url?.absoluteString ?? "",
395
+ "status": http.statusCode,
396
+ "statusText": HTTPURLResponse.localizedString(forStatusCode: http.statusCode),
397
+ "headers": headers,
398
+ "redirected": http.url != dataTask.originalRequest?.url,
399
+ ]))
400
+ }
401
+
402
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
403
+ guard let id = streamIds[dataTask.taskIdentifier] else { return }
404
+ let (text, rest) = Self.decodeUTF8((carry[id] ?? Data()) + data)
405
+ carry[id] = rest.isEmpty ? nil : rest
406
+ if !text.isEmpty { emit?(["id": id, "chunk": text]) }
407
+ }
408
+
409
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
410
+ guard let id = streamIds.removeValue(forKey: task.taskIdentifier) else { return }
411
+ tasks.removeValue(forKey: id)
412
+ carry.removeValue(forKey: id)
413
+ if let answer = heads.removeValue(forKey: id) {
414
+ let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
415
+ answer(.failure(CraftNativeActionError(code: code, message: error?.localizedDescription ?? "Network request failed")))
416
+ return
417
+ }
418
+ if let error { emit?(["id": id, "error": error.localizedDescription]) }
419
+ else { emit?(["id": id, "done": true]) }
420
+ }
421
+
422
+ /// The text in `data`, and the bytes at its end that only begin a
423
+ /// character (kept for the next piece). Invalid bytes read as U+FFFD.
424
+ static func decodeUTF8(_ data: Data) -> (String, Data) {
425
+ let bytes = [UInt8](data)
426
+ var cut = bytes.count
427
+ // Up to three bytes back for the lead byte of an unfinished character.
428
+ var back = 1
429
+ while back <= min(3, bytes.count) {
430
+ let byte = bytes[bytes.count - back]
431
+ if byte & 0xC0 == 0x80 { back += 1; continue }
432
+ let length = byte >= 0xF0 ? 4 : byte >= 0xE0 ? 3 : byte >= 0xC0 ? 2 : 1
433
+ if length > back { cut = bytes.count - back }
434
+ break
435
+ }
436
+ return (String(decoding: bytes[0..<cut], as: UTF8.self), Data(bytes[cut...]))
437
+ }
438
+ }
439
+
440
+ // MARK: - Push
441
+
442
+ /// `craft.push` on a native screen: the same permission and device token the
443
+ /// page's `craft.registerPush` gets. The token arrives at the app delegate,
444
+ /// which posts `craftPushToken`; every screen hears it.
445
+ enum CraftNativePush {
446
+ private static var waiting: [(Result<Any, CraftNativeActionError>) -> Void] = []
447
+ private static var observers: [NSObjectProtocol] = []
448
+ private static var deadline: DispatchWorkItem?
449
+ /// APNs answers in a second or two; it is given this long.
450
+ private static let registrationSeconds: Double = 20
451
+
452
+ /// `granted`, `denied` or `undetermined`, without asking.
453
+ static func status(_ done: @escaping (String) -> Void) {
454
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
455
+ let status: String
456
+ switch settings.authorizationStatus {
457
+ case .authorized, .provisional, .ephemeral: status = "granted"
458
+ case .denied: status = "denied"
459
+ default: status = "undetermined"
460
+ }
461
+ DispatchQueue.main.async { done(status) }
462
+ }
463
+ }
464
+
465
+ /// Asks once, if iOS has not been answered yet; the answer either way.
466
+ static func requestPermission(_ done: @escaping (String) -> Void) {
467
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in
468
+ status(done)
469
+ }
470
+ }
471
+
472
+ /// The device token, asking for permission first if it was never asked.
473
+ static func register(_ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
474
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
475
+ DispatchQueue.main.async {
476
+ guard granted else {
477
+ done(.failure(CraftNativeActionError(code: "PERMISSION_DENIED", message: error?.localizedDescription ?? "Notification permission denied")))
478
+ return
479
+ }
480
+ waiting.append(done)
481
+ guard waiting.count == 1 else { return }
482
+ observe()
483
+ let timeout = DispatchWorkItem {
484
+ settle(.failure(CraftNativeActionError(code: "TIMEOUT", message: "APNs did not answer within \(Int(registrationSeconds))s")))
485
+ }
486
+ deadline = timeout
487
+ DispatchQueue.main.asyncAfter(deadline: .now() + registrationSeconds, execute: timeout)
488
+ UIApplication.shared.registerForRemoteNotifications()
489
+ }
490
+ }
491
+ }
492
+
493
+ private static func observe() {
494
+ guard observers.isEmpty else { return }
495
+ observers.append(NotificationCenter.default.addObserver(forName: .craftPushToken, object: nil, queue: .main) { note in
496
+ guard let token = note.object as? String else { return }
497
+ settle(.success(token))
498
+ })
499
+ observers.append(NotificationCenter.default.addObserver(forName: .craftPushRegistrationError, object: nil, queue: .main) { note in
500
+ settle(.failure(CraftNativeActionError(code: "PUSH_REGISTRATION_ERROR", message: note.object as? String ?? "Push registration failed")))
501
+ })
502
+ }
503
+
504
+ private static func settle(_ result: Result<Any, CraftNativeActionError>) {
505
+ deadline?.cancel()
506
+ deadline = nil
507
+ observers.forEach(NotificationCenter.default.removeObserver)
508
+ observers.removeAll()
509
+ let callers = waiting
510
+ waiting.removeAll()
511
+ callers.forEach { $0(result) }
512
+ }
513
+ }
514
+
515
+ // MARK: - Share sheet
516
+
517
+ /// `craft.share` on a native screen: the system share sheet with the page's
518
+ /// options (`title`, `text`, `url`, `files`), plus `image`, a picture by URL
519
+ /// that is shared as the picture rather than as its address. Answers true
520
+ /// once something was shared, false when the sheet was dismissed.
521
+ enum CraftNativeShare {
522
+ static func share(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
523
+ var items: [Any] = []
524
+ if let title = options["title"] as? String, !title.isEmpty { items.append(title) }
525
+ if let text = options["text"] as? String, !text.isEmpty { items.append(text) }
526
+ if let address = options["url"] as? String, let url = URL(string: address) { items.append(url) }
527
+ for file in options["files"] as? [String] ?? [] {
528
+ if let url = URL(string: file), url.isFileURL { items.append(url) }
529
+ else if FileManager.default.fileExists(atPath: file) { items.append(URL(fileURLWithPath: file)) }
530
+ }
531
+ let present: ([Any]) -> Void = { items in
532
+ guard !items.isEmpty else {
533
+ done(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "Nothing to share")))
534
+ return
535
+ }
536
+ let sheet = UIActivityViewController(activityItems: items, applicationActivities: nil)
537
+ sheet.completionWithItemsHandler = { _, completed, _, error in
538
+ if let error { done(.failure(CraftNativeActionError(code: "SHARE_ERROR", message: error.localizedDescription))) }
539
+ else { done(.success(completed)) }
540
+ }
541
+ if !CraftPresenter.present(sheet, from: nil, anchor: nil) {
542
+ done(.failure(CraftNativeActionError(code: "SHARE_ERROR", message: "Unable to present the share sheet")))
543
+ }
544
+ }
545
+ guard let address = options["image"] as? String, let url = URL(string: address) else {
546
+ DispatchQueue.main.async { present(items) }
547
+ return
548
+ }
549
+ // The picture itself, so Messages and Photos get an image.
550
+ let load: (Data?) -> Void = { data in
551
+ DispatchQueue.main.async {
552
+ guard let data, let image = UIImage(data: data) else {
553
+ done(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "The image to share could not be loaded")))
554
+ return
555
+ }
556
+ present([image] + items)
557
+ }
558
+ }
559
+ if address.hasPrefix("data:image/"), let comma = address.firstIndex(of: ",") {
560
+ load(Data(base64Encoded: String(address[address.index(after: comma)...])))
561
+ } else if url.isFileURL {
562
+ load(try? Data(contentsOf: url))
563
+ } else {
564
+ URLSession.shared.dataTask(with: url) { data, _, _ in load(data) }.resume()
565
+ }
566
+ }
567
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.146",
3
+ "version": "0.0.148",
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>",