@craft-native/ios 0.0.487 → 0.0.491

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
@@ -546,6 +546,21 @@ function hybridConfigProblems(config) {
546
546
  if (!matchNativePath(config.nativeScreens, start, config.nativeScreenConstraints))
547
547
  problems.push(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
548
548
  }
549
+ const gate = config.nativeGate;
550
+ if (gate !== undefined) {
551
+ if (typeof gate.redirect !== "string" || normalizeNativePath(gate.redirect) !== gate.redirect)
552
+ problems.push(`nativeGate.redirect "${String(gate.redirect)}" must be an absolute path with no query, hash or trailing slash.`);
553
+ else if (hasNativeRoutes(config) && !matchNativePath(config.nativeScreens, gate.redirect, config.nativeScreenConstraints))
554
+ problems.push(`nativeGate.redirect "${gate.redirect}" is not a nativeScreens path; a signed-out launch would have nothing to show.`);
555
+ for (const path of gate.publicPaths ?? []) {
556
+ if (typeof path !== "string" || normalizeNativePath(path) !== path)
557
+ problems.push(`nativeGate.publicPaths "${String(path)}" must be an absolute path with no query, hash or trailing slash.`);
558
+ }
559
+ if (typeof gate.redirect === "string" && !(gate.publicPaths ?? []).some((path) => gate.redirect === path || gate.redirect.startsWith(`${path}/`)))
560
+ problems.push(`nativeGate.redirect "${gate.redirect}" must also be listed in nativeGate.publicPaths.`);
561
+ if (gate.token !== undefined && (typeof gate.token !== "string" || !gate.token))
562
+ problems.push("nativeGate.token must be the secure-storage key the sign-in lives under.");
563
+ }
549
564
  if (config.nativeApp !== undefined) {
550
565
  if (typeof config.nativeApp !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(config.nativeApp))
551
566
  problems.push(`nativeApp "${String(config.nativeApp)}" must be a route name from the native bundle.`);
package/dist/index.js CHANGED
@@ -740,6 +740,21 @@ function hybridConfigProblems(config) {
740
740
  if (!matchNativePath(config.nativeScreens, start, config.nativeScreenConstraints))
741
741
  problems.push(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
742
742
  }
743
+ const gate = config.nativeGate;
744
+ if (gate !== undefined) {
745
+ if (typeof gate.redirect !== "string" || normalizeNativePath(gate.redirect) !== gate.redirect)
746
+ problems.push(`nativeGate.redirect "${String(gate.redirect)}" must be an absolute path with no query, hash or trailing slash.`);
747
+ else if (hasNativeRoutes(config) && !matchNativePath(config.nativeScreens, gate.redirect, config.nativeScreenConstraints))
748
+ problems.push(`nativeGate.redirect "${gate.redirect}" is not a nativeScreens path; a signed-out launch would have nothing to show.`);
749
+ for (const path of gate.publicPaths ?? []) {
750
+ if (typeof path !== "string" || normalizeNativePath(path) !== path)
751
+ problems.push(`nativeGate.publicPaths "${String(path)}" must be an absolute path with no query, hash or trailing slash.`);
752
+ }
753
+ if (typeof gate.redirect === "string" && !(gate.publicPaths ?? []).some((path) => gate.redirect === path || gate.redirect.startsWith(`${path}/`)))
754
+ problems.push(`nativeGate.redirect "${gate.redirect}" must also be listed in nativeGate.publicPaths.`);
755
+ if (gate.token !== undefined && (typeof gate.token !== "string" || !gate.token))
756
+ problems.push("nativeGate.token must be the secure-storage key the sign-in lives under.");
757
+ }
743
758
  if (config.nativeApp !== undefined) {
744
759
  if (typeof config.nativeApp !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(config.nativeApp))
745
760
  problems.push(`nativeApp "${String(config.nativeApp)}" must be a route name from the native bundle.`);
@@ -317,6 +317,28 @@ export interface CraftConfig {
317
317
  * `craftsession` event on the page.
318
318
  */
319
319
  sessionTokenKey?: string;
320
+ /**
321
+ * Which screens a signed-out launch may start on.
322
+ *
323
+ * The shell resolves this while it is deciding the start path, before any
324
+ * screen is built. An app script cannot do the same job: it is started from
325
+ * the first frame's completion block, so by the time it can read a token the
326
+ * signed-in screen it would have prevented has already been painted — with
327
+ * the previous session's kept snapshot in it when the launch follows an
328
+ * expired token rather than a sign-out.
329
+ *
330
+ * ```ts
331
+ * nativeGate: {
332
+ * publicPaths: ['/m/login', '/m/signup', '/m/store'],
333
+ * redirect: '/m/login',
334
+ * }
335
+ * ```
336
+ *
337
+ * `publicPaths` admits a listed path and anything under it, so `/m/store`
338
+ * covers `/m/store/42` and not `/m/storefront`. `token` defaults to
339
+ * `sessionTokenKey`, the item `craft.session` already follows.
340
+ */
341
+ nativeGate?: NativeGate;
320
342
  /**
321
343
  * Crash and error reporting to bughq (CraftCrashReporter.swift): crashes
322
344
  * and hangs (signal handlers, NSException, MetricKit), uncaught JavaScript
@@ -325,6 +347,25 @@ export interface CraftConfig {
325
347
  */
326
348
  crashReporting?: CraftCrashReporting;
327
349
  }
350
+ /**
351
+ * An auth gate the shell applies while resolving the start path.
352
+ *
353
+ * See `CraftConfig.nativeGate`. The allowlist is static data in the apps that
354
+ * need this, so emitting it into the config costs nothing at runtime.
355
+ */
356
+ export interface NativeGate {
357
+ /**
358
+ * Paths a signed-out launch may start on, each admitting what sits under it.
359
+ *
360
+ * Not `public`: that is a Swift keyword, and remapping it would make the
361
+ * stored key differ from the property the host decodes it into.
362
+ */
363
+ publicPaths?: string[];
364
+ /** Where a signed-out launch starts instead. Must itself be public. */
365
+ redirect: string;
366
+ /** The secure-storage key holding the sign-in. Defaults to `sessionTokenKey`. */
367
+ token?: string;
368
+ }
328
369
  export interface CraftCrashReporting {
329
370
  /** The bughq project's public ingest key. Ships in the app; it is not a secret. */
330
371
  key: string;
@@ -362,7 +403,7 @@ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' |
362
403
  */
363
404
  export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
364
405
  /** What is wrong with a config's hybrid and tab settings, one sentence each. */
365
- export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'nativeScreenConstraints' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp' | 'nativeScriptThread' | 'notificationCategories'>): string[];
406
+ export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'nativeScreenConstraints' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp' | 'nativeScriptThread' | 'notificationCategories' | 'nativeGate'>): string[];
366
407
  /**
367
408
  * The builder installs a directory of route bundles (stx's `routesDir`) as
368
409
  * well as the one-file bundle, and the app runs each screen from its own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.487",
3
+ "version": "0.0.491",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2046,6 +2046,9 @@ struct CraftConfig: Codable {
2046
2046
  /// (`auth.token` unless set): `craft.session` follows it, and a change of
2047
2047
  /// it is `SESSION_CHANGED` on every screen (CraftSession).
2048
2048
  var sessionTokenKey: String? = nil
2049
+ /// Which screens a signed-out launch may start on, resolved before the
2050
+ /// first frame is built (CraftHybrid.configure).
2051
+ var nativeGate: NativeGate? = nil
2049
2052
  /// The bundle route of the app script: a screen-less script that runs for
2050
2053
  /// the app's whole life and hears the app's events (CraftHybrid.swift).
2051
2054
  var nativeApp: String? = nil
@@ -2057,6 +2060,33 @@ struct CraftConfig: Codable {
2057
2060
  /// unless the config names a project key.
2058
2061
  var crashReporting: CrashReporting? = nil
2059
2062
 
2063
+ /// An auth gate the shell applies while resolving the start path.
2064
+ ///
2065
+ /// An app script cannot do this. It is started from the first frame's
2066
+ /// `CATransaction` completion block, so by the time it can read a token
2067
+ /// the signed-in screen it would have prevented is already on screen —
2068
+ /// with the previous session's kept snapshot in it when the launch
2069
+ /// follows an expired token rather than a sign-out (#499). The check has
2070
+ /// to belong to whoever decides the start path, and that is the shell.
2071
+ struct NativeGate: Codable {
2072
+ /// Paths that may be shown without a sign-in. A path is public when
2073
+ /// it is listed, or when it sits under a listed one: `/m/store`
2074
+ /// admits `/m/store/42` and not `/m/storefront`.
2075
+ ///
2076
+ /// Spelled `publicPaths` rather than `public` in the stored config
2077
+ /// too. `public` is a Swift keyword, and remapping it through
2078
+ /// `CodingKeys` would make the property name and the stored key
2079
+ /// differ — which is exactly what the Zig conformance mirror checks,
2080
+ /// and a divergence worth not having for the sake of one word.
2081
+ var publicPaths: [String]? = nil
2082
+ /// Where a signed-out launch starts instead.
2083
+ var redirect: String? = nil
2084
+ /// The secure-storage key holding the sign-in. Defaults to
2085
+ /// `sessionTokenKey`, which is the same item `craft.session` follows,
2086
+ /// so an app that already declares one need not repeat it.
2087
+ var token: String? = nil
2088
+ }
2089
+
2060
2090
  struct Tab: Codable {
2061
2091
  var id: String
2062
2092
  var title: String
@@ -425,6 +425,7 @@ final class CraftHybrid: NSObject {
425
425
  : "[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; the app has no screens to show")
426
426
  }
427
427
  isEnabled = wanted && Self.hasBundle
428
+ applyGate(config)
428
429
  if isEnabled, !hasWeb, routes.match(startPath) == nil {
429
430
  // No web view to show it in: the first tab a screen draws.
430
431
  let fallback = (config.tabs ?? []).map(\.id).first { routes.match($0) != nil }
@@ -458,6 +459,54 @@ final class CraftHybrid: NSObject {
458
459
  DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { self.startAppScript() }
459
460
  }
460
461
 
462
+ /// Whether `path` may be shown to someone who is not signed in.
463
+ ///
464
+ /// Listed, or under something listed: `/m/store` admits `/m/store/42` and
465
+ /// not `/m/storefront`. Prefixes are compared with the separator attached
466
+ /// so a listed path cannot admit a sibling that merely starts with its
467
+ /// letters.
468
+ static func gateAdmits(_ path: String, public allowed: [String]) -> Bool {
469
+ allowed.contains { entry in
470
+ var listed = entry
471
+ while listed.count > 1, listed.hasSuffix("/") { listed.removeLast() }
472
+ guard !listed.isEmpty else { return false }
473
+ if path == listed { return true }
474
+ // "/" is the root path, not a prefix of everything. Reading it as
475
+ // one would admit every path, which is a gate that is not a gate.
476
+ guard listed != "/" else { return false }
477
+ return path.hasPrefix(listed + "/")
478
+ }
479
+ }
480
+
481
+ /// Send a signed-out launch to the gate's redirect before anything is built.
482
+ ///
483
+ /// This runs while `startPath` is still being resolved, which is the
484
+ /// whole point: the app script that would otherwise do it does not start
485
+ /// until the first frame has been committed, so it can only push sign-in
486
+ /// on top of a screen that has already been drawn with the previous
487
+ /// session's data in it (#499).
488
+ ///
489
+ /// The Keychain read is synchronous and can cost 100-900ms cold. It is
490
+ /// not an extra read: every signed-in screen reads this same item when it
491
+ /// opens, and `CraftKeychain` caches it, so the gate moves the app's first
492
+ /// read earlier rather than adding one. Only apps that declare a gate pay
493
+ /// it at all.
494
+ private func applyGate(_ config: CraftConfig) {
495
+ guard let gate = config.nativeGate, let redirect = gate.redirect, !redirect.isEmpty else { return }
496
+ guard !Self.gateAdmits(startPath, public: gate.publicPaths ?? []) else { return }
497
+ let key = gate.token ?? config.sessionTokenKey ?? "auth.token"
498
+ guard (CraftNativeActions.webSecureValue(forKey: key) ?? "").isEmpty else { return }
499
+ // A redirect nothing can draw would trade a private screen for a
500
+ // blank one. Say so and leave the start path alone; the fallback
501
+ // below then treats it as any other unroutable start.
502
+ if isEnabled, !hasWeb, routes.match(redirect) == nil {
503
+ NSLog("[craft hybrid] nativeGate redirect %@ has no native screen; the gate did not run", redirect)
504
+ return
505
+ }
506
+ NSLog("[craft hybrid] no sign-in under %@: starting on %@ instead of %@", key, redirect, startPath)
507
+ startPath = redirect
508
+ }
509
+
461
510
  /// A silent push or a background refresh is waiting for the app script,
462
511
  /// with iOS's clock running: it starts now rather than after a frame,
463
512
  /// which a launch in the background never draws.
@@ -2001,7 +2001,15 @@ enum CraftSnapshots {
2001
2001
  }
2002
2002
  lock.unlock()
2003
2003
  DispatchQueue.main.async {
2004
- if ok { NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name]) }
2004
+ // Posted whether or not the write landed. A screen displays its
2005
+ // checkpoint optimistically, and this notification carries only
2006
+ // the name, so a reader answers it by calling `read` — which
2007
+ // after a failure finds the previous value, because `pending`
2008
+ // was cleared above and the cache was only refilled on success.
2009
+ // Posting only on success left a failed checkpoint on screen as
2010
+ // though it had been kept; Android already restores it
2011
+ // (`changed("snapshots", name, if (ok) json else previous, null)`).
2012
+ NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name])
2005
2013
  completion?(ok)
2006
2014
  }
2007
2015
  }
@@ -12,6 +12,7 @@ import OSLog
12
12
  import Security
13
13
  import SwiftUI
14
14
  import UIKit
15
+ import UIKit.UIGestureRecognizerSubclass
15
16
 
16
17
  /// A menu belongs to its native view, not to coordinates in a web page.
17
18
  final class CraftNativeContextMenu: NSObject, UIContextMenuInteractionDelegate {
@@ -5195,6 +5196,23 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
5195
5196
  /// Native inspection of an SVG plot. Coordinates are fractions of the plot's
5196
5197
  /// bounds; labels and values are already localized by the application. No JS
5197
5198
  /// callback or bridge round trip is needed while a finger moves.
5199
+ /// Reports where each touch starts and then fails, so it observes a window
5200
+ /// without ever taking a touch from the view underneath.
5201
+ final class CraftTouchBeganRecognizer: UIGestureRecognizer {
5202
+ private let began: (CGPoint) -> Void
5203
+ init(began: @escaping (CGPoint) -> Void) {
5204
+ self.began = began
5205
+ super.init(target: nil, action: nil)
5206
+ cancelsTouchesInView = false
5207
+ delaysTouchesBegan = false
5208
+ delaysTouchesEnded = false
5209
+ }
5210
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
5211
+ state = .failed
5212
+ if let touch = touches.first { began(touch.location(in: view)) }
5213
+ }
5214
+ }
5215
+
5198
5216
  final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5199
5217
  struct Reading {
5200
5218
  let key: String
@@ -5214,6 +5232,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5214
5232
  private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
5215
5233
  private lazy var pan = UIPanGestureRecognizer(target: self, action: #selector(panned(_:)))
5216
5234
  private lazy var hold = UILongPressGestureRecognizer(target: self, action: #selector(held(_:)))
5235
+ private var outside: CraftTouchBeganRecognizer?
5217
5236
 
5218
5237
  init(view: UIView) {
5219
5238
  self.view = view
@@ -5241,6 +5260,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5241
5260
  color: (point["color"] as? String).flatMap { UIColor(css: $0) } ?? .systemBlue, label: label, rows: rows)
5242
5261
  }
5243
5262
  selectedIndex = -1
5263
+ unwatchOutside()
5244
5264
  view?.isUserInteractionEnabled = !readings.isEmpty
5245
5265
  if readings.isEmpty { view?.accessibilityTraits.remove(.adjustable); view?.accessibilityValue = nil }
5246
5266
  [tap, pan, hold].forEach { $0.isEnabled = !readings.isEmpty }
@@ -5270,6 +5290,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5270
5290
  }
5271
5291
 
5272
5292
  @discardableResult func dismiss() -> Bool {
5293
+ unwatchOutside()
5273
5294
  guard selectedIndex >= 0 else { return false }
5274
5295
  selectedIndex = -1
5275
5296
  configureAccessibility()
@@ -5277,20 +5298,47 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5277
5298
  return true
5278
5299
  }
5279
5300
 
5280
- private func select(at point: CGPoint) {
5281
- guard let view, view.bounds.width > 0 else { return }
5301
+ private func nearest(at point: CGPoint) -> Int? {
5302
+ guard let view, view.bounds.width > 0 else { return nil }
5282
5303
  let fraction = max(0, min(1, point.x / view.bounds.width))
5283
- if let nearest = readings.indices.min(by: { abs(readings[$0].x - fraction) < abs(readings[$1].x - fraction) }) { select(nearest) }
5304
+ return readings.indices.min(by: { abs(readings[$0].x - fraction) < abs(readings[$1].x - fraction) })
5305
+ }
5306
+ private func select(at point: CGPoint) {
5307
+ if let index = nearest(at: point) { select(index); watchOutside() }
5308
+ }
5309
+ /// A tap pins the reading under it. Tapping the pinned reading again puts
5310
+ /// it away, so a reading can always be closed where it was opened.
5311
+ func tap(at point: CGPoint) {
5312
+ guard let index = nearest(at: point) else { return }
5313
+ if index == selectedIndex { dismiss() } else { select(at: point) }
5314
+ }
5315
+ /// A drag or a hold reads the chart only while the finger is down.
5316
+ func scrub(_ state: UIGestureRecognizer.State, at point: CGPoint) {
5317
+ switch state {
5318
+ case .began, .changed: select(at: point)
5319
+ case .ended, .cancelled, .failed: dismiss()
5320
+ default: break
5321
+ }
5284
5322
  }
5285
- @objc private func tapped(_ gesture: UITapGestureRecognizer) { select(at: gesture.location(in: view)) }
5286
- @objc private func panned(_ gesture: UIPanGestureRecognizer) {
5287
- if gesture.state == .began || gesture.state == .changed { select(at: gesture.location(in: view)) }
5288
- if gesture.state == .cancelled { dismiss() }
5323
+ /// A touch that starts anywhere off the chart closes a pinned reading. The
5324
+ /// watcher never recognises, so the touch still reaches what it landed on.
5325
+ func touchBegan(inWindow point: CGPoint) {
5326
+ guard let view, let window = view.window else { return }
5327
+ if !view.bounds.contains(view.convert(point, from: window)) { dismiss() }
5289
5328
  }
5290
- @objc private func held(_ gesture: UILongPressGestureRecognizer) {
5291
- if gesture.state == .began || gesture.state == .changed { select(at: gesture.location(in: view)) }
5292
- if gesture.state == .cancelled { dismiss() }
5329
+ private func watchOutside() {
5330
+ guard outside == nil, let window = view?.window else { return }
5331
+ let watcher = CraftTouchBeganRecognizer { [weak self] point in self?.touchBegan(inWindow: point) }
5332
+ window.addGestureRecognizer(watcher)
5333
+ outside = watcher
5293
5334
  }
5335
+ private func unwatchOutside() {
5336
+ if let outside { outside.view?.removeGestureRecognizer(outside) }
5337
+ outside = nil
5338
+ }
5339
+ @objc private func tapped(_ gesture: UITapGestureRecognizer) { tap(at: gesture.location(in: view)) }
5340
+ @objc private func panned(_ gesture: UIPanGestureRecognizer) { scrub(gesture.state, at: gesture.location(in: view)) }
5341
+ @objc private func held(_ gesture: UILongPressGestureRecognizer) { scrub(gesture.state, at: gesture.location(in: view)) }
5294
5342
  func gestureRecognizerShouldBegin(_ gesture: UIGestureRecognizer) -> Bool {
5295
5343
  guard gesture === pan else { return true }
5296
5344
  let velocity = pan.velocity(in: view)
@@ -6002,9 +6050,23 @@ final class CraftNativeSolidSwitch: UIControl {
6002
6050
  return CGRect(x: min(max(x, track.minX), track.maxX - rect.width - stretch),
6003
6051
  y: rect.minY, width: rect.width + stretch, height: rect.height)
6004
6052
  }
6053
+ /// How much wider than tall the thumb is drawn.
6054
+ ///
6055
+ /// iOS 26 draws a wide pill where every system before it drew a circle,
6056
+ /// so a square thumb reads as a control from an older OS sitting among
6057
+ /// native ones. Measured on iOS 26.5 (iPhone 17 Pro simulator) by walking
6058
+ /// a real `UISwitch`: the control is 63x28 and its thumb 37x24, inset 2 on
6059
+ /// every side, travelling from x=2 to x=24.
6060
+ ///
6061
+ /// Expressed against the thumb's own height rather than as a fixed size,
6062
+ /// so it keeps following whatever the real `UISwitch` reports — which is
6063
+ /// where `naturalSize` already comes from. At the system's own 24-point
6064
+ /// thumb height this is exactly 37.
6065
+ private static let thumbAspect: CGFloat = 37.0 / 24.0
6005
6066
  private func thumbRect(at position: CGFloat) -> CGRect {
6006
6067
  let track = trackRect.insetBy(dx: 2, dy: 2)
6007
- return CGRect(x: track.minX + (track.width - track.height) * position, y: track.minY, width: track.height, height: track.height)
6068
+ let width = min(track.width, track.height * Self.thumbAspect)
6069
+ return CGRect(x: track.minX + (track.width - width) * position, y: track.minY, width: width, height: track.height)
6008
6070
  }
6009
6071
  override func draw(_ rect: CGRect) {
6010
6072
  let track = trackRect
@@ -6341,12 +6403,37 @@ private final class CraftNativeTextView: UITextView {
6341
6403
  if !isScrollEnabled, contentOffset != .zero { contentOffset = .zero }
6342
6404
  let inset = textContainerInset
6343
6405
  let padding = textContainer.lineFragmentPadding
6344
- placeholderLabel.frame = CGRect(
6345
- x: inset.left + padding,
6346
- y: inset.top,
6347
- width: max(0, bounds.width - inset.left - inset.right - padding * 2),
6348
- height: max(0, bounds.height - inset.top - inset.bottom)
6349
- )
6406
+ let measured = placeholderLabel
6407
+ .sizeThatFits(CGSize(width: Self.placeholderWidth(bounds: bounds, inset: inset, padding: padding),
6408
+ height: .greatestFiniteMagnitude))
6409
+ placeholderLabel.frame = Self.placeholderFrame(bounds: bounds, inset: inset, padding: padding,
6410
+ fittedHeight: measured.height)
6411
+ }
6412
+
6413
+ static func placeholderWidth(bounds: CGRect, inset: UIEdgeInsets, padding: CGFloat) -> CGFloat {
6414
+ max(0, bounds.width - inset.left - inset.right - padding * 2)
6415
+ }
6416
+
6417
+ /// Where the placeholder sits inside the box.
6418
+ ///
6419
+ /// Anchored at the top, and no taller than the text it holds. `UILabel`
6420
+ /// centres its text block vertically within its frame, while
6421
+ /// `UITextView` lays its glyphs out from the top, so a label handed the
6422
+ /// box's whole remaining height drew the placeholder of a four-row notes
6423
+ /// field halfway down it — nowhere near the caret that replaced it the
6424
+ /// moment the field was tapped (#501). The two rules only agreed when
6425
+ /// the box was exactly one line tall, which is why single-line fields
6426
+ /// looked right and every `textarea` did not. Given only the height its
6427
+ /// text needs, the label has no spare height to centre within.
6428
+ ///
6429
+ /// Clamped to what is left of the box so that a placeholder longer than
6430
+ /// the field stays inside it rather than drawing past the bottom.
6431
+ static func placeholderFrame(bounds: CGRect, inset: UIEdgeInsets, padding: CGFloat, fittedHeight: CGFloat) -> CGRect {
6432
+ let available = max(0, bounds.height - inset.top - inset.bottom)
6433
+ return CGRect(x: inset.left + padding,
6434
+ y: inset.top,
6435
+ width: placeholderWidth(bounds: bounds, inset: inset, padding: padding),
6436
+ height: min(max(0, fittedHeight), available))
6350
6437
  }
6351
6438
 
6352
6439
  func refreshPlaceholder() {
@@ -7037,6 +7124,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7037
7124
 
7038
7125
  override func viewWillDisappear(_ animated: Bool) {
7039
7126
  super.viewWillDisappear(animated)
7127
+ // A screen does not take the keyboard with it. Its field is still
7128
+ // first responder at this point, and once the view leaves the window
7129
+ // the next screen's `window?.endEditing` no longer reaches it — the
7130
+ // keyboard then stays up with nothing able to dismiss it until the
7131
+ // app is backgrounded (#497). This is the one moment both facts hold:
7132
+ // the screen is going, and its view still owns the responder.
7133
+ //
7134
+ // `lastFocusedInput` is deliberately left alone. A screen merely
7135
+ // covered by a push comes back, and restoreFocus putting the person
7136
+ // back where they were is the behaviour that was already there.
7137
+ view.endEditing(true)
7040
7138
  screenVisibilityChanged(false)
7041
7139
  stopCelebrations(in: view)
7042
7140
  // Release only this screen's accessory. A retained or prepared screen
@@ -7045,7 +7143,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7045
7143
  CraftChrome.shared.releaseTabAccessory(ownedBy: self)
7046
7144
  // Popped (or its stack dismissed) while the bar was away: it comes
7047
7145
  // back for whatever shows next, a web page included.
7048
- if tabBarHidden, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
7146
+ if tabBarHiddenApplied, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
7049
7147
  CraftChrome.shared.setHiddenByScreen(false)
7050
7148
  }
7051
7149
  }
@@ -7087,7 +7185,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7087
7185
 
7088
7186
  private func restoreShellChrome() {
7089
7187
  guard !isAppScript else { return }
7090
- CraftChrome.shared.setHiddenByScreen(tabBarHidden)
7188
+ applyTabBarHidden()
7091
7189
  // A screen not yet asked about the accessory (one shown for the first
7092
7190
  // time, before its script has run) leaves the bar as it is; one that
7093
7191
  // has drawn without asking for it takes off what nobody claims.
@@ -7183,6 +7281,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7183
7281
  private func disposeRuntime() {
7184
7282
  guard !runtimeDisposed else { return }
7185
7283
  runtimeDisposed = true
7284
+ // Again here, and forgetting the input this time: a screen can be torn
7285
+ // down without ever appearing (one prepared and then replaced), so
7286
+ // viewWillDisappear is not guaranteed to have run. Nothing comes back
7287
+ // from here, so there is no focus worth restoring.
7288
+ //
7289
+ // `viewIfLoaded`, never `view`: this also runs from `deinit`, where
7290
+ // `view` is a `UIView!` that UIKit has already cleared — reading it
7291
+ // traps on the implicit unwrap, and a screen that never appeared
7292
+ // would instead have `loadView` resurrect `self` while it is being
7293
+ // deallocated. A screen with no loaded view has nothing focused.
7294
+ forgetFocusedInput()
7295
+ viewIfLoaded?.endEditing(true)
7186
7296
  frameDelivery.close()
7187
7297
  storageLifetime.close()
7188
7298
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
@@ -7658,7 +7768,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7658
7768
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
7659
7769
  /// `craft.secureStorage.set` wrote.
7660
7770
  /// - `craft.navigation.setOptions({ title, titleColor, largeTitle, hidden,
7661
- /// backTitle, tabBarHidden, tabItem, swipeBack, contentSwipeBack,
7771
+ /// backTitle, tabBarHidden, tabBarHiddenWhenPushed, tabItem, swipeBack,
7772
+ /// contentSwipeBack,
7662
7773
  /// tabSwipeEnabled, backgroundColor, navigationBarSolid, leftButtons,
7663
7774
  /// rightButtons, tabAccessory })`; a button's tap is the event
7664
7775
  /// `navButton`. `craft.d.ts` types no navigation surface, so this list
@@ -8125,6 +8236,32 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8125
8236
  private var statusBarHiddenOverride: Bool?
8126
8237
  /// The app's tab bar away while this screen shows (`tabBarHidden`).
8127
8238
  private var tabBarHidden = false
8239
+ /// The bar away only once this screen is pushed onto something
8240
+ /// (`tabBarHiddenWhenPushed`): UIKit's `hidesBottomBarWhenPushed` idiom,
8241
+ /// for a screen that keeps the bar as its stack's root and drops it when
8242
+ /// it is a detail. Craft's tab bar is drawn by CraftChrome rather than a
8243
+ /// UITabBarController, so the depth is read here and the bar is asked
8244
+ /// directly; the Android shell has honoured the same key all along.
8245
+ private var tabBarHiddenWhenPushed = false
8246
+ /// What this screen last told the chrome. Teardown has to put back a bar
8247
+ /// this screen took away, and recomputing the answer there is no good:
8248
+ /// UIKit has already taken a popped screen out of `viewControllers` by
8249
+ /// `viewWillDisappear`, so a bar hidden for depth would read as never
8250
+ /// hidden at all and stay off for whatever shows next.
8251
+ private var tabBarHiddenApplied = false
8252
+
8253
+ /// Whether the bar should be away for this screen as it stands now.
8254
+ private var wantsTabBarHidden: Bool {
8255
+ if tabBarHidden { return true }
8256
+ guard tabBarHiddenWhenPushed, let stack = navigationController?.viewControllers else { return false }
8257
+ guard let depth = stack.firstIndex(where: { $0 === self }) else { return false }
8258
+ return depth > 0
8259
+ }
8260
+
8261
+ private func applyTabBarHidden() {
8262
+ tabBarHiddenApplied = wantsTabBarHidden
8263
+ CraftChrome.shared.setHiddenByScreen(tabBarHiddenApplied)
8264
+ }
8128
8265
  private var tabAccessory: [String: Any]?
8129
8266
  /// Whether the screen has said anything about the accessory yet
8130
8267
  /// (`tabAccessory`, null included): until it has, the bar keeps showing
@@ -8210,8 +8347,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8210
8347
  }
8211
8348
  if let hidden = options["tabBarHidden"] as? Bool {
8212
8349
  tabBarHidden = hidden
8350
+ }
8351
+ if let hiddenWhenPushed = options["tabBarHiddenWhenPushed"] as? Bool {
8352
+ tabBarHiddenWhenPushed = hiddenWhenPushed
8353
+ }
8354
+ if options.keys.contains("tabBarHidden") || options.keys.contains("tabBarHiddenWhenPushed") {
8355
+ // Prepared tabs and screens under a push keep the answer and apply
8356
+ // it when they are shown (restoreShellChrome); only the screen on
8357
+ // display may move the shared bar.
8213
8358
  if navigationController?.topViewController === self, isShownOnScreen {
8214
- CraftChrome.shared.setHiddenByScreen(hidden)
8359
+ applyTabBarHidden()
8215
8360
  }
8216
8361
  }
8217
8362
  if let swipeBack = options["swipeBack"] as? Bool {
@@ -8324,6 +8469,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8324
8469
 
8325
8470
  /// A bar button from `{ id, symbol | title }`. Its tap is the event
8326
8471
  /// `navButton` with `{ id }`.
8472
+ /// A bar button's choices, or nil when it has none worth a menu.
8473
+ static func navigationMenu(_ raw: Any?, chosen: @escaping (String) -> Void) -> UIMenu? {
8474
+ let actions: [UIAction] = ((raw as? [[String: Any]]) ?? []).prefix(12).compactMap { choice in
8475
+ guard let id = choice["id"] as? String, !id.isEmpty,
8476
+ let title = choice["title"] as? String, !title.isEmpty else { return nil }
8477
+ var attributes: UIMenuElement.Attributes = []
8478
+ if choice["disabled"] as? Bool == true { attributes.insert(.disabled) }
8479
+ if choice["destructive"] as? Bool == true { attributes.insert(.destructive) }
8480
+ let action = UIAction(title: title, image: (choice["symbol"] as? String).flatMap { UIImage(systemName: $0) },
8481
+ attributes: attributes) { _ in chosen(id) }
8482
+ action.accessibilityIdentifier = "nav-menu-\(id)"
8483
+ return action
8484
+ }
8485
+ return actions.isEmpty ? nil : UIMenu(children: actions)
8486
+ }
8487
+
8327
8488
  private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
8328
8489
  guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
8329
8490
  let action = UIAction { [weak self] _ in
@@ -8332,11 +8493,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8332
8493
  if spec["variant"] as? String == "accent" || spec["variant"] as? String == "account" {
8333
8494
  return accountNavigationButton(spec, action: action)
8334
8495
  }
8496
+ // `menu`: the button opens a native menu instead of sending its own
8497
+ // id, and each choice is reported as a press of that choice's id.
8498
+ let menu = Self.navigationMenu(spec["menu"]) { [weak self] choice in
8499
+ self?.send(type: "EVENT", payload: ["handlerName": "navButton", "nativeEvent": ["id": choice]])
8500
+ }
8335
8501
  let item: UIBarButtonItem
8336
8502
  if let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
8337
- item = UIBarButtonItem(image: image, primaryAction: action)
8503
+ item = menu.map { UIBarButtonItem(image: image, menu: $0) } ?? UIBarButtonItem(image: image, primaryAction: action)
8338
8504
  } else if let title = spec["title"] as? String {
8339
- item = UIBarButtonItem(title: title, primaryAction: action)
8505
+ item = menu.map { UIBarButtonItem(title: title, menu: $0) } ?? UIBarButtonItem(title: title, primaryAction: action)
8340
8506
  } else {
8341
8507
  return nil
8342
8508
  }
@@ -8927,14 +9093,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8927
9093
  ])
8928
9094
  }
8929
9095
  case "NAVIGATE", "NAVIGATE_REPLACE":
8930
- // A screen's own stack; the app script has none (it opens paths).
8931
- guard !isAppScript, navigationController?.topViewController === self,
8932
- let screen = payload["screen"] as? String, !screen.isEmpty,
8933
- let navigation = navigationController else { return }
9096
+ guard let screen = payload["screen"] as? String, !screen.isEmpty else { return }
9097
+ // Replacing a path needs no stack of its own: it asks the shell to
9098
+ // show that path, which is why it delegates rather than pushing.
9099
+ // The app script has no stack, so this was refused to it along
9100
+ // with everything else, and its only way to redirect was to push
9101
+ // sign-in on top of the screen it meant to prevent (#499). The
9102
+ // gate in CraftHybrid.configure is the fix for launch; this is
9103
+ // what lets a session that ends mid-run replace rather than stack.
8934
9104
  if type == "NAVIGATE_REPLACE", screen.hasPrefix("/") {
9105
+ guard isAppScript || navigationController?.topViewController === self else { return }
8935
9106
  hybridEvents?.nativeScreen(self, replace: screen)
8936
9107
  return
8937
9108
  }
9109
+ // A screen's own stack; the app script has none (it opens paths).
9110
+ guard !isAppScript, navigationController?.topViewController === self,
9111
+ let navigation = navigationController else { return }
8938
9112
  let params = payload["params"] as? [String: Any] ?? [:]
8939
9113
  let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
8940
9114
  next.hybridEvents = hybridEvents
@@ -9376,7 +9550,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9376
9550
  previousNodesByProtocolID.removeAll(keepingCapacity: true)
9377
9551
  adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
9378
9552
  indexPreviousNodes(previous)
9379
- let next = reconcile(document, identity: "root", path: "root", previous: previous)
9553
+ // The app's configured body face seeds the inheritance chain, so a
9554
+ // screen's text is branded for the same reason its chrome is. Without
9555
+ // this the chain starts empty and `fontFamily` reached the navigation
9556
+ // bar, the tab labels and the bar buttons — all UIAppearance proxies —
9557
+ // while every label, button and field inside a screen stayed in the
9558
+ // system face (#500).
9559
+ //
9560
+ // A node that sets its own family still wins: the fill below only
9561
+ // applies where a node set none or `inherit`. Nil unless the app
9562
+ // configured one, which the same guard makes free.
9563
+ let next = reconcile(document, identity: "root", path: "root", previous: previous,
9564
+ inheritedFontFamily: CraftChromeFonts.body)
9380
9565
  previousNodesByProtocolID.removeAll(keepingCapacity: true)
9381
9566
  adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
9382
9567
  if previous?.view !== next.view {
@@ -324,11 +324,19 @@ final class CraftNativeSounds {
324
324
  object: nil,
325
325
  queue: .main,
326
326
  ) { note in CraftNativeSounds.handleInterruption(note) }
327
+ /// There is no interruption for the media server restarting, and it is
328
+ /// not recoverable the way an interruption is: see `handleMediaReset`.
329
+ private static let mediaResetObserver = NotificationCenter.default.addObserver(
330
+ forName: AVAudioSession.mediaServicesWereResetNotification,
331
+ object: nil,
332
+ queue: .main,
333
+ ) { _ in CraftNativeSounds.handleMediaReset() }
327
334
  /// `{ id, event: "ended" | "error", message? }` to the screen's script.
328
335
  var emit: (([String: Any]) -> Void)?
329
336
 
330
337
  init() {
331
338
  _ = Self.interruptionObserver
339
+ _ = Self.mediaResetObserver
332
340
  Self.services.add(self)
333
341
  }
334
342
 
@@ -362,6 +370,33 @@ final class CraftNativeSounds {
362
370
  }
363
371
  }
364
372
 
373
+ /// The media server restarted, which orphans every audio object the
374
+ /// process holds. Apple's requirement is to discard them and make new
375
+ /// ones; an orphaned `AVQueuePlayer` never plays again, never reaches the
376
+ /// end of its item and so never reports anything. Without this, a sound
377
+ /// playing across a reset left the script a handle that was silent
378
+ /// forever, `pause`/`resume` answering `true` about nothing, and the
379
+ /// session claim never released — while Android reports the equivalent
380
+ /// permanent loss of audio focus as an `error` event and drops the sound.
381
+ ///
382
+ /// A file player's playhead does not survive the reset, so the handle is
383
+ /// released and the loss reported rather than guessed at. Web Audio is
384
+ /// recovered instead (CraftWebAudio.swift): its graph is pure synthesis
385
+ /// the script is still driving, so a new engine continues it.
386
+ static let mediaResetMessage = "Audio stopped because the media services were reset"
387
+
388
+ /// Sound services and their playback controls are used on main.
389
+ private static func handleMediaReset() {
390
+ // The pre-reset interruption, if any, is gone with the server.
391
+ sessionInterrupted = false
392
+ for service in services.allObjects {
393
+ for id in Array(service.sounds.keys) {
394
+ service.remove(id, result: .failure(CraftNativeActionError(code: "AUDIO_ERROR", message: mediaResetMessage)))
395
+ service.emit?(["id": id, "event": "error", "message": mediaResetMessage])
396
+ }
397
+ }
398
+ }
399
+
365
400
  /// Starts `url`. `answer` hears once it plays (or could not be loaded).
366
401
  func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
367
402
  stop(id: id, fadeOut: 0)
@@ -15,7 +15,12 @@ extension AVAudioRecorder: CraftVoiceRecorder {}
15
15
  /// is starting, so a late permission/encoder answer cannot resurrect it.
16
16
  final class CraftVoiceRecording {
17
17
  static let shared = CraftVoiceRecording()
18
- private static let fileQueue = DispatchQueue(label: "craft.voice-recording-files", qos: .utility)
18
+ /// Deleting a recording somebody just cancelled is work they are waiting
19
+ /// on: until it is gone, what they decided not to send still exists. At
20
+ /// `.utility` a busy device could leave the removal queued for seconds
21
+ /// behind everything else (it missed a two-second bound on a loaded CI
22
+ /// simulator while every other step of the cancellation was on time).
23
+ private static let fileQueue = DispatchQueue(label: "craft.voice-recording-files", qos: .userInitiated)
19
24
  private static let launchCleanup: Void = discardAbandonedRecordings(includeLegacy: false)
20
25
  private static let legacyCleanup: Void = discardAbandonedRecordings()
21
26
  private static let folderName = "voice-recordings"
@@ -106,7 +111,16 @@ final class CraftVoiceRecording {
106
111
  private let startupTimeout: TimeInterval
107
112
  private let stopTimeout: TimeInterval
108
113
  struct FileRemovalReceipt: Encodable {
114
+ /// From deciding to delete to the block actually running: the trace,
115
+ /// the submission, and the wait for a thread.
109
116
  let queueMilliseconds: Double
117
+ /// The first of those three alone. A run where `queueMilliseconds` is
118
+ /// seconds and this is near zero spent them waiting for GCD to admit
119
+ /// the block; one where this carries most of them spent them in
120
+ /// `trace`, which takes a process-wide lock and then logs
121
+ /// synchronously. Without it the two are indistinguishable, which is
122
+ /// where #490 ran out of evidence at 7638ms of queue for 3ms of work.
123
+ let submitMilliseconds: Double
110
124
  let removalMilliseconds: Double
111
125
  let ownedBefore: Bool
112
126
  let removed: Bool
@@ -178,12 +192,14 @@ final class CraftVoiceRecording {
178
192
  let queued = ProcessInfo.processInfo.systemUptime
179
193
  let observed = fileRemovalObserved
180
194
  Self.trace("cancel \(session.key.prefix(8)): file removal queued")
195
+ let submitted = ProcessInfo.processInfo.systemUptime
181
196
  Self.fileQueue.async {
182
197
  let began = ProcessInfo.processInfo.systemUptime
183
198
  let owned = CraftNativeFiles.owned(file.path) != nil
184
199
  let removed = CraftNativeFiles.remove(file.path)
185
200
  let receipt = FileRemovalReceipt(
186
201
  queueMilliseconds: (began - queued) * 1000,
202
+ submitMilliseconds: (submitted - queued) * 1000,
187
203
  removalMilliseconds: (ProcessInfo.processInfo.systemUptime - began) * 1000,
188
204
  ownedBefore: owned, removed: removed,
189
205
  existsAfter: FileManager.default.fileExists(atPath: file.path))
@@ -1361,7 +1361,12 @@ final class CraftWebAudio {
1361
1361
  private var engine: AVAudioEngine?
1362
1362
  private var claimedSession = false
1363
1363
  private var pump: Timer?
1364
+ /// Notifications about the audio session and the app, registered once and
1365
+ /// outliving any one engine.
1364
1366
  private var observers: [NSObjectProtocol] = []
1367
+ /// And the one about this engine's configuration, which does not: a
1368
+ /// rebuilt engine is a different object (`rebuildEngine`).
1369
+ private var engineObserver: NSObjectProtocol?
1365
1370
  private weak var script: JSContext?
1366
1371
 
1367
1372
  init(live: Bool = true) {
@@ -1434,15 +1439,7 @@ final class CraftWebAudio {
1434
1439
  contexts[id] = nil
1435
1440
  return nil
1436
1441
  }
1437
- let engine = self.engine ?? makeEngine()
1438
- let source = AVAudioSourceNode(format: format) { silence, _, frameCount, bufferList in
1439
- let buffers = UnsafeMutableAudioBufferListPointer(bufferList)
1440
- silence.pointee = ObjCBool(graph.render(Int(frameCount), into: buffers))
1441
- return noErr
1442
- }
1443
- engine.attach(source)
1444
- engine.connect(source, to: engine.mainMixerNode, format: format)
1445
- context.source = source
1442
+ attachSource(context, format: format, to: self.engine ?? makeEngine())
1446
1443
  refreshEngine()
1447
1444
  }
1448
1445
  startPump()
@@ -1482,25 +1479,84 @@ final class CraftWebAudio {
1482
1479
 
1483
1480
  // MARK: Engine and session
1484
1481
 
1482
+ /// A context's graph reaches the engine through a source node. Attached
1483
+ /// when the context opens, and again on a fresh engine for every live
1484
+ /// context after the media server restarts.
1485
+ private func attachSource(_ context: Context, format: AVAudioFormat, to engine: AVAudioEngine) {
1486
+ let graph = context.graph
1487
+ let source = AVAudioSourceNode(format: format) { silence, _, frameCount, bufferList in
1488
+ let buffers = UnsafeMutableAudioBufferListPointer(bufferList)
1489
+ silence.pointee = ObjCBool(graph.render(Int(frameCount), into: buffers))
1490
+ return noErr
1491
+ }
1492
+ engine.attach(source)
1493
+ engine.connect(source, to: engine.mainMixerNode, format: format)
1494
+ context.source = source
1495
+ }
1496
+
1485
1497
  private func makeEngine() -> AVAudioEngine {
1486
1498
  let engine = AVAudioEngine()
1487
1499
  self.engine = engine
1488
1500
  let center = NotificationCenter.default
1489
1501
  // A route change (headphones) or an interruption (a call) stops the
1490
- // engine; it starts again when the context still plays.
1491
- observers.append(center.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main) { [weak self] _ in
1502
+ // engine; it starts again when the context still plays. This one is
1503
+ // scoped to the engine, so a rebuilt engine replaces it.
1504
+ if let engineObserver { center.removeObserver(engineObserver) }
1505
+ engineObserver = center.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main) { [weak self] _ in
1492
1506
  self?.refreshEngine()
1493
- })
1507
+ }
1508
+ // The rest are about the session and the app, not this engine.
1509
+ guard observers.isEmpty else { return engine }
1494
1510
  observers.append(center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] note in
1495
1511
  let type = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.uintValue
1496
1512
  if type == AVAudioSession.InterruptionType.ended.rawValue { self?.refreshEngine() }
1497
1513
  })
1514
+ observers.append(center.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
1515
+ self?.rebuildEngine()
1516
+ })
1498
1517
  observers.append(center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in
1499
1518
  self?.refreshEngine()
1500
1519
  })
1501
1520
  return engine
1502
1521
  }
1503
1522
 
1523
+ /// The media server restarted, which orphans every audio object the
1524
+ /// process holds — this engine and the source nodes attached to it. An
1525
+ /// orphaned engine can be told to start and still produce no sound, and
1526
+ /// no configuration-change notification is posted for it, so
1527
+ /// `refreshEngine` cannot recover from this. Apple's requirement is to
1528
+ /// discard the orphans and make new ones.
1529
+ ///
1530
+ /// A context does survive, because `CraftAudioGraph` is pure synthesis
1531
+ /// holding no AVFoundation object: it is given a fresh source node on a
1532
+ /// fresh engine and keeps playing from where its own clock is. That is
1533
+ /// why this recovers where a file-backed sound reports the loss instead
1534
+ /// (`CraftNativeSounds.handleMediaReset`) — a player's playhead does not
1535
+ /// survive the reset, a synthesised graph does. Starting the new engine
1536
+ /// reactivates the session, so the claim this screen already holds does
1537
+ /// not need to be taken again.
1538
+ private func rebuildEngine() {
1539
+ guard live, let orphaned = engine else { return }
1540
+ for context in contexts.values {
1541
+ if let source = context.source {
1542
+ orphaned.disconnectNodeOutput(source)
1543
+ orphaned.detach(source)
1544
+ }
1545
+ context.source = nil
1546
+ }
1547
+ orphaned.stop()
1548
+ if let engineObserver { NotificationCenter.default.removeObserver(engineObserver) }
1549
+ engineObserver = nil
1550
+ engine = nil
1551
+ guard !contexts.isEmpty else { return }
1552
+ let engine = makeEngine()
1553
+ for context in contexts.values {
1554
+ guard let format = AVAudioFormat(standardFormatWithSampleRate: context.graph.sampleRate, channels: 2) else { continue }
1555
+ attachSource(context, format: format, to: engine)
1556
+ }
1557
+ refreshEngine()
1558
+ }
1559
+
1504
1560
  private func refreshEngine() {
1505
1561
  guard live, let engine else { return }
1506
1562
  let playing = contexts.values.contains { $0.running }
@@ -1522,6 +1578,8 @@ final class CraftWebAudio {
1522
1578
  }
1523
1579
 
1524
1580
  private func stopEngine() {
1581
+ if let engineObserver { NotificationCenter.default.removeObserver(engineObserver) }
1582
+ engineObserver = nil
1525
1583
  observers.forEach(NotificationCenter.default.removeObserver)
1526
1584
  observers.removeAll()
1527
1585
  engine?.stop()
@@ -1714,6 +1772,13 @@ final class CraftWebAudio {
1714
1772
  }
1715
1773
 
1716
1774
  var contextIdsForTesting: [Int] { Array(contexts.keys).sorted() }
1775
+ /// The engine and a context's source node, so a test can see that a
1776
+ /// media-services reset replaced them rather than restarting orphans
1777
+ /// (`rebuildEngine`). Posting the notification cannot really orphan them,
1778
+ /// so identity is the only observable; the clock moving afterwards is
1779
+ /// what shows the replacements actually render.
1780
+ var engineForTesting: AVAudioEngine? { engine }
1781
+ func sourceForTesting(_ contextId: Int) -> AVAudioSourceNode? { contexts[contextId]?.source }
1717
1782
 
1718
1783
  // MARK: Helpers
1719
1784
 
@@ -354,6 +354,30 @@ final class DriverTests: XCTestCase {
354
354
  lastTappedInput = input
355
355
  }
356
356
 
357
+ /// `target`'s frame once it has stopped moving, or its last reading at
358
+ /// the deadline. This only ever waits — it never taps.
359
+ ///
360
+ /// A journey that flicks the page before touching a control leaves the
361
+ /// scroll view decelerating, and a touch that lands on a decelerating
362
+ /// scroll view stops the scroll instead of reaching the control. HQ's
363
+ /// agreement journey does exactly that: `swipe up`, then `tapid` the
364
+ /// first switch, which stayed off while the second one — tapped seconds
365
+ /// later — turned on (#449). `scrollto` and `drag` both end with a hold
366
+ /// that leaves the content at rest; `swipe` is a flick and does not.
367
+ ///
368
+ /// Waiting before the one gesture is not a second gesture.
369
+ func settledFrame(_ target: XCUIElement, within seconds: TimeInterval = 1.0) -> CGRect {
370
+ var frame = target.frame
371
+ let deadline = Date().addingTimeInterval(seconds)
372
+ while Date() < deadline {
373
+ Thread.sleep(forTimeInterval: 0.1)
374
+ let next = target.frame
375
+ if next.equalTo(frame) { return frame }
376
+ frame = next
377
+ }
378
+ return frame
379
+ }
380
+
357
381
  func answerOpenPrompt() {
358
382
  // A direct app link has already arrived. Querying an invisible
359
383
  // SpringBoard here can stall its accessibility snapshot on iOS 27.
@@ -517,7 +541,7 @@ final class DriverTests: XCTestCase {
517
541
  // renderer or hit-testing failure (HQ agreement job 7570).
518
542
  let before = target.value as? String
519
543
  let enabledBefore = target.isEnabled
520
- let frameBefore = target.frame
544
+ let frameBefore = settledFrame(target)
521
545
  press(target)
522
546
  let settle = Date().addingTimeInterval(1.5)
523
547
  var flipped = false
@@ -525,11 +549,17 @@ final class DriverTests: XCTestCase {
525
549
  flipped = switchFlipped(from: before, to: target.value as? String)
526
550
  if !flipped { Thread.sleep(forTimeInterval: 0.25) }
527
551
  } while !flipped && Date() < settle
552
+ let frameAfter = target.frame
528
553
  let observation: [String: Any] = [
529
554
  "id": arg, "gestures": 1, "flipped": flipped,
530
555
  "enabledBefore": enabledBefore, "enabledAfter": target.isEnabled,
531
556
  "valueBefore": before ?? "unknown", "valueAfter": target.value as? String ?? "unknown",
532
557
  "frameBefore": [frameBefore.origin.x, frameBefore.origin.y, frameBefore.width, frameBefore.height],
558
+ // Without this a control that was still moving when it
559
+ // was tapped looks exactly like one that sat still and
560
+ // ignored the tap, which is where #449 ran out of
561
+ // evidence.
562
+ "frameAfter": [frameAfter.origin.x, frameAfter.origin.y, frameAfter.width, frameAfter.height],
533
563
  ]
534
564
  lastBooleanTap = observation
535
565
  if let data = try? JSONSerialization.data(withJSONObject: observation, options: [.sortedKeys]),