craft-native 0.0.146 → 0.0.147
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/templates/MainActivityNative.kt.template +33 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +1 -0
- package/dist/ios/templates/CraftApp.swift +56 -0
- package/dist/ios/templates/CraftHybrid.swift +26 -5
- package/dist/ios/templates/CraftNativeActions.swift +137 -1
- package/dist/ios/templates/CraftNativeScreen.swift +506 -39
- package/dist/ios/templates/CraftNativeServices.swift +567 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|