craft-native 0.0.150 → 0.0.152

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.
@@ -61,6 +61,9 @@ enum CraftNativeActions {
61
61
  if config.enableGeolocation && config.renderer != "native" { values.append("geolocation") }
62
62
  // The Music app's song and transport are the coordinator's too.
63
63
  if config.enableAppleMusic && config.renderer != "native" { values.append("music") }
64
+ // A heart-rate strap is the app's own service (CraftHeartRate.swift),
65
+ // so it needs no web view.
66
+ if config.enableHeartRate { values.append("heartRate") }
64
67
  return values
65
68
  }
66
69
 
@@ -290,6 +293,19 @@ enum CraftNativeActions {
290
293
  }
291
294
  CraftWebServices.perform(action: action, body: args.first as? [String: Any] ?? [:]) { complete(requestToken, $0, completion) }
292
295
  return
296
+ case ("HeartRate", _):
297
+ // A Bluetooth heart-rate strap, the same service as a page's
298
+ // `craft.heartRate`: one connection and one recording, whichever
299
+ // side started them. Its events reach every screen as
300
+ // `craftHeartRate`.
301
+ guard config.enableHeartRate else {
302
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Heart rate is disabled"), completion)
303
+ return
304
+ }
305
+ CraftHeartRate.shared.perform(method: method, args: args) { result in
306
+ complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
307
+ }
308
+ return
293
309
  case ("Linking", "openURL"), ("Linking", "canOpenURL"):
294
310
  // Another app or the system: `webcal:` subscriptions, `mailto:`,
295
311
  // `tel:`, the App Store, this app's page in Settings
@@ -360,196 +360,6 @@ private final class CraftNativeVideoView: UIView {
360
360
  deinit { stop() }
361
361
  }
362
362
 
363
- /// The small Web Audio subset used by native STX screens: oscillator and gain
364
- /// nodes connected to the context destination. JavaScriptCore has no Web Audio
365
- /// implementation, so the screen installs a compatible surface and routes the
366
- /// graph to one AVAudioEngine source node per oscillator.
367
- private final class CraftNativeAudio {
368
- private struct ParameterEvent {
369
- let time: Double
370
- let value: Double
371
- let ramp: Bool
372
- }
373
-
374
- private final class Parameter {
375
- private let lock = NSLock()
376
- private var current: Double
377
- private var events: [ParameterEvent] = []
378
-
379
- init(_ value: Double) {
380
- current = value
381
- }
382
-
383
- func set(_ value: Double) {
384
- lock.lock(); defer { lock.unlock() }
385
- current = value
386
- events.removeAll()
387
- }
388
-
389
- func schedule(_ value: Double, at time: Double, ramp: Bool) {
390
- lock.lock(); defer { lock.unlock() }
391
- events.append(ParameterEvent(time: max(0, time), value: value, ramp: ramp))
392
- events.sort { $0.time < $1.time }
393
- }
394
-
395
- func value(at time: Double) -> Double {
396
- lock.lock(); defer { lock.unlock() }
397
- var previousTime = 0.0
398
- var previousValue = current
399
- for event in events {
400
- if time < event.time {
401
- guard event.ramp, event.time > previousTime else { return previousValue }
402
- let fraction = (time - previousTime) / (event.time - previousTime)
403
- return previousValue + (event.value - previousValue) * fraction
404
- }
405
- previousTime = event.time
406
- previousValue = event.value
407
- }
408
- return previousValue
409
- }
410
- }
411
-
412
- private final class OscillatorState {
413
- let lock = NSLock()
414
- let frequency = Parameter(440)
415
- var gain = Parameter(1)
416
- var phase = 0.0
417
- var active = false
418
- let contextStart: TimeInterval
419
-
420
- init(contextStart: TimeInterval) {
421
- self.contextStart = contextStart
422
- }
423
- }
424
-
425
- private let engine = AVAudioEngine()
426
- private var format: AVAudioFormat?
427
- private var sources: [String: (state: OscillatorState, node: AVAudioSourceNode)] = [:]
428
- private var gains: [String: Parameter] = [:]
429
- private var contextStarts: [String: TimeInterval] = [:]
430
- private var closed = false
431
-
432
- func handle(_ payload: [String: Any]) {
433
- guard !closed, let action = payload["action"] as? String else { return }
434
- switch action {
435
- case "createContext":
436
- closed = false
437
- if let contextID = payload["contextId"] as? String {
438
- contextStarts[contextID] = Date().timeIntervalSince1970
439
- }
440
- case "createOscillator":
441
- guard let id = payload["nodeId"] as? String else { return }
442
- let contextStart = (payload["contextId"] as? String).flatMap { contextStarts[$0] } ?? Date().timeIntervalSince1970
443
- createOscillator(id: id, frequency: number(payload["frequency"]) ?? 440, contextStart: contextStart)
444
- case "createGain":
445
- guard let id = payload["nodeId"] as? String else { return }
446
- gains[id] = Parameter(number(payload["gain"]) ?? 1)
447
- case "connect":
448
- guard let sourceID = payload["sourceId"] as? String else { return }
449
- let destinationID = payload["destinationId"] as? String
450
- if var source = sources[sourceID] {
451
- source.state.lock.lock()
452
- source.state.gain = destinationID.flatMap { gains[$0] } ?? Parameter(1)
453
- source.state.lock.unlock()
454
- sources[sourceID] = source
455
- }
456
- case "frequency":
457
- guard let id = payload["nodeId"] as? String, let value = number(payload["value"]), let source = sources[id] else { return }
458
- source.state.frequency.set(max(0, value))
459
- case "frequencyAtTime", "frequencyRampToTime":
460
- guard let id = payload["nodeId"] as? String, let value = number(payload["value"]), let time = number(payload["time"]), let source = sources[id] else { return }
461
- source.state.frequency.schedule(max(0, value), at: time, ramp: action == "frequencyRampToTime")
462
- case "gain":
463
- guard let id = payload["nodeId"] as? String, let value = number(payload["value"]) else { return }
464
- gains[id]?.set(max(0, value))
465
- case "gainAtTime", "gainRampToTime":
466
- guard let id = payload["nodeId"] as? String, let value = number(payload["value"]), let time = number(payload["time"]) else { return }
467
- gains[id]?.schedule(max(0, value), at: time, ramp: action == "gainRampToTime")
468
- case "start", "stop":
469
- guard let id = payload["nodeId"] as? String, let source = sources[id] else { return }
470
- if action == "start" { start(source.state, after: max(0, number(payload["delay"]) ?? 0)) }
471
- else { stop(source.state, after: max(0, number(payload["delay"]) ?? 0)) }
472
- case "close":
473
- close()
474
- default:
475
- break
476
- }
477
- }
478
-
479
- func close() {
480
- guard !closed else { return }
481
- closed = true
482
- sources.values.forEach { source in
483
- source.state.lock.lock(); source.state.active = false; source.state.lock.unlock()
484
- engine.detach(source.node)
485
- }
486
- sources.removeAll()
487
- gains.removeAll()
488
- contextStarts.removeAll()
489
- engine.stop()
490
- }
491
-
492
- private func createOscillator(id: String, frequency: Double, contextStart: TimeInterval) {
493
- guard sources[id] == nil else { return }
494
- guard let format = audioFormat() else { return }
495
- let state = OscillatorState(contextStart: contextStart)
496
- state.frequency.set(max(0, frequency))
497
- let node = AVAudioSourceNode(format: format) { _, _, frameCount, audioBufferList in
498
- let buffers = UnsafeMutableAudioBufferListPointer(audioBufferList)
499
- state.lock.lock()
500
- let active = state.active
501
- let frequency = state.frequency
502
- let gain = state.gain
503
- var phase = state.phase
504
- state.lock.unlock()
505
- let startTime = max(0, Date().timeIntervalSince1970 - state.contextStart)
506
- for buffer in buffers {
507
- guard let data = buffer.mData else { continue }
508
- let samples = data.assumingMemoryBound(to: Float.self)
509
- for frame in 0..<Int(frameCount) {
510
- let frameTime = startTime + Double(frame) / format.sampleRate
511
- let frameFrequency = frequency.value(at: frameTime)
512
- let frameGain = gain.value(at: frameTime)
513
- samples[frame] = active ? Float(sin(phase) * frameGain) : 0
514
- phase += 2 * Double.pi * frameFrequency / format.sampleRate
515
- if phase >= 2 * Double.pi { phase -= 2 * Double.pi }
516
- }
517
- }
518
- state.lock.lock(); state.phase = phase; state.lock.unlock()
519
- return noErr
520
- }
521
- engine.attach(node)
522
- engine.connect(node, to: engine.mainMixerNode, format: format)
523
- sources[id] = (state, node)
524
- }
525
-
526
- private func audioFormat() -> AVAudioFormat? {
527
- if let format { return format }
528
- let created = AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 2)
529
- format = created
530
- return created
531
- }
532
-
533
- private func start(_ state: OscillatorState, after delay: Double) {
534
- if !engine.isRunning {
535
- try? engine.start()
536
- }
537
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
538
- state.lock.lock(); state.active = true; state.lock.unlock()
539
- }
540
- }
541
-
542
- private func stop(_ state: OscillatorState, after delay: Double) {
543
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
544
- state.lock.lock(); state.active = false; state.lock.unlock()
545
- }
546
- }
547
-
548
- private func number(_ value: Any?) -> Double? {
549
- (value as? NSNumber).map { $0.doubleValue }
550
- }
551
- }
552
-
553
363
  /// A WebView-free host for the first stx native vertical slice. The bundled
554
364
  /// JavaScript sends whole, compiled view trees to UIKit and receives control
555
365
  /// events and Craft API replies through JavaScriptCore.
@@ -2249,7 +2059,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2249
2059
  /// A drawing's shapes are not views: a change to one redraws its `Svg`.
2250
2060
  private var svgOwners: [String: String] = [:]
2251
2061
  private let mutationDocument = CraftNativeMutationDocument()
2252
- private let audio = CraftNativeAudio()
2062
+ /// The screen's Web Audio (CraftWebAudio.swift): its `AudioContext`s.
2063
+ private let audio = CraftWebAudio()
2253
2064
  /// `craft.audio`'s sounds and the streaming responses this screen reads.
2254
2065
  private let sounds = CraftNativeSounds()
2255
2066
  private let streams = CraftNativeEventStreams()
@@ -2610,6 +2421,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2610
2421
  jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
2611
2422
  jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
2612
2423
  jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
2424
+ // `AudioContext` and the rest of Web Audio, rendered natively.
2425
+ audio.install(in: jsContext)
2613
2426
  // `crypto` for code shared with the page (a sign-in's nonce and its
2614
2427
  // SHA-256): JavaScriptCore has none. Random bytes from the system's
2615
2428
  // generator, digests from CryptoKit, both as hex.
@@ -2766,76 +2579,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2766
2579
  keep('navigation', {
2767
2580
  setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
2768
2581
  });
2769
- if (typeof globalThis.AudioContext !== 'function') {
2770
- var nextAudioId = 0;
2771
- function audioMessage(payload) {
2772
- globalThis.__stxNativeBridge.postMessage({ type: 'AUDIO', payload: payload });
2773
- }
2774
- function audioParam(contextId, nodeId, action, initial) {
2775
- var current = Number(initial) || 0;
2776
- var param = {};
2777
- Object.defineProperty(param, 'value', {
2778
- configurable: true,
2779
- enumerable: true,
2780
- get: function() { return current; },
2781
- set: function(value) {
2782
- current = Number(value) || 0;
2783
- audioMessage({ action: action, contextId: contextId, nodeId: nodeId, value: current });
2784
- }
2785
- });
2786
- function schedule(value, when, ramp) {
2787
- current = Number(value) || 0;
2788
- var time = Number(when);
2789
- audioMessage({ action: action + (ramp ? 'RampToTime' : 'AtTime'), contextId: contextId, nodeId: nodeId, value: current, time: isFinite(time) ? Math.max(0, time) : 0 });
2790
- }
2791
- param.setValueAtTime = function(value, when) { schedule(value, when, false); return param; };
2792
- param.linearRampToValueAtTime = function(value, when) { schedule(value, when, true); return param; };
2793
- return param;
2794
- }
2795
- function AudioContext() {
2796
- this._id = 'audio-' + (++nextAudioId);
2797
- this._started = Date.now() / 1000;
2798
- this.state = 'running';
2799
- this.destination = { _contextId: this._id, _nodeId: 'destination' };
2800
- audioMessage({ action: 'createContext', contextId: this._id });
2801
- }
2802
- Object.defineProperty(AudioContext.prototype, 'currentTime', {
2803
- get: function() { return Date.now() / 1000 - this._started; }
2804
- });
2805
- AudioContext.prototype.createOscillator = function() {
2806
- var context = this;
2807
- var node = { _contextId: context._id, _nodeId: context._id + '-osc-' + (++nextAudioId) };
2808
- node.frequency = audioParam(context._id, node._nodeId, 'frequency', 440);
2809
- node.connect = function(destination) {
2810
- audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2811
- return destination;
2812
- };
2813
- node.start = function(when) {
2814
- audioMessage({ action: 'start', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2815
- };
2816
- node.stop = function(when) {
2817
- audioMessage({ action: 'stop', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2818
- };
2819
- audioMessage({ action: 'createOscillator', contextId: context._id, nodeId: node._nodeId, frequency: node.frequency.value });
2820
- return node;
2821
- };
2822
- AudioContext.prototype.createGain = function() {
2823
- var context = this;
2824
- var node = { _contextId: context._id, _nodeId: context._id + '-gain-' + (++nextAudioId) };
2825
- node.gain = audioParam(context._id, node._nodeId, 'gain', 1);
2826
- node.connect = function(destination) {
2827
- audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2828
- return destination;
2829
- };
2830
- audioMessage({ action: 'createGain', contextId: context._id, nodeId: node._nodeId, gain: node.gain.value });
2831
- return node;
2832
- };
2833
- AudioContext.prototype.resume = function() { this.state = 'running'; return Promise.resolve(); };
2834
- AudioContext.prototype.suspend = function() { this.state = 'suspended'; return Promise.resolve(); };
2835
- AudioContext.prototype.close = function() { this.state = 'closed'; audioMessage({ action: 'close', contextId: this._id }); return Promise.resolve(); };
2836
- globalThis.AudioContext = AudioContext;
2837
- globalThis.webkitAudioContext = AudioContext;
2838
- }
2839
2582
  })();
2840
2583
  """)
2841
2584
  }
@@ -3361,8 +3104,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3361
3104
  pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
3362
3105
  screenRequestCancellers.removeValue(forKey: requestToken)?()
3363
3106
  CraftNativeActions.cancel(requestToken: requestToken)
3364
- case "AUDIO":
3365
- audio.handle(payload)
3366
3107
  default:
3367
3108
  break
3368
3109
  }
@@ -261,7 +261,7 @@ final class CraftNativeSounds {
261
261
  private func remove(_ id: String) {
262
262
  guard let sound = sounds.removeValue(forKey: id) else { return }
263
263
  sound.end()
264
- Self.released()
264
+ Self.releaseSession()
265
265
  }
266
266
 
267
267
  /// Moves the volume in steps on the main run loop, a 30th of a second
@@ -284,7 +284,10 @@ final class CraftNativeSounds {
284
284
  RunLoop.main.add(timer, forMode: .common)
285
285
  }
286
286
 
287
- private static func claimSession(respectSilentSwitch: Bool) {
287
+ /// Takes the session for one more playing sound. Native screens' Web
288
+ /// Audio (CraftWebAudio.swift) counts here too, so speech hands the
289
+ /// session back to whichever of them still plays.
290
+ static func claimSession(respectSilentSwitch: Bool) {
288
291
  playing += 1
289
292
  category = respectSilentSwitch ? .ambient : .playback
290
293
  let session = AVAudioSession.sharedInstance()
@@ -297,7 +300,7 @@ final class CraftNativeSounds {
297
300
  }
298
301
  }
299
302
 
300
- private static func released() {
303
+ static func releaseSession() {
301
304
  playing = max(0, playing - 1)
302
305
  guard playing == 0, let previous = sessionToRestore else { return }
303
306
  sessionToRestore = nil