craft-native 0.0.490 → 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.
@@ -425,6 +425,13 @@ internal class CraftNativeStores private constructor(
425
425
  main.post { done(false) }
426
426
  return
427
427
  }
428
+ // UIKit refuses a payload that is not JSON before it touches the
429
+ // disk, and a checkpoint kept on one host and refused on the other is
430
+ // the divergence this contract exists to prevent (#396).
431
+ if (json != null && !craftNativeIsJsonText(json)) {
432
+ main.post { done(false) }
433
+ return
434
+ }
428
435
  writer.execute {
429
436
  val file = snapshotFile(name)
430
437
  val previous = snapshotCache[name]
@@ -2140,3 +2147,182 @@ internal object CraftNativeIcons {
2140
2147
  override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT
2141
2148
  }
2142
2149
  }
2150
+
2151
+ /**
2152
+ * Whether `text` is JSON that UIKit's own writer would also accept.
2153
+ *
2154
+ * UIKit refuses a snapshot whose payload is not JSON, before it touches the
2155
+ * disk: `JSONSerialization.jsonObject(with:options: [.fragmentsAllowed])`
2156
+ * returning nil fails the write. Android had no such check, so a payload iOS
2157
+ * rejected was written here and the two hosts disagreed about whether a
2158
+ * checkpoint had been kept (#396).
2159
+ *
2160
+ * The grammar is Foundation's, measured rather than assumed, because it is
2161
+ * not RFC 8259 and a strict parser would have diverged in the other
2162
+ * direction:
2163
+ *
2164
+ * - top-level fragments are allowed (`42`, `"text"`, `null`);
2165
+ * - **one** trailing comma is allowed in an object or array — `{"a":1,}`
2166
+ * and `[1,]` are accepted, `{"a":1,,}` and `[,1]` are not;
2167
+ * - a lone surrogate escape is rejected, a matched pair accepted;
2168
+ * - a raw control character inside a string is rejected, its escape is not;
2169
+ * - `00`, `+1`, `.5`, `1.`, `1.0e`, `NaN` and `Infinity` are all rejected;
2170
+ * - trailing content after the value is rejected, surrounding space is not.
2171
+ *
2172
+ * `org.json` cannot stand in for this: `JSONTokener` accepts unquoted keys,
2173
+ * single quotes and more, so it would admit payloads UIKit refuses.
2174
+ * `android.util.JsonReader` in strict mode refuses top-level fragments that
2175
+ * UIKit accepts. Both are wrong in a direction that matters, which is why
2176
+ * this is scanned here and pinned against Foundation itself by
2177
+ * `packages/android/src/snapshot-json-contract.test.ts`.
2178
+ */
2179
+ private class CraftNativeJsonScanner(private val text: String) {
2180
+ private var at = 0
2181
+
2182
+ fun validate(): Boolean {
2183
+ skipWhitespace()
2184
+ if (!value()) return false
2185
+ skipWhitespace()
2186
+ return at == text.length
2187
+ }
2188
+
2189
+ private fun skipWhitespace() {
2190
+ while (at < text.length && (text[at] == ' ' || text[at] == '\t' || text[at] == '\n' || text[at] == '\r')) at++
2191
+ }
2192
+
2193
+ private fun value(): Boolean {
2194
+ if (at >= text.length) return false
2195
+ return when (text[at]) {
2196
+ '{' -> obj()
2197
+ '[' -> arr()
2198
+ '"' -> string()
2199
+ 't' -> literal("true")
2200
+ 'f' -> literal("false")
2201
+ 'n' -> literal("null")
2202
+ else -> number()
2203
+ }
2204
+ }
2205
+
2206
+ private fun literal(word: String): Boolean {
2207
+ if (!text.startsWith(word, at)) return false
2208
+ at += word.length
2209
+ return true
2210
+ }
2211
+
2212
+ /** After a member, consume the separator. Returns null when malformed. */
2213
+ private fun afterMember(closing: Char): Boolean? {
2214
+ skipWhitespace()
2215
+ if (at >= text.length) return null
2216
+ return when (text[at]) {
2217
+ ',' -> {
2218
+ at++
2219
+ skipWhitespace()
2220
+ // Foundation allows exactly one trailing comma before the
2221
+ // close. A second comma, or a close that never comes, is not.
2222
+ if (at < text.length && text[at] == closing) { at++; true } else false
2223
+ }
2224
+ closing -> { at++; true }
2225
+ else -> null
2226
+ }
2227
+ }
2228
+
2229
+ private fun obj(): Boolean {
2230
+ at++
2231
+ skipWhitespace()
2232
+ if (at < text.length && text[at] == '}') { at++; return true }
2233
+ while (true) {
2234
+ skipWhitespace()
2235
+ if (at >= text.length || text[at] != '"') return false
2236
+ if (!string()) return false
2237
+ skipWhitespace()
2238
+ if (at >= text.length || text[at] != ':') return false
2239
+ at++
2240
+ skipWhitespace()
2241
+ if (!value()) return false
2242
+ val done = afterMember('}') ?: return false
2243
+ if (done) return true
2244
+ }
2245
+ }
2246
+
2247
+ private fun arr(): Boolean {
2248
+ at++
2249
+ skipWhitespace()
2250
+ if (at < text.length && text[at] == ']') { at++; return true }
2251
+ while (true) {
2252
+ skipWhitespace()
2253
+ if (!value()) return false
2254
+ val done = afterMember(']') ?: return false
2255
+ if (done) return true
2256
+ }
2257
+ }
2258
+
2259
+ private fun string(): Boolean {
2260
+ at++
2261
+ var pendingHighSurrogate = false
2262
+ while (at < text.length) {
2263
+ val character = text[at]
2264
+ when {
2265
+ character == '"' -> { at++; return !pendingHighSurrogate }
2266
+ character == '\\' -> {
2267
+ at++
2268
+ if (at >= text.length) return false
2269
+ val escape = text[at]
2270
+ if (escape == 'u') {
2271
+ if (at + 4 >= text.length) return false
2272
+ var code = 0
2273
+ for (offset in 1..4) {
2274
+ val digit = Character.digit(text[at + offset], 16)
2275
+ if (digit < 0) return false
2276
+ code = code * 16 + digit
2277
+ }
2278
+ at += 5
2279
+ val high = code in 0xD800..0xDBFF
2280
+ val low = code in 0xDC00..0xDFFF
2281
+ // A low surrogate is only legal directly after a high
2282
+ // one, and a high one must be followed by a low one.
2283
+ if (pendingHighSurrogate != low) return false
2284
+ pendingHighSurrogate = high
2285
+ continue
2286
+ }
2287
+ if (pendingHighSurrogate) return false
2288
+ if (escape !in listOf('"', '\\', '/', 'b', 'f', 'n', 'r', 't')) return false
2289
+ at++
2290
+ }
2291
+ character.code < 0x20 -> return false
2292
+ else -> {
2293
+ if (pendingHighSurrogate) return false
2294
+ at++
2295
+ }
2296
+ }
2297
+ }
2298
+ return false
2299
+ }
2300
+
2301
+ private fun number(): Boolean {
2302
+ val start = at
2303
+ if (at < text.length && text[at] == '-') at++
2304
+ val integerStart = at
2305
+ while (at < text.length && text[at] in '0'..'9') at++
2306
+ if (at == integerStart) return false
2307
+ // A leading zero may not be followed by another digit.
2308
+ if (text[integerStart] == '0' && at - integerStart > 1) return false
2309
+ if (at < text.length && text[at] == '.') {
2310
+ at++
2311
+ val fractionStart = at
2312
+ while (at < text.length && text[at] in '0'..'9') at++
2313
+ if (at == fractionStart) return false
2314
+ }
2315
+ if (at < text.length && (text[at] == 'e' || text[at] == 'E')) {
2316
+ at++
2317
+ if (at < text.length && (text[at] == '+' || text[at] == '-')) at++
2318
+ val exponentStart = at
2319
+ while (at < text.length && text[at] in '0'..'9') at++
2320
+ if (at == exponentStart) return false
2321
+ }
2322
+ return at > start
2323
+ }
2324
+ }
2325
+
2326
+ /** Whether a snapshot payload is JSON both hosts accept. */
2327
+ private fun craftNativeIsJsonText(text: String): Boolean = CraftNativeJsonScanner(text).validate()
2328
+
@@ -149,6 +149,27 @@ private fun craftNativeBoolean(value: Any?, fallback: Boolean = false): Boolean
149
149
 
150
150
  private fun craftNativeInt(value: Any?, fallback: Int): Int = (value as? Number)?.toInt() ?: fallback
151
151
 
152
+ /**
153
+ * Where a field's text and hint begin, vertically.
154
+ *
155
+ * The platform's `Widget.EditText` carries `gravity = center_vertical`, and
156
+ * nothing between it and this app overrides it: `Theme.CraftApp` inherits
157
+ * `Theme.Material3.DayNight.NoActionBar`, no Material3 theme binds
158
+ * `android:editTextStyle`, and AppCompat binds it to
159
+ * `Widget.AppCompat.EditText`, whose chain reaches `android:Widget.EditText`
160
+ * without touching gravity. A single-line field is never taller than its
161
+ * text, so the centring cannot show; a field given a `minHeight` drew its
162
+ * first line — and its hint — halfway down the box, where iOS and the web
163
+ * both start at the top (#502).
164
+ *
165
+ * Only the vertical bits are replaced. The horizontal ones are left exactly
166
+ * as they were, because `textAlign` resolves through `textAlignment`, whose
167
+ * default of `TEXT_ALIGNMENT_INHERIT` can still fall back to this gravity.
168
+ */
169
+ private fun craftNativeVerticalGravity(current: Int, multiline: Boolean): Int =
170
+ (current and Gravity.VERTICAL_GRAVITY_MASK.inv()) or (if (multiline) Gravity.TOP else Gravity.CENTER_VERTICAL)
171
+
172
+
152
173
  /**
153
174
  * A scroll event's geometry in density-independent units.
154
175
  *
@@ -6689,6 +6710,8 @@ class MainActivity : AppCompatActivity() {
6689
6710
  if (isSingleLine == multiline) isSingleLine = !multiline
6690
6711
  val wantedLines = if (multiline) craftNativeInt(props.opt("numberOfLines"), Int.MAX_VALUE).coerceAtLeast(1) else 1
6691
6712
  if (maxLines != wantedLines) maxLines = wantedLines
6713
+ val wantedGravity = craftNativeVerticalGravity(gravity, multiline)
6714
+ if (gravity != wantedGravity) gravity = wantedGravity
6692
6715
  isEnabled = craftNativeBoolean(props.opt("editable"), true)
6693
6716
  configureTextStyle(this, style)
6694
6717
  // The style's padding is where the text starts, as on iOS and
package/dist/cli.js CHANGED
@@ -1025,6 +1025,9 @@ async function detachDiskImage(mount, options = {}) {
1025
1025
  await wait(attempt * 2000);
1026
1026
  }
1027
1027
  }
1028
+ function attachedDevice(attachOutput) {
1029
+ return attachOutput.match(/^(\/dev\/disk\d+)(?=\s)/m)?.[1] ?? null;
1030
+ }
1028
1031
  async function createDiskImage(opts) {
1029
1032
  if (!/^[^/:\n]{1,27}$/.test(opts.volumeName))
1030
1033
  return { success: false, error: `Invalid DMG volume name "${opts.volumeName}"; must be 1..27 chars without /, :, or newline` };
@@ -1068,7 +1071,7 @@ async function createDiskImage(opts) {
1068
1071
  phase = "layout";
1069
1072
  writeInstallerLayout(mount, opts.volumeName, bundleName);
1070
1073
  phase = "detach";
1071
- const detached = await detachDiskImage(mount);
1074
+ const detached = await detachDiskImage(attachedDevice(attachment) ?? mount);
1072
1075
  if (!detached.success) {
1073
1076
  detachAttempts = detached.attempts;
1074
1077
  throw new Error(detached.error);
@@ -1083,7 +1086,7 @@ async function createDiskImage(opts) {
1083
1086
  Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
1084
1087
  } finally {
1085
1088
  if (mounted)
1086
- await runTool("hdiutil", ["detach", mount, "-force"]);
1089
+ await runTool("hdiutil", ["detach", attachedDevice(attachment) ?? mount, "-force"]);
1087
1090
  removeTempDir(scratch);
1088
1091
  }
1089
1092
  }
@@ -3243,7 +3246,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3243
3246
  `);
3244
3247
  }
3245
3248
  // package.json
3246
- var version = "0.0.490";
3249
+ var version = "0.0.491";
3247
3250
 
3248
3251
  // bin/cli.ts
3249
3252
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
376
376
  `);
377
377
  }
378
378
  // package.json
379
- var version = "0.0.490";
379
+ var version = "0.0.491";
380
380
 
381
381
  // src/sdk-version.ts
382
382
  var SDK_VERSION = version;
@@ -1393,6 +1393,9 @@ async function detachDiskImage(mount, options = {}) {
1393
1393
  await wait(attempt * 2000);
1394
1394
  }
1395
1395
  }
1396
+ function attachedDevice(attachOutput) {
1397
+ return attachOutput.match(/^(\/dev\/disk\d+)(?=\s)/m)?.[1] ?? null;
1398
+ }
1396
1399
  async function createDiskImage(opts) {
1397
1400
  if (!/^[^/:\n]{1,27}$/.test(opts.volumeName))
1398
1401
  return { success: false, error: `Invalid DMG volume name "${opts.volumeName}"; must be 1..27 chars without /, :, or newline` };
@@ -1436,7 +1439,7 @@ async function createDiskImage(opts) {
1436
1439
  phase = "layout";
1437
1440
  writeInstallerLayout(mount, opts.volumeName, bundleName);
1438
1441
  phase = "detach";
1439
- const detached = await detachDiskImage(mount);
1442
+ const detached = await detachDiskImage(attachedDevice(attachment) ?? mount);
1440
1443
  if (!detached.success) {
1441
1444
  detachAttempts = detached.attempts;
1442
1445
  throw new Error(detached.error);
@@ -1451,7 +1454,7 @@ async function createDiskImage(opts) {
1451
1454
  Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
1452
1455
  } finally {
1453
1456
  if (mounted)
1454
- await runTool("hdiutil", ["detach", mount, "-force"]);
1457
+ await runTool("hdiutil", ["detach", attachedDevice(attachment) ?? mount, "-force"]);
1455
1458
  removeTempDir(scratch);
1456
1459
  }
1457
1460
  }
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.490";
120
+ var version = "0.0.491";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -1149,6 +1149,9 @@ async function detachDiskImage(mount, options = {}) {
1149
1149
  await wait(attempt * 2000);
1150
1150
  }
1151
1151
  }
1152
+ function attachedDevice(attachOutput) {
1153
+ return attachOutput.match(/^(\/dev\/disk\d+)(?=\s)/m)?.[1] ?? null;
1154
+ }
1152
1155
  async function createDiskImage(opts) {
1153
1156
  if (!/^[^/:\n]{1,27}$/.test(opts.volumeName))
1154
1157
  return { success: false, error: `Invalid DMG volume name "${opts.volumeName}"; must be 1..27 chars without /, :, or newline` };
@@ -1192,7 +1195,7 @@ async function createDiskImage(opts) {
1192
1195
  phase = "layout";
1193
1196
  writeInstallerLayout(mount, opts.volumeName, bundleName);
1194
1197
  phase = "detach";
1195
- const detached = await detachDiskImage(mount);
1198
+ const detached = await detachDiskImage(attachedDevice(attachment) ?? mount);
1196
1199
  if (!detached.success) {
1197
1200
  detachAttempts = detached.attempts;
1198
1201
  throw new Error(detached.error);
@@ -1207,7 +1210,7 @@ async function createDiskImage(opts) {
1207
1210
  Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
1208
1211
  } finally {
1209
1212
  if (mounted)
1210
- await runTool("hdiutil", ["detach", mount, "-force"]);
1213
+ await runTool("hdiutil", ["detach", attachedDevice(attachment) ?? mount, "-force"]);
1211
1214
  removeTempDir(scratch);
1212
1215
  }
1213
1216
  }
@@ -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]),
package/dist/package.d.ts CHANGED
@@ -298,6 +298,11 @@ export declare function detachDiskImage(mount: string, options?: {
298
298
  error: string;
299
299
  attempts: DiskImageDetachAttempt[];
300
300
  }>;
301
+ /**
302
+ * The whole-disk device `hdiutil attach` reports for the image it mounted
303
+ * ("/dev/disk4" from its first line), or null when the output names none.
304
+ */
305
+ export declare function attachedDevice(attachOutput: string): string | null;
301
306
  /**
302
307
  * Helper: Create DMG from app bundle
303
308
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.490",
3
+ "version": "0.0.491",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",