react-native-audio-browser 0.2.0-next.21 → 0.2.0-next.23

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.
Files changed (29) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +28 -7
  3. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +2 -0
  4. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +32 -8
  5. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +21 -0
  6. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +6 -0
  7. package/ios/Browser/InertStyleDiagnostic.swift +25 -8
  8. package/ios/Browser/SectionPresentation.swift +18 -5
  9. package/ios/CarPlay/CPInterfaceController+Safe.swift +93 -23
  10. package/ios/CarPlay/CarPlayListItemFactory.swift +74 -54
  11. package/ios/CarPlay/TopTemplateReplacer.swift +96 -0
  12. package/ios/Model/NitroTypeStubs.swift +3 -0
  13. package/ios/Tests/InertStyleDiagnosticTests.swift +17 -8
  14. package/ios/Tests/JsonModelsTests.swift +14 -0
  15. package/ios/Tests/SectionPresentationTests.swift +21 -13
  16. package/ios/Tests/StyleResolverTests.swift +5 -0
  17. package/ios/Tests/TopTemplateReplacerTests.swift +172 -0
  18. package/lib/typescript/src/types/browser-nodes.d.ts +28 -21
  19. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  20. package/nitrogen/generated/android/c++/JGridTile.hpp +3 -0
  21. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +3 -2
  22. package/nitrogen/generated/ios/swift/GridTile.swift +4 -0
  23. package/nitrogen/generated/shared/c++/GridTile.hpp +6 -2
  24. package/package.json +1 -1
  25. package/src/types/browser-nodes.ts +28 -21
  26. package/website/guide/artwork.md +1 -1
  27. package/website/guide/browser.md +11 -5
  28. package/website/guide/carplay.md +30 -0
  29. package/website/guide/track.md +1 -1
package/README.md CHANGED
@@ -172,7 +172,7 @@ Full guides and the complete [API Reference](https://audiobrowser.dev/api/) live
172
172
 
173
173
  **Troubleshooting** — [Networking in native callbacks](https://audiobrowser.dev/guide/native-callback-fetch) · [Android SSL / trust anchors](https://audiobrowser.dev/guide/android-certificates)
174
174
 
175
- The example app in [`apps/example-native`](https://github.com/radio-garden/react-native-audio-browser/tree/main/apps/example-native) browses archive.org's audio collection — in-app, on CarPlay, and on Android Auto — with search, favorites, and the gate.
175
+ The example app in [`apps/example-native`](https://github.com/radio-garden/react-native-audio-browser/tree/main/apps/example-native) browses archive.org's audio collection — in-app, on CarPlay, and on Android Auto — with search, favorites, and the gate. Its **Style Lab** tab renders every CarPlay `style` declaration, each shelf titled with the block it draws ([source](https://github.com/radio-garden/react-native-audio-browser/blob/main/apps/example-native/src/utils/style-lab.ts)).
176
176
 
177
177
  ### For coding agents
178
178
 
@@ -66,8 +66,8 @@ object InertStyleDiagnostic {
66
66
  when (this) {
67
67
  GRID_WRAP,
68
68
  GRID_TILE -> !context.isGrid
69
- IMAGE_SHAPE -> !context.isGrid || context.isCard
70
- ACCESSORY_SYMBOL -> context.isCard
69
+ IMAGE_SHAPE -> !context.isShaped
70
+ ACCESSORY_SYMBOL -> context.isGrid && !context.isShaped
71
71
  CARD_TINT,
72
72
  CARD_IMAGE -> !context.isCard
73
73
  }
@@ -78,8 +78,9 @@ object InertStyleDiagnostic {
78
78
  when (this) {
79
79
  GRID_WRAP,
80
80
  GRID_TILE -> "`$key` renders only in a grid"
81
- IMAGE_SHAPE -> "only plain and condensed tiles have a shape"
82
- ACCESSORY_SYMBOL -> "card tiles have no accessory slot"
81
+ IMAGE_SHAPE -> "only image and condensed tiles have a shape"
82
+ ACCESSORY_SYMBOL ->
83
+ "among grid tiles only image and condensed ones have an accessory slot"
83
84
  CARD_TINT,
84
85
  CARD_IMAGE -> "the card properties render only under `gridTile: 'card'`"
85
86
  }
@@ -89,8 +90,9 @@ object InertStyleDiagnostic {
89
90
  when (this) {
90
91
  GRID_WRAP,
91
92
  GRID_TILE -> "Declare `display: 'grid'` on the section or the page, or drop `$key`."
92
- IMAGE_SHAPE -> "Render the items as plain or condensed grid tiles, or drop `imageShape`."
93
- ACCESSORY_SYMBOL -> "Use `gridTile: 'plain'` or `'condensed'`, or drop `accessorySymbol`."
93
+ IMAGE_SHAPE -> "Use `gridTile: 'image'` or `'condensed'` in a grid, or drop `imageShape`."
94
+ ACCESSORY_SYMBOL ->
95
+ "Use `gridTile: 'image'` or `'condensed'`, or a list, or drop `accessorySymbol`."
94
96
  CARD_TINT,
95
97
  CARD_IMAGE -> "Declare `display: 'grid'` with `gridTile: 'card'`, or drop `$key`."
96
98
  }
@@ -146,9 +148,28 @@ object InertStyleDiagnostic {
146
148
 
147
149
  /**
148
150
  * Whether the card treatment is what this container renders — the `card*` family's condition.
151
+ * Exhaustive, like [isShaped]: a new tile kind has to be ruled on here.
149
152
  */
150
153
  val isCard: Boolean
151
- get() = isGrid && tile == GridTile.CARD
154
+ get() =
155
+ isGrid &&
156
+ when (tile) {
157
+ GridTile.CARD -> true
158
+ GridTile.PLAIN,
159
+ GridTile.IMAGE,
160
+ GridTile.CONDENSED -> false
161
+ }
162
+
163
+ /** Whether this container renders a tile family with a shape and an accessory slot. */
164
+ val isShaped: Boolean
165
+ get() =
166
+ isGrid &&
167
+ when (tile) {
168
+ GridTile.IMAGE,
169
+ GridTile.CONDENSED -> true
170
+ GridTile.PLAIN,
171
+ GridTile.CARD -> false
172
+ }
152
173
 
153
174
  /**
154
175
  * The effective style, for the message — in the wire spelling the author types ([toWireString]
@@ -82,6 +82,7 @@ internal fun String?.toImageShape(): ImageShape? =
82
82
  internal fun GridTile.toWireString(): String =
83
83
  when (this) {
84
84
  GridTile.PLAIN -> "plain"
85
+ GridTile.IMAGE -> "image"
85
86
  GridTile.CARD -> "card"
86
87
  GridTile.CONDENSED -> "condensed"
87
88
  }
@@ -89,6 +90,7 @@ internal fun GridTile.toWireString(): String =
89
90
  internal fun String?.toGridTile(): GridTile? =
90
91
  when (this?.takeIf { it.isNotEmpty() }?.lowercase()) {
91
92
  "plain" -> GridTile.PLAIN
93
+ "image" -> GridTile.IMAGE
92
94
  "card" -> GridTile.CARD
93
95
  "condensed" -> GridTile.CONDENSED
94
96
  else -> null
@@ -100,6 +100,7 @@ class InertStyleDiagnosticTest {
100
100
  @Test
101
101
  fun `imageShape needs a shaped tile`() {
102
102
  val list = sectionStyle(imageShape = ImageShape.CIRCULAR)
103
+ val plain = sectionStyle(display = StyleDisplay.GRID, imageShape = ImageShape.CIRCULAR)
103
104
  val cards =
104
105
  sectionStyle(
105
106
  display = StyleDisplay.GRID,
@@ -112,26 +113,49 @@ class InertStyleDiagnosticTest {
112
113
  gridTile = GridTile.CONDENSED,
113
114
  imageShape = ImageShape.CIRCULAR,
114
115
  )
116
+ val image =
117
+ sectionStyle(
118
+ display = StyleDisplay.GRID,
119
+ gridTile = GridTile.IMAGE,
120
+ imageShape = ImageShape.CIRCULAR,
121
+ )
115
122
  assertEquals(1, findings(listOf(section(style = list))).size)
123
+ assertEquals(1, findings(listOf(section(style = plain))).size)
116
124
  assertEquals(1, findings(listOf(section(style = cards))).size)
117
- // Plain and condensed tiles both take a shape.
118
125
  assertTrue(findings(listOf(section(style = condensed))).isEmpty())
126
+ assertTrue(findings(listOf(section(style = image))).isEmpty())
119
127
  }
120
128
 
121
129
  @Test
122
- fun `accessorySymbol is inert only on cards`() {
130
+ fun `accessorySymbol needs an accessory slot`() {
123
131
  val rows = sectionStyle(accessorySymbol = "lock.fill")
132
+ val condensed =
133
+ sectionStyle(
134
+ display = StyleDisplay.GRID,
135
+ gridTile = GridTile.CONDENSED,
136
+ accessorySymbol = "lock.fill",
137
+ )
138
+ val image =
139
+ sectionStyle(
140
+ display = StyleDisplay.GRID,
141
+ gridTile = GridTile.IMAGE,
142
+ accessorySymbol = "lock.fill",
143
+ )
144
+ val plain = sectionStyle(display = StyleDisplay.GRID, accessorySymbol = "lock.fill")
124
145
  val cards =
125
146
  sectionStyle(
126
147
  display = StyleDisplay.GRID,
127
148
  gridTile = GridTile.CARD,
128
149
  accessorySymbol = "lock.fill",
129
150
  )
130
- // List rows draw accessories; card elements have no slot for one.
131
151
  assertTrue(findings(listOf(section(style = rows))).isEmpty())
132
- val found = findings(listOf(section(style = cards)))
133
- assertEquals(1, found.size)
134
- assertTrue(found[0], found[0].contains("no accessory slot"))
152
+ assertTrue(findings(listOf(section(style = condensed))).isEmpty())
153
+ assertTrue(findings(listOf(section(style = image))).isEmpty())
154
+ for (style in listOf(plain, cards)) {
155
+ val found = findings(listOf(section(style = style)))
156
+ assertEquals(1, found.size)
157
+ assertTrue(found[0], found[0].contains("accessory slot"))
158
+ }
135
159
  }
136
160
 
137
161
  @Test
@@ -167,10 +191,10 @@ class InertStyleDiagnosticTest {
167
191
  }
168
192
 
169
193
  @Test
170
- fun `a track's own shape is live in a plain grid`() {
194
+ fun `a track's own shape is live in a shaped grid`() {
171
195
  val grid =
172
196
  section(
173
- style = sectionStyle(display = StyleDisplay.GRID),
197
+ style = sectionStyle(display = StyleDisplay.GRID, gridTile = GridTile.IMAGE),
174
198
  children = arrayOf(track(style = trackStyle(imageShape = ImageShape.CIRCULAR))),
175
199
  )
176
200
  assertTrue(findings(listOf(grid)).isEmpty())
@@ -1,5 +1,7 @@
1
1
  package com.audiobrowser.browser
2
2
 
3
+ import com.audiobrowser.util.toGridTile
4
+ import com.audiobrowser.util.toWireString
3
5
  import com.margelo.nitro.audiobrowser.ArtworkRendering
4
6
  import com.margelo.nitro.audiobrowser.CardImage
5
7
  import com.margelo.nitro.audiobrowser.GridTile
@@ -134,6 +136,25 @@ class JsonStyleTest {
134
136
  assertEquals(CardImage.BACKGROUND, style?.cardImage)
135
137
  }
136
138
 
139
+ @Test
140
+ fun `decodes the image family`() {
141
+ val resolved =
142
+ json.decodeFromString<JsonResolvedTrack>(
143
+ """{"path":"/home","title":"Home","sections":[
144
+ {"style":{"display":"grid","gridTile":"image","imageShape":"circular"},
145
+ "children":[{"title":"C","src":"s"}]}
146
+ ]}"""
147
+ )
148
+ val style = resolved.toNitro().sections?.first()?.style
149
+ assertEquals(GridTile.IMAGE, style?.gridTile)
150
+ assertEquals(ImageShape.CIRCULAR, style?.imageShape)
151
+ }
152
+
153
+ @Test
154
+ fun `every gridTile survives the wire`() {
155
+ GridTile.entries.forEach { assertEquals(it, it.toWireString().toGridTile()) }
156
+ }
157
+
137
158
  @Test
138
159
  fun `unknown gridTile decodes as no declaration`() {
139
160
  val resolved =
@@ -132,6 +132,12 @@ class StyleResolverTest {
132
132
  page = sectionStyle(gridTile = GridTile.CARD),
133
133
  )
134
134
  assertEquals(GridTile.PLAIN, overridden.gridTile)
135
+ val image =
136
+ StyleResolver.sectionStyle(
137
+ section = sectionStyle(gridTile = GridTile.IMAGE),
138
+ page = sectionStyle(gridTile = GridTile.CARD),
139
+ )
140
+ assertEquals(GridTile.IMAGE, image.gridTile)
135
141
  }
136
142
 
137
143
  @Test
@@ -69,8 +69,8 @@ enum InertStyleDiagnostic {
69
69
  func isInert(in context: Context) -> Bool {
70
70
  switch self {
71
71
  case .gridWrap, .gridTile: !context.isGrid
72
- case .imageShape: !context.isGrid || context.isCard
73
- case .accessorySymbol: context.isCard
72
+ case .imageShape: !context.isShaped
73
+ case .accessorySymbol: context.isGrid && !context.isShaped
74
74
  case .cardTint, .cardImage: !context.isCard
75
75
  }
76
76
  }
@@ -79,8 +79,8 @@ enum InertStyleDiagnostic {
79
79
  var rule: String {
80
80
  switch self {
81
81
  case .gridWrap, .gridTile: "`\(rawValue)` renders only in a grid"
82
- case .imageShape: "only plain and condensed tiles have a shape"
83
- case .accessorySymbol: "card tiles have no accessory slot"
82
+ case .imageShape: "only image and condensed tiles have a shape"
83
+ case .accessorySymbol: "among grid tiles only image and condensed ones have an accessory slot"
84
84
  case .cardTint, .cardImage: "the card properties render only under `gridTile: 'card'`"
85
85
  }
86
86
  }
@@ -90,9 +90,9 @@ enum InertStyleDiagnostic {
90
90
  case .gridWrap, .gridTile:
91
91
  "Declare `display: 'grid'` on the section or the page, or drop `\(rawValue)`."
92
92
  case .imageShape:
93
- "Render the items as plain or condensed grid tiles, or drop `imageShape`."
93
+ "Use `gridTile: 'image'` or `'condensed'` in a grid, or drop `imageShape`."
94
94
  case .accessorySymbol:
95
- "Use `gridTile: 'plain'` or `'condensed'`, or drop `accessorySymbol`."
95
+ "Use `gridTile: 'image'` or `'condensed'`, or a list, or drop `accessorySymbol`."
96
96
  case .cardTint, .cardImage:
97
97
  "Declare `display: 'grid'` with `gridTile: 'card'`, or drop `\(rawValue)`."
98
98
  }
@@ -129,8 +129,25 @@ enum InertStyleDiagnostic {
129
129
  }
130
130
 
131
131
  /// Whether the card treatment is what this container renders — the
132
- /// condition the `card*` family is named after.
133
- var isCard: Bool { isGrid && tile == .card }
132
+ /// condition the `card*` family is named after. Exhaustive, like
133
+ /// `isShaped`: a new tile kind has to be ruled on here.
134
+ var isCard: Bool {
135
+ guard isGrid else { return false }
136
+ return switch tile {
137
+ case .card: true
138
+ case .plain, .image, .condensed: false
139
+ }
140
+ }
141
+
142
+ /// Whether this container renders a tile family with a shape and an
143
+ /// accessory slot.
144
+ var isShaped: Bool {
145
+ guard isGrid else { return false }
146
+ return switch tile {
147
+ case .image, .condensed: true
148
+ case .plain, .card: false
149
+ }
150
+ }
134
151
 
135
152
  /// The effective style, for the message — in the wire spelling the author
136
153
  /// types (`stringValue` is the generated enum's own mapping). `gridTile`
@@ -41,11 +41,23 @@ enum SectionPresentation: Equatable {
41
41
  return symbol
42
42
  }
43
43
 
44
+ /// The title a tile shelf draws as the list section's header; nil when
45
+ /// the image row's own text carries it — here, not in the CarPlay factory,
46
+ /// so the rule is testable off-device. CarPlay draws a chevron beside an
47
+ /// image row's text whether or not the row has a handler, so only a shelf
48
+ /// with a "view all" `path` keeps its title there.
49
+ ///
50
+ /// - Parameter supportsElements: whether the platform has the iOS 26
51
+ /// element API; the legacy image row's text is not optional.
52
+ static func tileSectionHeader(_ section: Section, supportsElements: Bool) -> String? {
53
+ supportsElements && section.path == nil ? section.title : nil
54
+ }
55
+
44
56
  /// The element family a tile presentation renders on iOS 26+ — here, not
45
57
  /// in the CarPlay factory, so the style→family mapping is testable
46
- /// off-device. `gridTile` picks the family (plain by default); the plain
47
- /// single-line shelf keeps row elements, the one plain element with a
48
- /// subtitle slot. Pre-26 has no element API at all: the treatment drops
58
+ /// off-device. `gridTile` picks the family (plain by default), in either
59
+ /// wrap mode: a plain tile is a row element (title + subtitle), an image
60
+ /// tile an image-grid element (title, shape, accessory). Pre-26 has no element API at all: the treatment drops
49
61
  /// with the legacy image row (decorations drop before layout).
50
62
  enum TileElementFamily: Equatable {
51
63
  case rowElements
@@ -54,11 +66,12 @@ enum SectionPresentation: Equatable {
54
66
  case condensedElements
55
67
  }
56
68
 
57
- static func tileFamily(for style: SectionStyle?, singleLine: Bool) -> TileElementFamily {
69
+ static func tileFamily(for style: SectionStyle?) -> TileElementFamily {
58
70
  switch style?.gridTile {
71
+ case .image: .imageGridElements
59
72
  case .card: .cardElements
60
73
  case .condensed: .condensedElements
61
- case .plain, nil: singleLine ? .rowElements : .imageGridElements
74
+ case .plain, nil: .rowElements
62
75
  }
63
76
  }
64
77
  }
@@ -12,6 +12,10 @@ import os.log
12
12
  /// preconditions are checked up front, the underlying call always gets a
13
13
  /// completion, and every failure is logged, with an optional caller completion
14
14
  /// for call sites that chain on the result.
15
+ ///
16
+ /// A push onto a full stack replaces the top template (`TopTemplateReplacer`).
17
+ /// The stack mutations wait for that replacement to settle before they run,
18
+ /// so none of them lands between its pop and its push.
15
19
  @MainActor
16
20
  extension CPInterfaceController {
17
21
  private static let stackLogger = Logger(subsystem: "com.audiobrowser", category: "TemplateStack")
@@ -26,9 +30,43 @@ extension CPInterfaceController {
26
30
  /// push — taking down the whole app, not just the CarPlay scene.
27
31
  private static let maximumStackDepth = 5
28
32
 
33
+ private static var topReplacerKey: UInt8 = 0
34
+
35
+ private var topReplacer: TopTemplateReplacer {
36
+ if let replacer = objc_getAssociatedObject(self, &Self.topReplacerKey) as? TopTemplateReplacer {
37
+ return replacer
38
+ }
39
+ let replacer = TopTemplateReplacer()
40
+ objc_setAssociatedObject(self, &Self.topReplacerKey, replacer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
41
+ return replacer
42
+ }
43
+
44
+ private func whenStackSettled(_ mutation: @escaping (CPInterfaceController) -> Void) {
45
+ topReplacer.whenSettled { [weak self] in
46
+ guard let self else { return }
47
+ mutation(self)
48
+ }
49
+ }
50
+
51
+ /// Pushes `template`, skipping the raise case (already in the hierarchy). At
52
+ /// the depth limit it replaces the top template.
53
+ func safePush(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
54
+ whenStackSettled { controller in
55
+ let templates = controller.templates
56
+ guard templates.count >= Self.maximumStackDepth, !templates.contains(where: { $0 === template }) else {
57
+ controller.guardedPush(template, animated: animated, completion: completion)
58
+ return
59
+ }
60
+ controller.topReplacer.push(
61
+ template, replacingTopOf: GuardedTemplateStack(controller: controller), animated: animated,
62
+ completion: completion,
63
+ )
64
+ }
65
+ }
66
+
29
67
  /// Pushes `template`, skipping the raise cases (already in the hierarchy,
30
68
  /// stack at the depth limit).
31
- func safePush(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
69
+ fileprivate func guardedPush(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)?) {
32
70
  guard !templates.contains(where: { $0 === template }) else {
33
71
  Self.stackLogger.warning("push skipped: \(Self.name(of: template)) is already in the stack")
34
72
  completion?(false, nil)
@@ -48,7 +86,7 @@ extension CPInterfaceController {
48
86
  }
49
87
 
50
88
  /// Pops the top template, skipping the raise case (nothing above the root).
51
- func safePop(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
89
+ fileprivate func guardedPop(animated: Bool, completion: ((Bool, Error?) -> Void)?) {
52
90
  guard templates.count > 1 else {
53
91
  Self.stackLogger.warning("pop skipped: already at the root template")
54
92
  completion?(false, nil)
@@ -62,41 +100,54 @@ extension CPInterfaceController {
62
100
  }
63
101
  }
64
102
 
103
+ /// Pops the top template, skipping the raise case (nothing above the root).
104
+ func safePop(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
105
+ whenStackSettled { controller in
106
+ controller.guardedPop(animated: animated, completion: completion)
107
+ }
108
+ }
109
+
65
110
  /// Pops back to `template`, skipping the raise case (not in the hierarchy).
66
111
  /// A no-op success when it is already the top template.
67
112
  func safePop(to template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
68
- guard templates.contains(where: { $0 === template }) else {
69
- Self.stackLogger.warning("pop(to:) skipped: \(Self.name(of: template)) is not in the stack")
70
- completion?(false, nil)
71
- return
72
- }
73
- guard topTemplate !== template else {
74
- completion?(true, nil)
75
- return
76
- }
77
- pop(to: template, animated: animated) { popped, error in
78
- if let error {
79
- Self.stackLogger.error("pop(to:) \(Self.name(of: template)) failed: \(error.localizedDescription)")
113
+ whenStackSettled { controller in
114
+ guard controller.templates.contains(where: { $0 === template }) else {
115
+ Self.stackLogger.warning("pop(to:) skipped: \(Self.name(of: template)) is not in the stack")
116
+ completion?(false, nil)
117
+ return
118
+ }
119
+ guard controller.topTemplate !== template else {
120
+ completion?(true, nil)
121
+ return
122
+ }
123
+ controller.pop(to: template, animated: animated) { popped, error in
124
+ if let error {
125
+ Self.stackLogger.error("pop(to:) \(Self.name(of: template)) failed: \(error.localizedDescription)")
126
+ }
127
+ completion?(popped, error)
80
128
  }
81
- completion?(popped, error)
82
129
  }
83
130
  }
84
131
 
85
132
  func safePopToRoot(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
86
- popToRootTemplate(animated: animated) { popped, error in
87
- if let error {
88
- Self.stackLogger.error("popToRootTemplate failed: \(error.localizedDescription)")
133
+ whenStackSettled { controller in
134
+ controller.popToRootTemplate(animated: animated) { popped, error in
135
+ if let error {
136
+ Self.stackLogger.error("popToRootTemplate failed: \(error.localizedDescription)")
137
+ }
138
+ completion?(popped, error)
89
139
  }
90
- completion?(popped, error)
91
140
  }
92
141
  }
93
142
 
94
143
  func safeSetRoot(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
95
- setRootTemplate(template, animated: animated) { set, error in
96
- if let error {
97
- Self.stackLogger.error("setRootTemplate \(Self.name(of: template)) failed: \(error.localizedDescription)")
144
+ whenStackSettled { controller in
145
+ controller.setRootTemplate(template, animated: animated) { set, error in
146
+ if let error {
147
+ Self.stackLogger.error("setRootTemplate \(Self.name(of: template)) failed: \(error.localizedDescription)")
148
+ }
149
+ completion?(set, error)
98
150
  }
99
- completion?(set, error)
100
151
  }
101
152
  }
102
153
 
@@ -124,3 +175,22 @@ extension CPInterfaceController {
124
175
  }
125
176
  }
126
177
  }
178
+
179
+ /// The guarded pop and push a replacement is made of, so a push that still
180
+ /// cannot fit skips and logs rather than raising.
181
+ @MainActor
182
+ private struct GuardedTemplateStack: TemplateStack {
183
+ let controller: CPInterfaceController
184
+
185
+ var templates: [CPTemplate] {
186
+ controller.templates
187
+ }
188
+
189
+ func popTop(completion: @escaping (Bool, Error?) -> Void) {
190
+ controller.guardedPop(animated: false, completion: completion)
191
+ }
192
+
193
+ func push(_ template: CPTemplate, animated: Bool, completion: @escaping (Bool, Error?) -> Void) {
194
+ controller.guardedPush(template, animated: animated, completion: completion)
195
+ }
196
+ }