craft-native 0.0.149 → 0.0.151

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/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.149";
3306
+ var version = "0.0.151";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.149";
313
+ var version = "0.0.151";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.149";
61
+ var version = "0.0.151";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -790,6 +790,7 @@ async function init(options) {
790
790
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
791
791
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
792
792
  cpSync(join(TEMPLATES_DIR, "CraftNativeServices.swift"), join(output, "Sources", "CraftNativeServices.swift"));
793
+ cpSync(join(TEMPLATES_DIR, "CraftWebAudio.swift"), join(output, "Sources", "CraftWebAudio.swift"));
793
794
  cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
794
795
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
795
796
  const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{BACKGROUND_TASK_IDENTIFIERS\}\}/g, renderBackgroundTaskIdentifiers(config)).replace(/\{\{LAUNCH_SCREEN\}\}/g, renderLaunchScreen(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
@@ -566,6 +566,18 @@ final class CraftChrome: ObservableObject {
566
566
  tabBarVisible = true
567
567
  }
568
568
 
569
+ /// A native screen that wants the whole screen (a workout under way) asks
570
+ /// the bar away while it shows (`tabBarHidden`, CraftNativeScreen.swift);
571
+ /// the next screen shown says again. The page's own hideTabBar is apart.
572
+ private(set) var hiddenByScreen = false
573
+ func setHiddenByScreen(_ hidden: Bool) {
574
+ guard hiddenByScreen != hidden else { return }
575
+ hiddenByScreen = hidden
576
+ pendingHide?.cancel()
577
+ withAnimation(Self.motion(.easeInOut(duration: 0.2))) { tabBarVisible = !hidden && !tabs.isEmpty }
578
+ publishLayout()
579
+ }
580
+
569
581
  /// However the page behaves, the splash is gone after `seconds`
570
582
  /// (`splashMaxSeconds`, three by default). It used to be ten: a page that
571
583
  /// never painted held a logo on screen for ten seconds, which reads as a
@@ -595,7 +607,7 @@ final class CraftChrome: ObservableObject {
595
607
  let dark = (body["tintDark"] as? String).flatMap { UIColor(hex: $0) }
596
608
  tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
597
609
  }
598
- withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty }
610
+ withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen }
599
611
  publishLayout()
600
612
  case "selectTab":
601
613
  if let id = body["id"] as? String, id != selected {
@@ -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()
@@ -2335,9 +2146,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2335
2146
  // a screen that hid its own.
2336
2147
  navigationController?.setNavigationBarHidden(false, animated: animated)
2337
2148
  }
2149
+ // Each screen says whether it wants the tab bar, so one that hid it
2150
+ // never leaves it hidden for the next.
2151
+ CraftChrome.shared.setHiddenByScreen(tabBarHidden)
2338
2152
  refreshTraitDefaults()
2339
2153
  }
2340
2154
 
2155
+ override func viewWillDisappear(_ animated: Bool) {
2156
+ super.viewWillDisappear(animated)
2157
+ // Popped (or its stack dismissed) while the bar was away: it comes
2158
+ // back for whatever shows next, a web page included.
2159
+ if tabBarHidden, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
2160
+ CraftChrome.shared.setHiddenByScreen(false)
2161
+ }
2162
+ }
2163
+
2341
2164
  override func viewDidAppear(_ animated: Bool) {
2342
2165
  super.viewDidAppear(animated)
2343
2166
  screenVisibilityChanged(true)
@@ -2598,6 +2421,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2598
2421
  jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
2599
2422
  jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
2600
2423
  jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
2424
+ // `AudioContext` and the rest of Web Audio, rendered natively.
2425
+ audio.install(in: jsContext)
2601
2426
  // `crypto` for code shared with the page (a sign-in's nonce and its
2602
2427
  // SHA-256): JavaScriptCore has none. Random bytes from the system's
2603
2428
  // generator, digests from CryptoKit, both as hex.
@@ -2754,76 +2579,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2754
2579
  keep('navigation', {
2755
2580
  setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
2756
2581
  });
2757
- if (typeof globalThis.AudioContext !== 'function') {
2758
- var nextAudioId = 0;
2759
- function audioMessage(payload) {
2760
- globalThis.__stxNativeBridge.postMessage({ type: 'AUDIO', payload: payload });
2761
- }
2762
- function audioParam(contextId, nodeId, action, initial) {
2763
- var current = Number(initial) || 0;
2764
- var param = {};
2765
- Object.defineProperty(param, 'value', {
2766
- configurable: true,
2767
- enumerable: true,
2768
- get: function() { return current; },
2769
- set: function(value) {
2770
- current = Number(value) || 0;
2771
- audioMessage({ action: action, contextId: contextId, nodeId: nodeId, value: current });
2772
- }
2773
- });
2774
- function schedule(value, when, ramp) {
2775
- current = Number(value) || 0;
2776
- var time = Number(when);
2777
- audioMessage({ action: action + (ramp ? 'RampToTime' : 'AtTime'), contextId: contextId, nodeId: nodeId, value: current, time: isFinite(time) ? Math.max(0, time) : 0 });
2778
- }
2779
- param.setValueAtTime = function(value, when) { schedule(value, when, false); return param; };
2780
- param.linearRampToValueAtTime = function(value, when) { schedule(value, when, true); return param; };
2781
- return param;
2782
- }
2783
- function AudioContext() {
2784
- this._id = 'audio-' + (++nextAudioId);
2785
- this._started = Date.now() / 1000;
2786
- this.state = 'running';
2787
- this.destination = { _contextId: this._id, _nodeId: 'destination' };
2788
- audioMessage({ action: 'createContext', contextId: this._id });
2789
- }
2790
- Object.defineProperty(AudioContext.prototype, 'currentTime', {
2791
- get: function() { return Date.now() / 1000 - this._started; }
2792
- });
2793
- AudioContext.prototype.createOscillator = function() {
2794
- var context = this;
2795
- var node = { _contextId: context._id, _nodeId: context._id + '-osc-' + (++nextAudioId) };
2796
- node.frequency = audioParam(context._id, node._nodeId, 'frequency', 440);
2797
- node.connect = function(destination) {
2798
- audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2799
- return destination;
2800
- };
2801
- node.start = function(when) {
2802
- audioMessage({ action: 'start', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2803
- };
2804
- node.stop = function(when) {
2805
- audioMessage({ action: 'stop', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2806
- };
2807
- audioMessage({ action: 'createOscillator', contextId: context._id, nodeId: node._nodeId, frequency: node.frequency.value });
2808
- return node;
2809
- };
2810
- AudioContext.prototype.createGain = function() {
2811
- var context = this;
2812
- var node = { _contextId: context._id, _nodeId: context._id + '-gain-' + (++nextAudioId) };
2813
- node.gain = audioParam(context._id, node._nodeId, 'gain', 1);
2814
- node.connect = function(destination) {
2815
- audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2816
- return destination;
2817
- };
2818
- audioMessage({ action: 'createGain', contextId: context._id, nodeId: node._nodeId, gain: node.gain.value });
2819
- return node;
2820
- };
2821
- AudioContext.prototype.resume = function() { this.state = 'running'; return Promise.resolve(); };
2822
- AudioContext.prototype.suspend = function() { this.state = 'suspended'; return Promise.resolve(); };
2823
- AudioContext.prototype.close = function() { this.state = 'closed'; audioMessage({ action: 'close', contextId: this._id }); return Promise.resolve(); };
2824
- globalThis.AudioContext = AudioContext;
2825
- globalThis.webkitAudioContext = AudioContext;
2826
- }
2827
2582
  })();
2828
2583
  """)
2829
2584
  }
@@ -2852,6 +2607,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2852
2607
  private var rootTopToSafeArea: NSLayoutConstraint?
2853
2608
  private var rootTopToView: NSLayoutConstraint?
2854
2609
  private var navigationBarHidden: Bool?
2610
+ /// The app's tab bar away while this screen shows (`tabBarHidden`).
2611
+ private var tabBarHidden = false
2855
2612
  private var prefersLargeTitle: Bool?
2856
2613
 
2857
2614
  /// Whether this screen asked for a bar of its own through `setOptions`: a
@@ -2879,6 +2636,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2879
2636
  if largeTitle { navigationController?.navigationBar.prefersLargeTitles = true }
2880
2637
  trackContentScrollView()
2881
2638
  }
2639
+ if let hidden = options["tabBarHidden"] as? Bool {
2640
+ tabBarHidden = hidden
2641
+ if navigationController?.topViewController === self, view.window != nil {
2642
+ CraftChrome.shared.setHiddenByScreen(hidden)
2643
+ }
2644
+ }
2882
2645
  if let hidden = options["hidden"] as? Bool {
2883
2646
  navigationBarHidden = hidden
2884
2647
  if navigationController?.topViewController === self {
@@ -3341,8 +3104,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3341
3104
  pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
3342
3105
  screenRequestCancellers.removeValue(forKey: requestToken)?()
3343
3106
  CraftNativeActions.cancel(requestToken: requestToken)
3344
- case "AUDIO":
3345
- audio.handle(payload)
3346
3107
  default:
3347
3108
  break
3348
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