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.
- package/dist/android/templates/CraftNativeShell.kt.template +186 -0
- package/dist/android/templates/MainActivityNative.kt.template +23 -0
- package/dist/cli.js +6 -3
- package/dist/index.cjs +6 -3
- package/dist/index.js +6 -3
- package/dist/ios/templates/CraftNativeServices.swift +35 -0
- package/dist/ios/templates/CraftVoiceRecording.swift +17 -1
- package/dist/ios/templates/CraftWebAudio.swift +77 -12
- package/dist/ios/templates/testing/DriverTests.swift.template +31 -1
- package/dist/package.d.ts +5 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
*/
|