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.
- package/dist/api/mobile.d.ts +113 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +2 -1
- package/dist/index.js +2 -1
- package/dist/ios/src/index.d.ts +9 -0
- package/dist/ios/src/index.js +6 -1
- package/dist/ios/templates/CraftApp.swift +56 -1
- package/dist/ios/templates/CraftHeartRate.swift +699 -0
- package/dist/ios/templates/CraftNativeActions.swift +16 -0
- package/dist/ios/templates/CraftNativeScreen.swift +4 -263
- package/dist/ios/templates/CraftNativeServices.swift +6 -3
- package/dist/ios/templates/CraftWebAudio.swift +2269 -0
- package/dist/mobile.js +163 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|