craft-native 0.0.423 → 0.0.435
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/CraftNativeCapabilities.kt.template +15 -3
- package/dist/android/templates/CraftNativeSounds.kt.template +3 -2
- package/dist/android/templates/MainActivityNative.kt.template +61 -12
- package/dist/browser.js +9 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +2 -0
- package/dist/ios/src/index.js +2 -1
- package/dist/ios/templates/CraftApp.swift +63 -0
- package/dist/ios/templates/CraftNativeScreen.swift +82 -39
- package/dist/ios/templates/CraftVoiceRecording.swift +62 -5
- package/dist/ios/templates/testing/CraftTimedInput.m +90 -1
- package/dist/ios/templates/testing/DriverTests.swift.template +49 -10
- package/dist/macos/src/index.js +2 -2
- package/dist/macos/src/toolchain.d.ts +2 -2
- package/dist/macos/templates/CraftMacApplication.swift +23 -5
- package/dist/macos/templates/CraftMacRenderer.swift +34 -2
- package/package.json +1 -1
|
@@ -478,7 +478,7 @@ internal class CraftNativeCapabilities(
|
|
|
478
478
|
}
|
|
479
479
|
"remove" -> {
|
|
480
480
|
val file = ownedFile(args.optString(0)) ?: return failure("INVALID_ARGUMENT", "files.remove needs a file made by craft.files")
|
|
481
|
-
success(file
|
|
481
|
+
success(removeOwnedFile(file))
|
|
482
482
|
}
|
|
483
483
|
"download" -> download(requestToken, args)
|
|
484
484
|
else -> failure("UNKNOWN_ACTION", "Unsupported Files method")
|
|
@@ -802,7 +802,7 @@ internal class CraftNativeCapabilities(
|
|
|
802
802
|
success(copied)
|
|
803
803
|
} catch (error: Exception) {
|
|
804
804
|
for (index in 0 until copied.length()) {
|
|
805
|
-
copied.optJSONObject(index)?.optString("path")?.let { ownedFile(it)?.
|
|
805
|
+
copied.optJSONObject(index)?.optString("path")?.let { ownedFile(it)?.let(::removeOwnedFile) }
|
|
806
806
|
}
|
|
807
807
|
val message = error.message ?: "The selected document could not be read"
|
|
808
808
|
if (message.startsWith("CANCELLED:")) failure("CANCELLED", message.removePrefix("CANCELLED:").trim())
|
|
@@ -915,6 +915,18 @@ internal class CraftNativeCapabilities(
|
|
|
915
915
|
return file.takeIf { it.path.startsWith(root.path + File.separator) }
|
|
916
916
|
}
|
|
917
917
|
|
|
918
|
+
/** Delete only a validated owned file; never sweep siblings or the shared root. */
|
|
919
|
+
private fun removeOwnedFile(file: File): Boolean {
|
|
920
|
+
// File.delete is non-recursive and reports permission failures. Reject
|
|
921
|
+
// directories before removal; parent pruning must remain best effort.
|
|
922
|
+
if (!runCatching { file.isFile && file.delete() }.getOrDefault(false)) return false
|
|
923
|
+
runCatching {
|
|
924
|
+
val parent = file.parentFile
|
|
925
|
+
if (parent != null && parent != fileRoot.canonicalFile && parent.isDirectory) parent.delete()
|
|
926
|
+
}
|
|
927
|
+
return true
|
|
928
|
+
}
|
|
929
|
+
|
|
918
930
|
private fun safeFileName(raw: String): String {
|
|
919
931
|
val name = raw.substringAfterLast('/').substringAfterLast('\\').trim()
|
|
920
932
|
return name.ifBlank { "file" }.replace(Regex("[\\\\/:*?\"<>|\\r\\n]"), "_")
|
|
@@ -939,7 +951,7 @@ internal class CraftNativeCapabilities(
|
|
|
939
951
|
val files = if (data is JSONArray) (0 until data.length()).mapNotNull { data.optJSONObject(it) }
|
|
940
952
|
else listOfNotNull(data as? JSONObject)
|
|
941
953
|
files.forEach { file ->
|
|
942
|
-
ownedFile(file.optString("path"))?.
|
|
954
|
+
ownedFile(file.optString("path"))?.let(::removeOwnedFile)
|
|
943
955
|
}
|
|
944
956
|
}
|
|
945
957
|
|
|
@@ -57,13 +57,14 @@ internal class CraftNativeSounds(
|
|
|
57
57
|
if (id.isBlank()) return done(failure("INVALID_ARGUMENT", "audio.play needs an id"))
|
|
58
58
|
val uri = source(address)
|
|
59
59
|
?: return done(failure("INVALID_ARGUMENT", "audio.play needs an absolute http, https, file or content URL"))
|
|
60
|
-
stop(id, 0.0)
|
|
61
|
-
|
|
62
60
|
val requestedVolume = options.optDouble("volume", 1.0)
|
|
63
61
|
val volume = requestedVolume.takeIf { it.isFinite() }?.coerceIn(0.0, 1.0)?.toFloat()
|
|
64
62
|
?: return done(failure("INVALID_ARGUMENT", "audio.play volume must be a finite number between 0 and 1"))
|
|
65
63
|
val fadeIn = options.optDouble("fadeIn", 0.0)
|
|
66
64
|
if (!fadeIn.isFinite() || fadeIn < 0) return done(failure("INVALID_ARGUMENT", "audio.play fadeIn must be a non-negative finite number"))
|
|
65
|
+
// Reject malformed options before cancelling a sound with the same id.
|
|
66
|
+
// Its player, pending load and cancellation handle still belong to it.
|
|
67
|
+
stop(id, 0.0)
|
|
67
68
|
val player = MediaPlayer()
|
|
68
69
|
val sound = Sound(player, if (fadeIn > 0) 0f else volume, pending = done)
|
|
69
70
|
sounds[id] = sound
|
|
@@ -9,6 +9,10 @@ import android.content.pm.PackageManager
|
|
|
9
9
|
import android.content.res.ColorStateList
|
|
10
10
|
import android.content.res.Configuration
|
|
11
11
|
import android.content.res.Resources
|
|
12
|
+
import android.hardware.Sensor
|
|
13
|
+
import android.hardware.SensorEvent
|
|
14
|
+
import android.hardware.SensorEventListener
|
|
15
|
+
import android.hardware.SensorManager
|
|
12
16
|
import android.database.ContentObserver
|
|
13
17
|
import android.content.Intent
|
|
14
18
|
import android.app.PictureInPictureParams
|
|
@@ -4683,6 +4687,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4683
4687
|
private val capabilityHandler = Handler(Looper.getMainLooper())
|
|
4684
4688
|
private var hapticsEnabled = false
|
|
4685
4689
|
private var clipboardEnabled = false
|
|
4690
|
+
private var shakeEnabled = false
|
|
4686
4691
|
internal var nativeAppState = "inactive"
|
|
4687
4692
|
private set
|
|
4688
4693
|
private var deliveredInitialDeepLink = false
|
|
@@ -4755,6 +4760,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4755
4760
|
}.getOrDefault(JSONObject())
|
|
4756
4761
|
hapticsEnabled = config.optBoolean("enableHaptics", false)
|
|
4757
4762
|
clipboardEnabled = config.optBoolean("enableClipboard", false)
|
|
4763
|
+
shakeEnabled = config.optBoolean("enableShakeGesture", false)
|
|
4758
4764
|
// Normally started already by its content provider; this covers a
|
|
4759
4765
|
// process that skipped providers (instrumentation).
|
|
4760
4766
|
CraftCrashReporter.start(this)
|
|
@@ -6101,9 +6107,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6101
6107
|
val handler = craftNativeEventName(events, "onPress", "onClick")
|
|
6102
6108
|
if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
|
|
6103
6109
|
setOnClickListener {
|
|
6104
|
-
screen
|
|
6105
|
-
val current = screen.handlers[this] ?: return@setOnClickListener
|
|
6106
|
-
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
|
|
6110
|
+
dispatchNativePress(screen, it)
|
|
6107
6111
|
}
|
|
6108
6112
|
}
|
|
6109
6113
|
"Button" -> (previous as? Button ?: CraftNativePointerButton(this).apply {
|
|
@@ -6877,9 +6881,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6877
6881
|
control.isClickable = true
|
|
6878
6882
|
if (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable")) {
|
|
6879
6883
|
control.setOnClickListener {
|
|
6880
|
-
screen
|
|
6881
|
-
val current = screen.handlers[it] ?: return@setOnClickListener
|
|
6882
|
-
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
|
|
6884
|
+
dispatchNativePress(screen, it)
|
|
6883
6885
|
}
|
|
6884
6886
|
control.setOnTouchListener { view, event ->
|
|
6885
6887
|
if (!CraftNativePointers.ownsTouch(view) && event.actionMasked != MotionEvent.ACTION_CANCEL) return@setOnTouchListener false
|
|
@@ -6907,9 +6909,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6907
6909
|
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
|
|
6908
6910
|
}
|
|
6909
6911
|
if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
|
|
6910
|
-
screen
|
|
6911
|
-
val current = screen.handlers[view] ?: return@setOnTouchListener true
|
|
6912
|
-
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
|
|
6912
|
+
dispatchNativePress(screen, view)
|
|
6913
6913
|
}
|
|
6914
6914
|
true
|
|
6915
6915
|
}
|
|
@@ -6920,9 +6920,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6920
6920
|
control.setOnTouchListener(null)
|
|
6921
6921
|
if (screen.pressBaseBackgrounds.containsKey(control)) control.background = screen.pressBaseBackgrounds[control]
|
|
6922
6922
|
control.setOnClickListener {
|
|
6923
|
-
screen
|
|
6924
|
-
val current = screen.handlers[control] ?: return@setOnClickListener
|
|
6925
|
-
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
|
|
6923
|
+
dispatchNativePress(screen, control)
|
|
6926
6924
|
}
|
|
6927
6925
|
}
|
|
6928
6926
|
} else {
|
|
@@ -8254,7 +8252,45 @@ class MainActivity : AppCompatActivity() {
|
|
|
8254
8252
|
publish("APP_STATE", JSONObject().put("state", nativeAppState))
|
|
8255
8253
|
}
|
|
8256
8254
|
|
|
8255
|
+
// MARK: - Shake gesture (enableShakeGesture)
|
|
8256
|
+
|
|
8257
|
+
/// The accelerometer, watched for a shake while the app is in front, as on
|
|
8258
|
+
/// iOS. Armed on resume and disarmed on pause, because the sensor is the
|
|
8259
|
+
/// whole cost of this feature and nobody can shake a phone at an app they
|
|
8260
|
+
/// cannot see.
|
|
8261
|
+
private var lastShakeAt = 0L
|
|
8262
|
+
private val shakeSensors by lazy { getSystemService(Context.SENSOR_SERVICE) as? SensorManager }
|
|
8263
|
+
private val shakeListener = object : SensorEventListener {
|
|
8264
|
+
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
|
8265
|
+
override fun onSensorChanged(event: SensorEvent?) {
|
|
8266
|
+
val reading = event?.values ?: return
|
|
8267
|
+
if (reading.size < 3) return
|
|
8268
|
+
// Android reports m/s^2 with gravity included, so a phone at rest
|
|
8269
|
+
// reads about 9.81. Converted to g to match the iOS threshold and
|
|
8270
|
+
// the force the event carries.
|
|
8271
|
+
val magnitude = kotlin.math.sqrt((reading[0] * reading[0] + reading[1] * reading[1] + reading[2] * reading[2]).toDouble()) / SensorManager.GRAVITY_EARTH
|
|
8272
|
+
if (magnitude < 2.2) return
|
|
8273
|
+
val now = System.currentTimeMillis()
|
|
8274
|
+
// One event per gesture, not one per sample.
|
|
8275
|
+
if (now - lastShakeAt < 2000L) return
|
|
8276
|
+
lastShakeAt = now
|
|
8277
|
+
CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "shake").put("detail", JSONObject().put("force", magnitude)))
|
|
8278
|
+
}
|
|
8279
|
+
}
|
|
8280
|
+
|
|
8281
|
+
private fun armShakeGesture() {
|
|
8282
|
+
if (!shakeEnabled) return
|
|
8283
|
+
val sensor = shakeSensors?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) ?: return
|
|
8284
|
+
shakeSensors?.registerListener(shakeListener, sensor, SensorManager.SENSOR_DELAY_UI)
|
|
8285
|
+
}
|
|
8286
|
+
|
|
8287
|
+
private fun disarmShakeGesture() {
|
|
8288
|
+
if (!shakeEnabled) return
|
|
8289
|
+
shakeSensors?.unregisterListener(shakeListener)
|
|
8290
|
+
}
|
|
8291
|
+
|
|
8257
8292
|
override fun onPause() {
|
|
8293
|
+
disarmShakeGesture()
|
|
8258
8294
|
if (::services.isInitialized) services.pauseVoice()
|
|
8259
8295
|
nativeAppState = "inactive"
|
|
8260
8296
|
publish("APP_STATE", JSONObject().put("state", nativeAppState))
|
|
@@ -8314,6 +8350,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
8314
8350
|
|
|
8315
8351
|
override fun onResume() {
|
|
8316
8352
|
super.onResume()
|
|
8353
|
+
armShakeGesture()
|
|
8317
8354
|
if (::services.isInitialized) services.resumed()
|
|
8318
8355
|
}
|
|
8319
8356
|
|
|
@@ -8371,6 +8408,18 @@ class MainActivity : AppCompatActivity() {
|
|
|
8371
8408
|
}
|
|
8372
8409
|
}
|
|
8373
8410
|
|
|
8411
|
+
// The authored handler owns navigation. The bridge is asynchronous, so
|
|
8412
|
+
// opening the link as well cannot honor a handler's preventDefault intent.
|
|
8413
|
+
private fun dispatchNativePress(screen: Screen, view: View) {
|
|
8414
|
+
if (!view.isEnabled) return
|
|
8415
|
+
val handler = screen.handlers[view]
|
|
8416
|
+
if (handler != null) {
|
|
8417
|
+
send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject()))
|
|
8418
|
+
} else {
|
|
8419
|
+
screen.linkTargets[view]?.let { target -> navigateOpen(target) }
|
|
8420
|
+
}
|
|
8421
|
+
}
|
|
8422
|
+
|
|
8374
8423
|
private fun send(screen: Screen, type: String, payload: JSONObject, correlationId: String? = null) {
|
|
8375
8424
|
if (!screen.active) return
|
|
8376
8425
|
val message = JSONObject().put("type", type).put("payload", payload)
|
package/dist/browser.js
CHANGED
|
@@ -317,7 +317,7 @@ async function createBrowserStorage(options) {
|
|
|
317
317
|
};
|
|
318
318
|
const accountChanged = async () => {
|
|
319
319
|
const next = storage.getItem(tokenKey);
|
|
320
|
-
if (next === token) {
|
|
320
|
+
if (next === token && (!transitioning || transition)) {
|
|
321
321
|
await transition;
|
|
322
322
|
return;
|
|
323
323
|
}
|
|
@@ -351,7 +351,14 @@ async function createBrowserStorage(options) {
|
|
|
351
351
|
if (before !== next)
|
|
352
352
|
options.sessionChanged?.(next, before);
|
|
353
353
|
})();
|
|
354
|
-
|
|
354
|
+
const attempt = transition;
|
|
355
|
+
try {
|
|
356
|
+
await attempt;
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (current === revision && transition === attempt)
|
|
359
|
+
transition = null;
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
355
362
|
};
|
|
356
363
|
const secureKey = (name) => name === sessionTokenKey ? tokenKey : key(`credential:${name}`);
|
|
357
364
|
return {
|
package/dist/cli.js
CHANGED
|
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3047
3047
|
`);
|
|
3048
3048
|
}
|
|
3049
3049
|
// package.json
|
|
3050
|
-
var version = "0.0.
|
|
3050
|
+
var version = "0.0.435";
|
|
3051
3051
|
|
|
3052
3052
|
// bin/cli.ts
|
|
3053
3053
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -84,6 +84,8 @@ export interface CraftConfig {
|
|
|
84
84
|
enableAudioRecording?: boolean;
|
|
85
85
|
enableVideoRecording?: boolean;
|
|
86
86
|
enableMotionSensors?: boolean;
|
|
87
|
+
/** Shaking the phone raises a `shake` event. Arms the accelerometer while the app is in front. */
|
|
88
|
+
enableShakeGesture?: boolean;
|
|
87
89
|
enableLocalDatabase?: boolean;
|
|
88
90
|
enableBluetooth?: boolean;
|
|
89
91
|
enableNFC?: boolean;
|
package/dist/ios/src/index.js
CHANGED
|
@@ -18,7 +18,7 @@ function iosPermissions(config) {
|
|
|
18
18
|
contacts: config.enableContacts,
|
|
19
19
|
calendar: config.enableCalendar,
|
|
20
20
|
bluetooth: config.enableBluetooth || config.enableHeartRate,
|
|
21
|
-
motion: config.enableMotionSensors,
|
|
21
|
+
motion: config.enableMotionSensors || config.enableShakeGesture,
|
|
22
22
|
biometric: config.enableBiometric,
|
|
23
23
|
music: config.enableAppleMusic,
|
|
24
24
|
health: config.enableHealthKit,
|
|
@@ -701,6 +701,7 @@ var DEFAULT_CONFIG = {
|
|
|
701
701
|
enableAudioRecording: false,
|
|
702
702
|
enableVideoRecording: false,
|
|
703
703
|
enableMotionSensors: false,
|
|
704
|
+
enableShakeGesture: false,
|
|
704
705
|
enableLocalDatabase: false,
|
|
705
706
|
enableBluetooth: false,
|
|
706
707
|
enableNFC: false,
|
|
@@ -1830,6 +1830,11 @@ struct CraftConfig: Codable {
|
|
|
1830
1830
|
var enableAudioRecording: Bool = false
|
|
1831
1831
|
var enableVideoRecording: Bool = false
|
|
1832
1832
|
var enableMotionSensors: Bool = false
|
|
1833
|
+
/// Shaking the phone raises a `shake` event, for a report-a-problem
|
|
1834
|
+
/// gesture. Its own flag rather than a corner of `enableMotionSensors`,
|
|
1835
|
+
/// because it arms the accelerometer for as long as the app is in front:
|
|
1836
|
+
/// an app that wanted only the motion API should not pay for that.
|
|
1837
|
+
var enableShakeGesture: Bool = false
|
|
1833
1838
|
var enableLocalDatabase: Bool = false
|
|
1834
1839
|
var enableBluetooth: Bool = false
|
|
1835
1840
|
var enableNFC: Bool = false
|
|
@@ -2413,6 +2418,10 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2413
2418
|
private var motionManager: CMMotionManager?
|
|
2414
2419
|
private var isMotionUpdating = false
|
|
2415
2420
|
|
|
2421
|
+
// Shake gesture (enableShakeGesture), armed only while the app is in front
|
|
2422
|
+
private var shakeMotion: CMMotionManager?
|
|
2423
|
+
private var lastShakeAt: Date?
|
|
2424
|
+
|
|
2416
2425
|
// Bluetooth
|
|
2417
2426
|
private var centralManager: CBCentralManager?
|
|
2418
2427
|
private var peripheralManager: CBPeripheralManager?
|
|
@@ -2465,6 +2474,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2465
2474
|
if config.enableMotionSensors {
|
|
2466
2475
|
motionManager = CMMotionManager()
|
|
2467
2476
|
}
|
|
2477
|
+
// The app starts in front, so the gesture starts armed.
|
|
2478
|
+
armShakeGesture()
|
|
2468
2479
|
#endif
|
|
2469
2480
|
if config.enableHealthKit {
|
|
2470
2481
|
// Apple Health's heart rate as it arrives
|
|
@@ -3866,6 +3877,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3866
3877
|
@objc private func appDidEnterBackground(_ notification: Notification) {
|
|
3867
3878
|
backgroundedAt = Date()
|
|
3868
3879
|
saveInteractionState()
|
|
3880
|
+
disarmShakeGesture()
|
|
3869
3881
|
}
|
|
3870
3882
|
|
|
3871
3883
|
@objc private func appDidReceiveMemoryWarning(_ notification: Notification) {
|
|
@@ -3997,6 +4009,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3997
4009
|
}
|
|
3998
4010
|
|
|
3999
4011
|
@objc private func appWillEnterForeground(_ notification: Notification) {
|
|
4012
|
+
armShakeGesture()
|
|
4000
4013
|
if recoverOnForeground, let webView {
|
|
4001
4014
|
// The page is about to be rebuilt; there is no one to resume.
|
|
4002
4015
|
backgroundedAt = nil
|
|
@@ -8499,6 +8512,56 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
8499
8512
|
#endif
|
|
8500
8513
|
}
|
|
8501
8514
|
|
|
8515
|
+
// MARK: - Shake Gesture
|
|
8516
|
+
|
|
8517
|
+
/// The accelerometer, watched for a shake while the app is in front.
|
|
8518
|
+
///
|
|
8519
|
+
/// Deliberately not `startDeviceMotionUpdates`: that fuses attitude and
|
|
8520
|
+
/// gravity every sample, and a shake needs neither. Raw acceleration at
|
|
8521
|
+
/// 20 Hz is enough to see one and costs a fraction of the same watch.
|
|
8522
|
+
///
|
|
8523
|
+
/// Armed on foreground and disarmed on background, because the sensor
|
|
8524
|
+
/// is the whole cost of this feature and nobody can shake a phone at an
|
|
8525
|
+
/// app they cannot see.
|
|
8526
|
+
private func armShakeGesture() {
|
|
8527
|
+
#if CRAFT_PERMISSION_MOTION
|
|
8528
|
+
guard config.enableShakeGesture, shakeMotion == nil else { return }
|
|
8529
|
+
let manager = CMMotionManager()
|
|
8530
|
+
guard manager.isAccelerometerAvailable else { return }
|
|
8531
|
+
manager.accelerometerUpdateInterval = 1.0 / 20.0
|
|
8532
|
+
shakeMotion = manager
|
|
8533
|
+
manager.startAccelerometerUpdates(to: .main) { [weak self] sample, _ in
|
|
8534
|
+
guard let self, let sample else { return }
|
|
8535
|
+
self.readShake(sample.acceleration)
|
|
8536
|
+
}
|
|
8537
|
+
#endif
|
|
8538
|
+
}
|
|
8539
|
+
|
|
8540
|
+
private func disarmShakeGesture() {
|
|
8541
|
+
#if CRAFT_PERMISSION_MOTION
|
|
8542
|
+
shakeMotion?.stopAccelerometerUpdates()
|
|
8543
|
+
shakeMotion = nil
|
|
8544
|
+
#endif
|
|
8545
|
+
}
|
|
8546
|
+
|
|
8547
|
+
#if CRAFT_PERMISSION_MOTION
|
|
8548
|
+
private func readShake(_ force: CMAcceleration) {
|
|
8549
|
+
// Total force, gravity included, so a phone at rest reads 1g and
|
|
8550
|
+
// the threshold is what a deliberate shake adds on top of it. At
|
|
8551
|
+
// 2.2g a pocket, a footfall and setting the phone down are all
|
|
8552
|
+
// below it, which is what a fitness app has to clear.
|
|
8553
|
+
let magnitude = sqrt(force.x * force.x + force.y * force.y + force.z * force.z)
|
|
8554
|
+
guard magnitude >= 2.2 else { return }
|
|
8555
|
+
let now = Date()
|
|
8556
|
+
// One event per gesture, not one per sample. A shake is a second
|
|
8557
|
+
// or two of samples over the threshold, and every one of them
|
|
8558
|
+
// would otherwise be reported as its own shake.
|
|
8559
|
+
if let lastShakeAt, now.timeIntervalSince(lastShakeAt) < 2.0 { return }
|
|
8560
|
+
lastShakeAt = now
|
|
8561
|
+
sendToWeb("shake", data: ["force": magnitude])
|
|
8562
|
+
}
|
|
8563
|
+
#endif
|
|
8564
|
+
|
|
8502
8565
|
// MARK: - Motion Sensors
|
|
8503
8566
|
private func startMotionUpdates(interval: Double, callbackId: String?) {
|
|
8504
8567
|
#if CRAFT_PERMISSION_MOTION
|
|
@@ -2716,6 +2716,8 @@ enum CraftNativeFlexLayout {
|
|
|
2716
2716
|
? label.sizeThatFits(CGSize(width: resolvedWidth, height: unbounded)).height
|
|
2717
2717
|
: maxContent.height)
|
|
2718
2718
|
return CGSize(width: resolvedWidth, height: resolvedHeight)
|
|
2719
|
+
case let image as CraftNativePositionedImageView where image.image == nil:
|
|
2720
|
+
return CGSize(width: width ?? 0, height: height ?? 0)
|
|
2719
2721
|
case let image as UIImageView where image.image == nil:
|
|
2720
2722
|
return CGSize(width: width ?? 0, height: height ?? 0)
|
|
2721
2723
|
default:
|
|
@@ -4165,25 +4167,37 @@ private extension UIImage {
|
|
|
4165
4167
|
}
|
|
4166
4168
|
|
|
4167
4169
|
/// Explicit CSS-facing image geometry; the loader and semantic view stay retained.
|
|
4168
|
-
class CraftNativePositionedImageView:
|
|
4170
|
+
class CraftNativePositionedImageView: UIView {
|
|
4169
4171
|
private var storedImage: UIImage?
|
|
4170
|
-
private
|
|
4172
|
+
private let fittedImage = UIImageView()
|
|
4171
4173
|
private var fit: String?
|
|
4172
4174
|
private var position = CGPoint(x: 0.5, y: 0.5)
|
|
4173
4175
|
|
|
4174
|
-
override
|
|
4176
|
+
override init(frame: CGRect) {
|
|
4177
|
+
super.init(frame: frame)
|
|
4178
|
+
isUserInteractionEnabled = false
|
|
4179
|
+
fittedImage.isUserInteractionEnabled = false
|
|
4180
|
+
fittedImage.isAccessibilityElement = false
|
|
4181
|
+
addSubview(fittedImage)
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
4185
|
+
|
|
4186
|
+
// Keep source identity separate from UIKit's image renderer. A UIImageView
|
|
4187
|
+
// host with this stored-image getter painted an extra copy even with
|
|
4188
|
+
// super.image set to nil; only the child owns image drawing now.
|
|
4189
|
+
var image: UIImage? {
|
|
4175
4190
|
get { storedImage }
|
|
4176
4191
|
set {
|
|
4177
4192
|
storedImage = newValue
|
|
4178
|
-
|
|
4179
|
-
fittedImage?.image = newValue
|
|
4193
|
+
fittedImage.image = newValue
|
|
4180
4194
|
invalidateIntrinsicContentSize()
|
|
4181
4195
|
setNeedsLayout()
|
|
4182
4196
|
}
|
|
4183
4197
|
}
|
|
4184
4198
|
|
|
4185
4199
|
override var intrinsicContentSize: CGSize {
|
|
4186
|
-
fit == nil ?
|
|
4200
|
+
fit == nil ? fittedImage.intrinsicContentSize : storedImage?.craftIntrinsicSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
|
4187
4201
|
}
|
|
4188
4202
|
|
|
4189
4203
|
func configureObjectGeometry(props: [String: Any], style: [String: Any]) {
|
|
@@ -4197,22 +4211,28 @@ class CraftNativePositionedImageView: UIImageView {
|
|
|
4197
4211
|
let requested = rawFit as? String ?? "fill"
|
|
4198
4212
|
fit = ["fill", "contain", "cover", "none", "scale-down"].contains(requested) ? requested : "fill"
|
|
4199
4213
|
position = Self.objectPosition(rawPosition)
|
|
4200
|
-
if fittedImage == nil {
|
|
4201
|
-
let child = UIImageView()
|
|
4202
|
-
child.isUserInteractionEnabled = false
|
|
4203
|
-
child.isAccessibilityElement = false
|
|
4204
|
-
child.contentMode = .scaleToFill
|
|
4205
|
-
insertSubview(child, at: 0)
|
|
4206
|
-
fittedImage = child
|
|
4207
|
-
}
|
|
4208
4214
|
} else { fit = nil }
|
|
4209
|
-
fittedImage
|
|
4210
|
-
super.image = fit == nil ? storedImage : nil
|
|
4211
|
-
fittedImage?.image = storedImage
|
|
4215
|
+
fittedImage.contentMode = fit == nil ? contentMode : .scaleToFill
|
|
4212
4216
|
invalidateIntrinsicContentSize()
|
|
4213
4217
|
setNeedsLayout()
|
|
4214
4218
|
}
|
|
4215
4219
|
|
|
4220
|
+
override var contentMode: UIView.ContentMode {
|
|
4221
|
+
didSet {
|
|
4222
|
+
fittedImage.contentMode = fit == nil ? contentMode : .scaleToFill
|
|
4223
|
+
setNeedsLayout()
|
|
4224
|
+
}
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4227
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
4228
|
+
fit == nil ? fittedImage.sizeThatFits(size) : intrinsicContentSize
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
override func tintColorDidChange() {
|
|
4232
|
+
super.tintColorDidChange()
|
|
4233
|
+
fittedImage.tintColor = tintColor
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4216
4236
|
var usesObjectGeometry: Bool { fit != nil }
|
|
4217
4237
|
var objectCovers: Bool { fit == "cover" }
|
|
4218
4238
|
|
|
@@ -4234,7 +4254,8 @@ class CraftNativePositionedImageView: UIImageView {
|
|
|
4234
4254
|
|
|
4235
4255
|
override func layoutSubviews() {
|
|
4236
4256
|
super.layoutSubviews()
|
|
4237
|
-
|
|
4257
|
+
let child = fittedImage
|
|
4258
|
+
guard let fit else { child.frame = bounds; return }
|
|
4238
4259
|
let source = storedImage?.craftIntrinsicSize ?? .zero
|
|
4239
4260
|
guard source.width > 0, source.height > 0 else { child.frame = .zero; return }
|
|
4240
4261
|
let contain = min(bounds.width / source.width, bounds.height / source.height)
|
|
@@ -9203,7 +9224,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9203
9224
|
case "Icon":
|
|
9204
9225
|
configureIcon(result as! UIImageView, props: props, style: style)
|
|
9205
9226
|
case "Image":
|
|
9206
|
-
let image = result as!
|
|
9227
|
+
let image = result as! CraftNativePositionedImageView
|
|
9207
9228
|
let imageID = ObjectIdentifier(image)
|
|
9208
9229
|
if let tint = color(style["tintColor"]) {
|
|
9209
9230
|
tintedImages.insert(imageID)
|
|
@@ -9215,7 +9236,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9215
9236
|
if let current = image.image { image.image = imageForDisplay(current, view: image) }
|
|
9216
9237
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
9217
9238
|
image.contentMode = imageContentMode(requestedResizeMode)
|
|
9218
|
-
|
|
9239
|
+
image.configureObjectGeometry(props: props, style: style)
|
|
9219
9240
|
applyImageClipping(image, contentMode: image.contentMode, style: style)
|
|
9220
9241
|
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
|
|
9221
9242
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
|
|
@@ -10347,7 +10368,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10347
10368
|
view.clipsToBounds = overflow == "hidden"
|
|
10348
10369
|
|| (view is UIScrollView && overflow != "visible")
|
|
10349
10370
|
|| (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
|
|
10350
|
-
|| (view is UIImageView && (borderRadius > 0 || hasCornerRadii))
|
|
10371
|
+
|| ((view is UIImageView || view is CraftNativePositionedImageView) && (borderRadius > 0 || hasCornerRadii))
|
|
10351
10372
|
// Per-corner rounding clips its content, as its mask did.
|
|
10352
10373
|
|| hasCornerRadii
|
|
10353
10374
|
view.layer.masksToBounds = view.clipsToBounds
|
|
@@ -10659,7 +10680,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10659
10680
|
/// The pixels a picture is drawn at, when its style fixes its size (a
|
|
10660
10681
|
/// 40-point avatar, a card's photo): nil otherwise, for a picture whose
|
|
10661
10682
|
/// own size lays it out, which must keep its full size.
|
|
10662
|
-
static func displayPixels(of view:
|
|
10683
|
+
static func displayPixels(of view: UIView) -> (size: CGSize, scale: CGFloat)? {
|
|
10663
10684
|
let style = view.craftLayoutStyle
|
|
10664
10685
|
let points: CGSize
|
|
10665
10686
|
switch (style.width, style.height, style.aspectRatio) {
|
|
@@ -10742,16 +10763,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10742
10763
|
return candidate
|
|
10743
10764
|
}
|
|
10744
10765
|
|
|
10745
|
-
private func applyImageClipping(_ view:
|
|
10766
|
+
private func applyImageClipping(_ view: CraftNativePositionedImageView, contentMode: UIView.ContentMode, style: [String: Any]) {
|
|
10746
10767
|
let authored = (style["overflow"] as? String) == "hidden"
|
|
10747
10768
|
|| (view.craftRequestedCornerRadius ?? 0) > 0
|
|
10748
10769
|
|| view.craftRequestedCornerRadii != nil
|
|
10749
|
-
let clips = authored || contentMode == .scaleAspectFill ||
|
|
10770
|
+
let clips = authored || contentMode == .scaleAspectFill || view.usesObjectGeometry
|
|
10750
10771
|
view.clipsToBounds = clips
|
|
10751
10772
|
view.layer.masksToBounds = clips
|
|
10752
10773
|
}
|
|
10753
10774
|
|
|
10754
|
-
private func updateImage(_ view:
|
|
10775
|
+
private func updateImage(_ view: CraftNativePositionedImageView, source: Any?, defaultSource: Any? = nil) {
|
|
10755
10776
|
let sourceObject = source as? [String: Any]
|
|
10756
10777
|
let uri = (source as? String) ?? sourceObject?["uri"] as? String
|
|
10757
10778
|
let headers = (sourceObject?["headers"] as? [String: Any])?.reduce(into: [String: String]()) { result, entry in
|
|
@@ -10793,7 +10814,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10793
10814
|
if let url = imageURL(for: uri) {
|
|
10794
10815
|
// Decoded at the size it is drawn, when the style fixes that size.
|
|
10795
10816
|
let pixels = Self.displayPixels(of: view)
|
|
10796
|
-
let fill =
|
|
10817
|
+
let fill = view.objectCovers || view.contentMode == .scaleAspectFill
|
|
10797
10818
|
// media.keep owns private/offline copies keyed by the authored URL.
|
|
10798
10819
|
// Read before the decoded/network cache, without retaining private
|
|
10799
10820
|
// pixels there after the app clears its media on sign-out.
|
|
@@ -10884,7 +10905,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10884
10905
|
return !uri.contains(":") ? UIImage(named: uri) : nil
|
|
10885
10906
|
}
|
|
10886
10907
|
|
|
10887
|
-
private func imageFailure(_ view:
|
|
10908
|
+
private func imageFailure(_ view: CraftNativePositionedImageView, uri: String?, message: String) {
|
|
10888
10909
|
imageErrors[ObjectIdentifier(view)] = message
|
|
10889
10910
|
view.image = nil
|
|
10890
10911
|
imageDidChange(view)
|
|
@@ -10895,19 +10916,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10895
10916
|
NSLog("[craft native] %@: %@", message, uri ?? "<missing>")
|
|
10896
10917
|
}
|
|
10897
10918
|
|
|
10898
|
-
private func imageDidChange(_ view:
|
|
10919
|
+
private func imageDidChange(_ view: CraftNativePositionedImageView) {
|
|
10899
10920
|
view.invalidateIntrinsicContentSize()
|
|
10900
10921
|
CraftNativeFlexLayout.invalidateAncestors(of: view)
|
|
10901
10922
|
}
|
|
10902
10923
|
|
|
10903
|
-
private func imageForDisplay(_ image: UIImage, view:
|
|
10924
|
+
private func imageForDisplay(_ image: UIImage, view: CraftNativePositionedImageView) -> UIImage {
|
|
10904
10925
|
let displayed = image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
|
|
10905
10926
|
displayed.craftIntrinsicSize = image.craftIntrinsicSize
|
|
10906
10927
|
return displayed
|
|
10907
10928
|
}
|
|
10908
10929
|
|
|
10909
10930
|
private func emitImageEvent(
|
|
10910
|
-
_ view:
|
|
10931
|
+
_ view: CraftNativePositionedImageView,
|
|
10911
10932
|
handler: String?,
|
|
10912
10933
|
nativeEvent: [String: Any] = [:]
|
|
10913
10934
|
) {
|
|
@@ -10961,7 +10982,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10961
10982
|
switch role ?? type.lowercased() {
|
|
10962
10983
|
case "tablist": traits.insert(.tabBar)
|
|
10963
10984
|
case "button", "checkbox": traits.insert(.button)
|
|
10964
|
-
case "image": traits.insert(.image)
|
|
10985
|
+
case "image", "imagebackground": traits.insert(.image)
|
|
10965
10986
|
case "header": traits.insert(.header)
|
|
10966
10987
|
case "link": traits.insert(.link)
|
|
10967
10988
|
case "search": traits.insert(.searchField)
|
|
@@ -11023,11 +11044,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11023
11044
|
|
|
11024
11045
|
@objc private func buttonPressed(_ sender: UIButton) {
|
|
11025
11046
|
let focused = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
11026
|
-
|
|
11027
|
-
|
|
11047
|
+
let id = ObjectIdentifier(sender)
|
|
11048
|
+
// An authored handler owns navigation; the asynchronous bridge cannot
|
|
11049
|
+
// wait for preventDefault before opening the link.
|
|
11050
|
+
guard let handler = handlers[id] else {
|
|
11051
|
+
if let target = linkTargets[id] { hybridEvents?.nativeScreen(self, open: target, props: [:]) }
|
|
11028
11052
|
return
|
|
11029
11053
|
}
|
|
11030
|
-
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
11031
11054
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
|
|
11032
11055
|
restoreFocus(focused)
|
|
11033
11056
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
|
|
@@ -11191,7 +11214,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11191
11214
|
|
|
11192
11215
|
@objc private func solidSwitchChanged(_ sender: CraftNativeSolidSwitch) {
|
|
11193
11216
|
guard sender.isEnabled, let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
11194
|
-
|
|
11217
|
+
sendAfterThumb(handler: handler, value: sender.isOn)
|
|
11195
11218
|
}
|
|
11196
11219
|
|
|
11197
11220
|
@objc private func switchChanged(_ sender: UISwitch) {
|
|
@@ -11202,7 +11225,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11202
11225
|
self.switchUserValues.removeValue(forKey: id)
|
|
11203
11226
|
}
|
|
11204
11227
|
guard let handler = handlers[id] else { return }
|
|
11205
|
-
|
|
11228
|
+
sendAfterThumb(handler: handler, value: sender.isOn)
|
|
11229
|
+
}
|
|
11230
|
+
|
|
11231
|
+
/// A switch's value, told to the script after UIKit has been left to draw
|
|
11232
|
+
/// the thumb.
|
|
11233
|
+
///
|
|
11234
|
+
/// On the default main-thread script a handler runs inline, inside the
|
|
11235
|
+
/// control's own action, so the touch's CATransaction stays open until the
|
|
11236
|
+
/// script has finished its work and whatever render it causes. UIKit has
|
|
11237
|
+
/// already set the new value by then, so the thumb does not travel: the
|
|
11238
|
+
/// switch sits still for as long as the script takes and then appears in
|
|
11239
|
+
/// its new state. Yielding the runloop turn first lets that transaction
|
|
11240
|
+
/// commit, the animation is handed to the render server, and the script's
|
|
11241
|
+
/// work no longer holds the thumb. A background script never blocks the
|
|
11242
|
+
/// main thread, so it is told at once.
|
|
11243
|
+
private func sendAfterThumb(handler: String, value: Bool) {
|
|
11244
|
+
let payload: [String: Any] = ["handlerName": handler, "nativeEvent": ["value": value]]
|
|
11245
|
+
guard !CraftNativeScriptThread.isBackground else {
|
|
11246
|
+
send(type: "EVENT", payload: payload)
|
|
11247
|
+
return
|
|
11248
|
+
}
|
|
11249
|
+
DispatchQueue.main.async { [weak self] in self?.send(type: "EVENT", payload: payload) }
|
|
11206
11250
|
}
|
|
11207
11251
|
|
|
11208
11252
|
@objc private func sliderChanged(_ sender: UISlider) {
|
|
@@ -11385,11 +11429,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11385
11429
|
@objc private func viewPressed(_ sender: UITapGestureRecognizer) {
|
|
11386
11430
|
guard let view = sender.view else { return }
|
|
11387
11431
|
let id = ObjectIdentifier(view)
|
|
11388
|
-
|
|
11389
|
-
|
|
11432
|
+
guard let handler = handlers[id] else {
|
|
11433
|
+
if let target = linkTargets[id] { hybridEvents?.nativeScreen(self, open: target, props: [:]) }
|
|
11390
11434
|
return
|
|
11391
11435
|
}
|
|
11392
|
-
guard let handler = handlers[id] else { return }
|
|
11393
11436
|
if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
|
|
11394
11437
|
let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
|
|
11395
11438
|
UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in
|
|
@@ -69,6 +69,7 @@ final class CraftVoiceRecording {
|
|
|
69
69
|
var recorder: CraftVoiceRecorder? // audio queue only
|
|
70
70
|
var completion: ((Result<Any, CraftNativeActionError>) -> Void)? // main only
|
|
71
71
|
var deadline: DispatchWorkItem?
|
|
72
|
+
var stopping = false // main only
|
|
72
73
|
private let lock = NSLock()
|
|
73
74
|
private var cancelled = false
|
|
74
75
|
init(key: String, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
@@ -86,15 +87,29 @@ final class CraftVoiceRecording {
|
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
private var current: Session?
|
|
90
|
+
/// Each microphone step in the unified log (`subsystem == "craft.native"`,
|
|
91
|
+
/// category `voice-recording`), so a recording that never finished says
|
|
92
|
+
/// whether its stop arrived, and where AVFoundation held it.
|
|
93
|
+
private static func trace(_ message: String) {
|
|
94
|
+
CraftNativeConsole.write("info", category: "voice-recording", message: message)
|
|
95
|
+
}
|
|
89
96
|
private let audioQueue: DispatchQueue
|
|
90
97
|
private let permission: (@escaping (Bool) -> Void) -> Void
|
|
91
98
|
private let makeRecorder: (URL) throws -> CraftVoiceRecorder
|
|
92
99
|
private let deactivate: () -> Void
|
|
100
|
+
/// Releasing the audio session runs here, never on `audioQueue`: a
|
|
101
|
+
/// deactivation that stalls (seen on simulators with a virtual input)
|
|
102
|
+
/// must not hold the next recording's start or stop behind it.
|
|
103
|
+
private let releaseQueue = DispatchQueue(label: "craft.voice-recording-release", qos: .utility)
|
|
104
|
+
private let generationLock = NSLock()
|
|
105
|
+
private var generation = 0
|
|
93
106
|
private let startupTimeout: TimeInterval
|
|
107
|
+
private let stopTimeout: TimeInterval
|
|
94
108
|
private var backgroundObserver: NSObjectProtocol?
|
|
95
109
|
|
|
96
110
|
init(audioQueue: DispatchQueue = DispatchQueue(label: "craft.voice-recording", qos: .userInitiated),
|
|
97
111
|
startupTimeout: TimeInterval = 15,
|
|
112
|
+
stopTimeout: TimeInterval = 10,
|
|
98
113
|
permission: @escaping (@escaping (Bool) -> Void) -> Void = { answer in
|
|
99
114
|
#if CRAFT_PERMISSION_MICROPHONE
|
|
100
115
|
AVAudioSession.sharedInstance().requestRecordPermission(answer)
|
|
@@ -116,6 +131,7 @@ final class CraftVoiceRecording {
|
|
|
116
131
|
Self.prepareForLaunch()
|
|
117
132
|
self.audioQueue = audioQueue
|
|
118
133
|
self.startupTimeout = startupTimeout
|
|
134
|
+
self.stopTimeout = stopTimeout
|
|
119
135
|
self.permission = permission
|
|
120
136
|
self.makeRecorder = makeRecorder
|
|
121
137
|
self.deactivate = deactivate
|
|
@@ -128,7 +144,17 @@ final class CraftVoiceRecording {
|
|
|
128
144
|
session.recorder?.stop()
|
|
129
145
|
session.recorder = nil
|
|
130
146
|
if let file = session.file { CraftNativeFiles.remove(file.path) }
|
|
131
|
-
|
|
147
|
+
releaseAudioSession()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// Deactivate off the recording queue, and not at all once a newer
|
|
151
|
+
/// recording has started: it would only take the microphone from it.
|
|
152
|
+
private func releaseAudioSession() {
|
|
153
|
+
let mine = generationLock.withLock { generation }
|
|
154
|
+
releaseQueue.async { [weak self] in
|
|
155
|
+
guard let self, self.generationLock.withLock({ self.generation }) == mine else { return }
|
|
156
|
+
self.deactivate()
|
|
157
|
+
}
|
|
132
158
|
}
|
|
133
159
|
|
|
134
160
|
private func clear(_ error: CraftNativeActionError = CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")) {
|
|
@@ -150,6 +176,7 @@ final class CraftVoiceRecording {
|
|
|
150
176
|
private func performOnMain(method: String, args: [Any], isActive: @escaping () -> Bool, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
151
177
|
func fail(_ code: String, _ message: String) { completion(.failure(CraftNativeActionError(code: code, message: message))) }
|
|
152
178
|
guard let key = args.first as? String, UUID(uuidString: key) != nil else { fail("INVALID_ARGUMENT", "A recording owner UUID is required"); return }
|
|
179
|
+
Self.trace("\(method) \(key.prefix(8)) (current \(current.map { String($0.key.prefix(8)) } ?? "none"))")
|
|
153
180
|
if method == "cancel" {
|
|
154
181
|
if current?.key == key { clear() }
|
|
155
182
|
completion(.success(true))
|
|
@@ -157,11 +184,36 @@ final class CraftVoiceRecording {
|
|
|
157
184
|
}
|
|
158
185
|
if method == "stop" {
|
|
159
186
|
guard let session = current, session.key == key, let file = session.file else { fail("RECORDING_NOT_FOUND", "This recording is no longer available"); return }
|
|
187
|
+
guard !session.stopping else { fail("RECORDING_BUSY", "This recording is already finishing"); return }
|
|
188
|
+
session.stopping = true
|
|
189
|
+
// Answered exactly once: by the encoder's result, or by the
|
|
190
|
+
// deadline when AVFoundation never returns (a stalled encoder or
|
|
191
|
+
// audio route), which then erases the recording like a cancel.
|
|
192
|
+
var settled = false
|
|
193
|
+
var timeout: DispatchWorkItem?
|
|
194
|
+
func settle(_ result: Result<Any, CraftNativeActionError>) {
|
|
195
|
+
timeout?.cancel()
|
|
196
|
+
guard !settled else { return }
|
|
197
|
+
settled = true
|
|
198
|
+
completion(result)
|
|
199
|
+
}
|
|
200
|
+
let late = CraftNativeActionError(code: "RECORDING_TIMEOUT", message: "The recording took too long to finish. Please try again.")
|
|
201
|
+
timeout = DispatchWorkItem { [weak self] in
|
|
202
|
+
guard !settled else { return }
|
|
203
|
+
Self.trace("stop \(key.prefix(8)) timed out waiting for the encoder")
|
|
204
|
+
if let self, self.current === session { self.clear(late) }
|
|
205
|
+
settle(.failure(late))
|
|
206
|
+
}
|
|
207
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + stopTimeout, execute: timeout!)
|
|
160
208
|
audioQueue.async {
|
|
161
|
-
guard !session.isCancelled else {
|
|
209
|
+
guard !session.isCancelled else {
|
|
210
|
+
return DispatchQueue.main.async { settle(.failure(CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled"))) }
|
|
211
|
+
}
|
|
162
212
|
session.recorder?.stop()
|
|
163
213
|
session.recorder = nil
|
|
164
|
-
|
|
214
|
+
Self.trace("stop \(key.prefix(8)): encoder stopped")
|
|
215
|
+
// Read the finished file before releasing the audio session:
|
|
216
|
+
// the answer must not wait on a deactivation that can stall.
|
|
165
217
|
let result: Result<Any, CraftNativeActionError>
|
|
166
218
|
if let audio = try? AVAudioFile(forReading: file), audio.length > 0 {
|
|
167
219
|
let duration = Double(audio.length) / audio.processingFormat.sampleRate
|
|
@@ -170,14 +222,17 @@ final class CraftVoiceRecording {
|
|
|
170
222
|
result = .failure(CraftNativeActionError(code: "EMPTY_RECORDING", message: "The recording was empty"))
|
|
171
223
|
}
|
|
172
224
|
DispatchQueue.main.async {
|
|
225
|
+
guard !settled else { return }
|
|
173
226
|
guard self.current === session, isActive(), !session.isCancelled else {
|
|
174
227
|
if self.current === session { self.clear() }
|
|
175
|
-
|
|
228
|
+
settle(.failure(CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")))
|
|
176
229
|
return
|
|
177
230
|
}
|
|
178
231
|
if case .failure = result { self.clear() }
|
|
179
|
-
|
|
232
|
+
Self.trace("stop \(key.prefix(8)) answered")
|
|
233
|
+
settle(result)
|
|
180
234
|
}
|
|
235
|
+
self.releaseAudioSession()
|
|
181
236
|
}
|
|
182
237
|
return
|
|
183
238
|
}
|
|
@@ -209,6 +264,7 @@ final class CraftVoiceRecording {
|
|
|
209
264
|
self.audioQueue.async {
|
|
210
265
|
do {
|
|
211
266
|
guard !session.isCancelled else { return }
|
|
267
|
+
self.generationLock.withLock { self.generation += 1 }
|
|
212
268
|
session.recorder = try self.makeRecorder(file)
|
|
213
269
|
guard !session.isCancelled else { self.erase(session); return }
|
|
214
270
|
guard session.recorder?.record(forDuration: max(0.5, seconds - 0.25)) == true else {
|
|
@@ -220,6 +276,7 @@ final class CraftVoiceRecording {
|
|
|
220
276
|
if self.current === session { self.clear() }
|
|
221
277
|
return
|
|
222
278
|
}
|
|
279
|
+
Self.trace("start \(key.prefix(8)): recording")
|
|
223
280
|
session.finish(.success(true))
|
|
224
281
|
}
|
|
225
282
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#import "CraftTimedInput.h"
|
|
2
2
|
#import <UIKit/UIKit.h>
|
|
3
3
|
#import <math.h>
|
|
4
|
+
#import <dlfcn.h>
|
|
5
|
+
#import <XCTest/XCTest.h>
|
|
4
6
|
|
|
5
7
|
// XCTest's own touch-record API is private. Look up classes and check every
|
|
6
8
|
// selector at runtime; unsupported Xcode versions fail rather than skipping or
|
|
@@ -12,6 +14,16 @@
|
|
|
12
14
|
- (id)initWithName:(NSString *)name interfaceOrientation:(NSInteger)orientation;
|
|
13
15
|
- (void)addPointerEventPath:(id)path;
|
|
14
16
|
- (BOOL)synthesizeWithError:(NSError **)error;
|
|
17
|
+
+ (id)eventStreamWithEventActions:(void (^)(id))actions;
|
|
18
|
+
- (void)advanceTime:(double)seconds;
|
|
19
|
+
- (void)touchDown:(CGPoint)point;
|
|
20
|
+
- (void)liftUp:(CGPoint)point;
|
|
21
|
+
- (id)underlyingPlayer;
|
|
22
|
+
- (BOOL)prewarmForEventStream:(id)stream withError:(NSError **)error;
|
|
23
|
+
- (void)setNeedsInitialDelay:(BOOL)value;
|
|
24
|
+
- (void)setIgnoresCompletionDelay:(BOOL)value;
|
|
25
|
+
- (void)playEventStream:(id)stream options:(id)options completion:(void (^)(void))completion;
|
|
26
|
+
- (void)tearDown;
|
|
15
27
|
@end
|
|
16
28
|
|
|
17
29
|
@implementation CraftTimedInput
|
|
@@ -21,6 +33,68 @@
|
|
|
21
33
|
return NO;
|
|
22
34
|
}
|
|
23
35
|
|
|
36
|
+
// The simulator's XCTest touch-path translator omits idle intervals between
|
|
37
|
+
// fingers and rounds stationary moves to its sample interval. Submit stationary,
|
|
38
|
+
// non-overlapping taps as one native HID stream with explicit clock advances.
|
|
39
|
+
// Moving/overlapping paths retain the existing XCTest gesture implementation.
|
|
40
|
+
+ (BOOL)synthesizeStationaryPaths:(NSArray *)paths frame:(CGRect)frame error:(NSError **)error {
|
|
41
|
+
static dispatch_once_t once;
|
|
42
|
+
static void *recap;
|
|
43
|
+
dispatch_once(&once, ^{
|
|
44
|
+
recap = dlopen("/System/Library/PrivateFrameworks/Recap.framework/Recap", RTLD_NOW | RTLD_LOCAL);
|
|
45
|
+
});
|
|
46
|
+
Class streamClass = NSClassFromString(@"RCPSyntheticEventStream");
|
|
47
|
+
Class playerClass = NSClassFromString(@"RCPInlinePlayer");
|
|
48
|
+
if (!recap || ![streamClass respondsToSelector:@selector(eventStreamWithEventActions:)])
|
|
49
|
+
return [self fail:@"Native timed tap streams are unavailable on this runtime" error:error];
|
|
50
|
+
for (NSString *name in @[@"advanceTime:", @"touchDown:", @"liftUp:"])
|
|
51
|
+
if (![streamClass instancesRespondToSelector:NSSelectorFromString(name)])
|
|
52
|
+
return [self fail:@"Native timed tap stream cannot preserve the schedule" error:error];
|
|
53
|
+
for (NSString *name in @[@"underlyingPlayer", @"setNeedsInitialDelay:", @"setIgnoresCompletionDelay:",
|
|
54
|
+
@"playEventStream:options:completion:", @"tearDown"])
|
|
55
|
+
if (![playerClass instancesRespondToSelector:NSSelectorFromString(name)])
|
|
56
|
+
return [self fail:@"Native timed tap playback is unavailable" error:error];
|
|
57
|
+
|
|
58
|
+
id stream = [streamClass eventStreamWithEventActions:^(id builder) {
|
|
59
|
+
double previous = 0;
|
|
60
|
+
for (NSArray *points in paths) {
|
|
61
|
+
NSDictionary *first = points.firstObject, *last = points.lastObject;
|
|
62
|
+
double start = [first[@"at"] doubleValue], end = [last[@"at"] doubleValue];
|
|
63
|
+
CGPoint point = CGPointMake(frame.origin.x + [first[@"x"] doubleValue] * frame.size.width,
|
|
64
|
+
frame.origin.y + [first[@"y"] doubleValue] * frame.size.height);
|
|
65
|
+
[builder advanceTime:start - previous];
|
|
66
|
+
[builder touchDown:point];
|
|
67
|
+
[builder advanceTime:end - start];
|
|
68
|
+
[builder liftUp:point];
|
|
69
|
+
previous = end;
|
|
70
|
+
}
|
|
71
|
+
}];
|
|
72
|
+
if (!stream) return [self fail:@"Native timed tap stream could not be constructed" error:error];
|
|
73
|
+
id player = [playerClass new];
|
|
74
|
+
id underlying = [player underlyingPlayer];
|
|
75
|
+
if (![underlying respondsToSelector:@selector(prewarmForEventStream:withError:)]) {
|
|
76
|
+
[player tearDown];
|
|
77
|
+
return [self fail:@"Native timed tap delivery cannot be prepared" error:error];
|
|
78
|
+
}
|
|
79
|
+
// Preparing the HID delivery service emits no touches. Do not add a warm-up
|
|
80
|
+
// touch or an initial/completion sleep to make the receiver appear reliable.
|
|
81
|
+
if (![underlying prewarmForEventStream:stream withError:error]) {
|
|
82
|
+
[player tearDown];
|
|
83
|
+
return NO;
|
|
84
|
+
}
|
|
85
|
+
[player setNeedsInitialDelay:NO];
|
|
86
|
+
[player setIgnoresCompletionDelay:YES];
|
|
87
|
+
XCTestExpectation *done = [[XCTestExpectation alloc] initWithDescription:@"Native timed tap completion"];
|
|
88
|
+
[player playEventStream:stream options:nil completion:^{
|
|
89
|
+
[player tearDown];
|
|
90
|
+
[done fulfill];
|
|
91
|
+
}];
|
|
92
|
+
// Validated streams end within 12s. A timeout is a failure, never a retry.
|
|
93
|
+
if ([XCTWaiter waitForExpectations:@[done] timeout:20] != XCTWaiterResultCompleted)
|
|
94
|
+
return [self fail:@"Native timed tap playback did not complete" error:error];
|
|
95
|
+
return YES;
|
|
96
|
+
}
|
|
97
|
+
|
|
24
98
|
+ (BOOL)synthesizePaths:(NSArray *)paths frame:(CGRect)frame error:(NSError **)error {
|
|
25
99
|
if (![paths isKindOfClass:NSArray.class] || paths.count == 0 || paths.count > 64
|
|
26
100
|
|| CGRectIsEmpty(frame) || !isfinite(frame.size.width) || !isfinite(frame.size.height))
|
|
@@ -43,6 +117,21 @@
|
|
|
43
117
|
previous = at;
|
|
44
118
|
}
|
|
45
119
|
}
|
|
120
|
+
NSArray *ordered = [paths sortedArrayUsingComparator:^NSComparisonResult(NSArray *left, NSArray *right) {
|
|
121
|
+
return [left.firstObject[@"at"] compare:right.firstObject[@"at"]];
|
|
122
|
+
}];
|
|
123
|
+
BOOL stationary = [ordered.firstObject[0][@"at"] doubleValue] == 0;
|
|
124
|
+
double previousEnd = 0;
|
|
125
|
+
for (NSArray *points in ordered) {
|
|
126
|
+
NSDictionary *first = points.firstObject;
|
|
127
|
+
if ([first[@"at"] doubleValue] < previousEnd) stationary = NO;
|
|
128
|
+
for (NSDictionary *point in points)
|
|
129
|
+
if ([point[@"x"] doubleValue] != [first[@"x"] doubleValue]
|
|
130
|
+
|| [point[@"y"] doubleValue] != [first[@"y"] doubleValue]) stationary = NO;
|
|
131
|
+
previousEnd = [points.lastObject[@"at"] doubleValue];
|
|
132
|
+
}
|
|
133
|
+
if (stationary) return [self synthesizeStationaryPaths:ordered frame:frame error:error];
|
|
134
|
+
|
|
46
135
|
Class recordClass = NSClassFromString(@"XCSynthesizedEventRecord");
|
|
47
136
|
Class pathClass = NSClassFromString(@"XCPointerEventPath");
|
|
48
137
|
for (NSString *name in @[@"initWithName:interfaceOrientation:", @"addPointerEventPath:", @"synthesizeWithError:"])
|
|
@@ -68,7 +157,7 @@
|
|
|
68
157
|
[path liftUpAtOffset:[points.lastObject[@"at"] doubleValue]];
|
|
69
158
|
[record addPointerEventPath:path];
|
|
70
159
|
}
|
|
71
|
-
// One dispatch
|
|
160
|
+
// One dispatch preserves the existing moving/overlapping gesture path.
|
|
72
161
|
// Individual XCUICoordinate.tap() calls would wait for app quiescence.
|
|
73
162
|
return [record synthesizeWithError:error];
|
|
74
163
|
}
|
|
@@ -42,7 +42,7 @@ import Darwin
|
|
|
42
42
|
/// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not
|
|
43
43
|
/// selected <tab> the tab bar's selected button is this one
|
|
44
44
|
/// tab <name> tap a tab bar button
|
|
45
|
-
/// scrollto <text>
|
|
45
|
+
/// scrollto <text> at most 12 drags until the target is hittable and visible
|
|
46
46
|
/// swipe up|down|left|right
|
|
47
47
|
/// drag x1 y1 x2 y2 slow app-relative drag, held 0.3s before release
|
|
48
48
|
/// audit <name> accessibility audit (element descriptions, Dynamic Type, contrast); logs each issue
|
|
@@ -143,13 +143,29 @@ final class DriverTests: XCTestCase {
|
|
|
143
143
|
return any.exists ? any : nil
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/// Where a label is looked for, in order: the topmost presented alert,
|
|
147
|
+
/// then the topmost sheet, then the whole app. A sheet's "Sign out" and
|
|
148
|
+
/// the row that opened it share a label, and the row behind the sheet
|
|
149
|
+
/// is still reported hittable, so the presented one has to win.
|
|
150
|
+
func searchScopes<Scope>(alerts: [Scope], sheets: [Scope], app: Scope) -> [Scope] {
|
|
151
|
+
[alerts.last, sheets.last].compactMap { $0 } + [app]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func presentedScopes() -> [XCUIElement] {
|
|
155
|
+
let alerts = app.alerts.allElementsBoundByIndex.filter { $0.exists }
|
|
156
|
+
let sheets = app.sheets.allElementsBoundByIndex.filter { $0.exists }
|
|
157
|
+
return searchScopes(alerts: alerts, sheets: sheets, app: app as XCUIElement)
|
|
158
|
+
}
|
|
159
|
+
|
|
146
160
|
func exact(_ label: String) -> XCUIElement? {
|
|
147
161
|
let predicate = NSPredicate(format: "label == %@", label)
|
|
148
|
-
for
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
for scope in presentedScopes() {
|
|
163
|
+
for query in [scope.buttons, scope.links, scope.staticTexts] {
|
|
164
|
+
let matches = query.matching(predicate)
|
|
165
|
+
for index in 0..<min(matches.count, 25) {
|
|
166
|
+
let candidate = matches.element(boundBy: index)
|
|
167
|
+
if candidate.exists && candidate.isHittable { return candidate }
|
|
168
|
+
}
|
|
153
169
|
}
|
|
154
170
|
}
|
|
155
171
|
return nil
|
|
@@ -164,6 +180,27 @@ final class DriverTests: XCTestCase {
|
|
|
164
180
|
return nil
|
|
165
181
|
}
|
|
166
182
|
|
|
183
|
+
/// A short target fits entirely; an oversized target fills the viewport.
|
|
184
|
+
func visibleScrollFrame(_ target: CGRect, in viewport: CGRect) -> Bool {
|
|
185
|
+
let visible = target.intersection(viewport)
|
|
186
|
+
return !target.isEmpty && !viewport.isEmpty && !visible.isNull
|
|
187
|
+
&& visible.width >= min(target.width, viewport.width)
|
|
188
|
+
&& visible.height >= min(target.height, viewport.height)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func scrollTargetVisible(_ target: XCUIElement?) -> Bool {
|
|
192
|
+
guard let target, target.exists, target.isHittable,
|
|
193
|
+
visibleScrollFrame(target.frame, in: app.frame) else { return false }
|
|
194
|
+
// Hittable alone accepts controls clipped by the scroll view's edge.
|
|
195
|
+
// Only an ancestor scroll view clips this target, not its siblings.
|
|
196
|
+
let identity = NSPredicate(format: "identifier == %@ AND label == %@", target.identifier, target.label)
|
|
197
|
+
for scroll in app.scrollViews.allElementsBoundByIndex {
|
|
198
|
+
if scroll.descendants(matching: .any).matching(identity).firstMatch.exists,
|
|
199
|
+
!visibleScrollFrame(target.frame, in: scroll.frame.intersection(app.frame)) { return false }
|
|
200
|
+
}
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
|
|
167
204
|
/// About a third of a screen of scrolling, as a held drag.
|
|
168
205
|
///
|
|
169
206
|
/// `app.swipeUp()` is a flick, and how far a flick throws a scroll view
|
|
@@ -590,13 +627,13 @@ final class DriverTests: XCTestCase {
|
|
|
590
627
|
case "scrollto":
|
|
591
628
|
var found = element(containing: arg)
|
|
592
629
|
var drags = 0
|
|
593
|
-
while
|
|
630
|
+
while !scrollTargetVisible(found) && drags < 12 {
|
|
594
631
|
let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < app.frame.midY } ?? false
|
|
595
632
|
scrollPage(towardTop: towardTop)
|
|
596
633
|
drags += 1
|
|
597
634
|
found = element(containing: arg)
|
|
598
635
|
}
|
|
599
|
-
check(found
|
|
636
|
+
check(scrollTargetVisible(found), "scrollto \(arg)")
|
|
600
637
|
case "swipe":
|
|
601
638
|
switch arg {
|
|
602
639
|
case "up": app.swipeUp()
|
|
@@ -644,7 +681,9 @@ final class DriverTests: XCTestCase {
|
|
|
644
681
|
// A system prompt over the app ("Save Password?" → Not Now), wherever iOS shows it.
|
|
645
682
|
let label = arg.isEmpty ? "Not Now" : arg
|
|
646
683
|
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
647
|
-
|
|
684
|
+
// The app's own alert or sheet first (its button can share a
|
|
685
|
+
// label with the control behind it), then the app, then iOS.
|
|
686
|
+
let candidates = { self.presentedScopes().map { $0.buttons[label] } + [springboard.buttons[label]] }
|
|
648
687
|
let end = Date().addingTimeInterval(6)
|
|
649
688
|
// A tap is not a dismissal. iOS 26 draws "Save Password?" as a
|
|
650
689
|
// sheet that is in the tree, and hittable, while it still slides
|
|
@@ -655,7 +694,7 @@ final class DriverTests: XCTestCase {
|
|
|
655
694
|
var tapped = false
|
|
656
695
|
var gone = false
|
|
657
696
|
while !gone && Date() < end {
|
|
658
|
-
guard let button = candidates.first(where: { $0.exists && $0.isHittable }) else {
|
|
697
|
+
guard let button = candidates().first(where: { $0.exists && $0.isHittable }) else {
|
|
659
698
|
if tapped { gone = true } else { Thread.sleep(forTimeInterval: 0.5) }
|
|
660
699
|
continue
|
|
661
700
|
}
|
package/dist/macos/src/index.js
CHANGED
|
@@ -43,9 +43,9 @@ import process from "process";
|
|
|
43
43
|
var macosToolchain = {
|
|
44
44
|
zig: "0.18.0-dev.252+86dc5ce25",
|
|
45
45
|
sources: {
|
|
46
|
-
"zig-js": { repository: "zig-utils/zig-js", revision: "
|
|
46
|
+
"zig-js": { repository: "zig-utils/zig-js", revision: "59497a090677aeb317e52ac55329e6a680457727", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
|
|
47
47
|
"zig-regex": { repository: "zig-utils/zig-regex", revision: "fdd9c2e333ea6ac2fd06d080d939ef7f11d61b1b", paths: ["src"] },
|
|
48
|
-
"zig-gc": { repository: "zig-utils/zig-gc", revision: "
|
|
48
|
+
"zig-gc": { repository: "zig-utils/zig-gc", revision: "73c802e05768a729614e9c4296abb118aa06faf9", paths: ["src"] }
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
51
|
var defaultRun = async (args, cwd) => {
|
|
@@ -9,7 +9,7 @@ export declare const macosToolchain: {
|
|
|
9
9
|
readonly sources: {
|
|
10
10
|
readonly 'zig-js': {
|
|
11
11
|
readonly repository: 'zig-utils/zig-js';
|
|
12
|
-
readonly revision: '
|
|
12
|
+
readonly revision: '59497a090677aeb317e52ac55329e6a680457727';
|
|
13
13
|
readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
|
|
14
14
|
};
|
|
15
15
|
readonly 'zig-regex': {
|
|
@@ -19,7 +19,7 @@ export declare const macosToolchain: {
|
|
|
19
19
|
};
|
|
20
20
|
readonly 'zig-gc': {
|
|
21
21
|
readonly repository: 'zig-utils/zig-gc';
|
|
22
|
-
readonly revision: '
|
|
22
|
+
readonly revision: '73c802e05768a729614e9c4296abb118aa06faf9';
|
|
23
23
|
readonly paths: readonly ['src'];
|
|
24
24
|
};
|
|
25
25
|
};
|
|
@@ -21,6 +21,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
|
|
|
21
21
|
let trafficLights: CGPoint?
|
|
22
22
|
/// The screen has work to finish before the app quits (`craft.desktop.onQuit`).
|
|
23
23
|
var holdsQuit = false
|
|
24
|
+
/// The content width the screen last heard, so a resize that changes nothing sends nothing.
|
|
25
|
+
var reportedViewportWidth: CGFloat = -1
|
|
24
26
|
init(name: String, route: String, options: [String: Any], application: CraftMacApplication) {
|
|
25
27
|
self.name = name
|
|
26
28
|
runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: application.network)
|
|
@@ -49,9 +51,22 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
|
|
|
49
51
|
if name == "main" && application.testDirectory == nil { window.setFrameAutosaveName("\(application.appName)NativeMain") }
|
|
50
52
|
window.makeKeyAndOrderFront(nil)
|
|
51
53
|
placeTrafficLights()
|
|
54
|
+
// The screen starts knowing its width, so `md:`/`lg:` classes and `matchMedia` are right on the first frame.
|
|
55
|
+
let size = window.contentLayoutRect.size
|
|
56
|
+
reportedViewportWidth = size.width
|
|
57
|
+
runtime.context.evaluateScript("__stxNativeBridge.viewportWidth = \(Double(size.width)); __stxNativeBridge.viewportHeight = \(Double(size.height));")
|
|
52
58
|
runtime.load(application.bundle)
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/// Tells the screen its new width (VIEWPORT, as a phone does on rotation): only when the width changed,
|
|
62
|
+
/// since a live resize lays out every frame.
|
|
63
|
+
func reportViewport() {
|
|
64
|
+
let size = window.contentLayoutRect.size
|
|
65
|
+
guard size.width > 0, abs(size.width - reportedViewportWidth) >= 0.5 else { return }
|
|
66
|
+
reportedViewportWidth = size.width
|
|
67
|
+
runtime.send("VIEWPORT", ["width": Double(size.width), "height": Double(size.height)])
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
/// AppKit lays the title bar out again on resize, full screen and appearance changes, so this runs after each.
|
|
56
71
|
func placeTrafficLights() {
|
|
57
72
|
guard let inset = trafficLights, let close = window.standardWindowButton(.closeButton), let titlebar = close.superview?.superview else { return }
|
|
@@ -64,8 +79,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
|
|
|
64
79
|
window.standardWindowButton(type)?.setFrameOrigin(CGPoint(x: inset.x + CGFloat(index) * (size.width + 6), y: (height - size.height) / 2))
|
|
65
80
|
}
|
|
66
81
|
}
|
|
67
|
-
func windowDidResize(_ notification: Notification) { placeTrafficLights() }
|
|
68
|
-
func windowDidExitFullScreen(_ notification: Notification) { placeTrafficLights() }
|
|
82
|
+
func windowDidResize(_ notification: Notification) { placeTrafficLights(); reportViewport() }
|
|
83
|
+
func windowDidExitFullScreen(_ notification: Notification) { placeTrafficLights(); reportViewport() }
|
|
69
84
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
|
70
85
|
if name == "main" { sender.orderOut(nil); return false }
|
|
71
86
|
return true
|
|
@@ -213,6 +228,9 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
213
228
|
defer { CATransaction.commit() }
|
|
214
229
|
let scheme = CraftMacColor.isDark ? "dark" : "light"
|
|
215
230
|
for scene in scenes.values {
|
|
231
|
+
// AppKit lays the title bar out again for the new appearance, after this returns.
|
|
232
|
+
scene.placeTrafficLights()
|
|
233
|
+
DispatchQueue.main.async { [weak scene] in scene?.placeTrafficLights() }
|
|
216
234
|
scene.runtime.context.evaluateScript("__stxNativeBridge.colorScheme = '\(scheme)';")
|
|
217
235
|
// The protocol's own message: the screen restyles its `dark:` classes and tells `craft.appearance.onChange`.
|
|
218
236
|
scene.runtime.send("APPEARANCE", ["colorScheme": scheme])
|
|
@@ -455,7 +473,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
455
473
|
let box = scene.renderer.boxes.values.first(where: { ($0.props["testID"] as? String ?? $0.props["testId"] as? String ?? $0.id) == target }) else { return ["ok": false, "error": "No control \(action["target"] ?? "")"] }
|
|
456
474
|
let text = action["text"] as? String ?? ""
|
|
457
475
|
switch kind {
|
|
458
|
-
case "press": if let button = box.control as? NSButton { button.performClick(nil) } else { box.fire("onPress") }
|
|
476
|
+
case "press": if let button = box.control as? NSButton { button.performClick(nil) } else if let toggle = box.control as? NSSwitch { toggle.performClick(nil) } else { box.fire("onPress") }
|
|
459
477
|
case "type":
|
|
460
478
|
if let field = box.control as? NSTextField { field.stringValue = text }
|
|
461
479
|
else if let view = (box.control as? NSScrollView)?.documentView as? NSTextView { view.string = text }
|
|
@@ -500,7 +518,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
500
518
|
let editor = (target as? NSTextField)?.currentEditor() as? NSTextView ?? target as? NSTextView
|
|
501
519
|
guard let editor else { return ["ok": false, "error": "Not a text input"] }
|
|
502
520
|
editor.doCommand(by: command)
|
|
503
|
-
case "click": if let button = box.control as? NSButton { button.performClick(nil) } else { box.pressed() }
|
|
521
|
+
case "click": if let button = box.control as? NSButton { button.performClick(nil) } else if let toggle = box.control as? NSSwitch { toggle.performClick(nil) } else { box.pressed() }
|
|
504
522
|
case "fill":
|
|
505
523
|
let value = action["value"] as? String ?? ""
|
|
506
524
|
if let field = box.control as? NSTextField { field.stringValue = value; box.input(value) }
|
|
@@ -536,7 +554,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
536
554
|
return node
|
|
537
555
|
}
|
|
538
556
|
func classes(_ view: NSView) -> [String] { [String(describing: type(of: view))] + view.subviews.flatMap(classes) }
|
|
539
|
-
return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: type(of: scene.runtime.context)), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "errors": scene.runtime.errors]
|
|
557
|
+
return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: type(of: scene.runtime.context)), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "viewportWidth": Double(scene.reportedViewportWidth), "errors": scene.runtime.errors]
|
|
540
558
|
}
|
|
541
559
|
let webViews = windows.reduce(0) { count, window in count + ((window["viewClasses"] as? [String] ?? []).filter { $0.contains("WKWebView") || $0 == "WebView" }.count) }
|
|
542
560
|
return ["renderer": "appkit", "scriptEngine": "zig-js", "appearance": CraftMacColor.isDark ? "dark" : "light", "windows": windows, "webViews": webViews, "menu": testMenu]
|
|
@@ -277,6 +277,18 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
277
277
|
var lastDropPosition = ""
|
|
278
278
|
var acceptsDrops = false
|
|
279
279
|
var wantedFocus = false
|
|
280
|
+
/// Measurements are pure in a box's subtree (its style, props, text and children) and the size
|
|
281
|
+
/// offered, but a flex pass asks for the same ones over and over: a column measures its children
|
|
282
|
+
/// twice, a row measures them again at their final widths, and each child's own layout measures its
|
|
283
|
+
/// subtree once more. Unremembered, that repeats exponentially with nesting depth (a browser window
|
|
284
|
+
/// spent most of its first render here). Every change to a box (its style, props, text, visibility
|
|
285
|
+
/// or children) arrives through `render`, which starts a new generation and so forgets every measurement.
|
|
286
|
+
static var measureGeneration = 0
|
|
287
|
+
/// Measurements computed rather than remembered; the layout checks hold it to the size of the tree.
|
|
288
|
+
static var measuredCount = 0
|
|
289
|
+
private struct MeasureKey: Hashable { let width: CGFloat, stretch: Bool }
|
|
290
|
+
private var measured: [MeasureKey: CGSize] = [:]
|
|
291
|
+
private var measuredGeneration = -1
|
|
280
292
|
var componentRoot: CraftMacBox? { props["data-stx-component-wrapper"] != nil && flow?.boxes.count == 1 && flow?.boxes.first?.kind != "Modal" ? flow?.boxes.first : nil }
|
|
281
293
|
var layoutStyle: [String: Any] {
|
|
282
294
|
guard let child = componentRoot else { return style }
|
|
@@ -306,7 +318,10 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
306
318
|
case "Picker":
|
|
307
319
|
let picker = NSPopUpButton(frame: .zero, pullsDown: false)
|
|
308
320
|
picker.target = self; picker.action = #selector(changed); control = picker
|
|
309
|
-
case "Switch"
|
|
321
|
+
case "Switch":
|
|
322
|
+
// A toggle, as System Settings draws one; a checkbox is the Checkbox primitive's.
|
|
323
|
+
let toggle = NSSwitch(); toggle.target = self; toggle.action = #selector(changed); control = toggle
|
|
324
|
+
case "Checkbox":
|
|
310
325
|
let button = NSButton(checkboxWithTitle: "", target: self, action: #selector(changed)); control = button
|
|
311
326
|
case "Icon", "Image":
|
|
312
327
|
let image = NSImageView(); image.imageScaling = .scaleProportionallyUpOrDown; control = image
|
|
@@ -395,6 +410,11 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
395
410
|
text.maximumNumberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue ?? 0
|
|
396
411
|
text.lineBreakMode = text.maximumNumberOfLines == 1 ? .byTruncatingTail : .byWordWrapping
|
|
397
412
|
}
|
|
413
|
+
if let toggle = control as? NSSwitch {
|
|
414
|
+
toggle.state = (props["value"] as? Bool ?? props["checked"] as? Bool ?? false) ? .on : .off
|
|
415
|
+
toggle.isEnabled = props["disabled"] as? Bool != true
|
|
416
|
+
if let label { toggle.setAccessibilityLabel(label) }
|
|
417
|
+
}
|
|
398
418
|
if let button = control as? NSButton {
|
|
399
419
|
let title = props["title"] as? String ?? nativeText(node)
|
|
400
420
|
button.title = props["iconOnly"] as? Bool == true ? "" : title
|
|
@@ -705,6 +725,15 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
705
725
|
return NSEdgeInsets(top: number(style["paddingTop"]) ?? vertical, left: number(style["paddingLeft"]) ?? horizontal, bottom: number(style["paddingBottom"]) ?? vertical, right: number(style["paddingRight"]) ?? horizontal)
|
|
706
726
|
}
|
|
707
727
|
func measure(width: CGFloat, stretch: Bool = false) -> CGSize {
|
|
728
|
+
if measuredGeneration != CraftMacBox.measureGeneration || measured.count > 64 { measured.removeAll(keepingCapacity: true); measuredGeneration = CraftMacBox.measureGeneration }
|
|
729
|
+
let key = MeasureKey(width: width, stretch: stretch)
|
|
730
|
+
if let size = measured[key] { return size }
|
|
731
|
+
let size = measureSubtree(width: width, stretch: stretch)
|
|
732
|
+
measured[key] = size
|
|
733
|
+
return size
|
|
734
|
+
}
|
|
735
|
+
private func measureSubtree(width: CGFloat, stretch: Bool) -> CGSize {
|
|
736
|
+
CraftMacBox.measuredCount &+= 1
|
|
708
737
|
if isHidden { return .zero }
|
|
709
738
|
if let child = componentRoot { return child.measure(width: width, stretch: stretch) }
|
|
710
739
|
let specifiedWidth = dimension(style["width"], base: width)
|
|
@@ -790,7 +819,8 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
790
819
|
func fire(_ name: String, _ payload: [String: Any] = [:]) { renderer?.runtime.event(events[name], payload: payload) }
|
|
791
820
|
@objc func pressed() { fire("onPress") }
|
|
792
821
|
@objc func changed() {
|
|
793
|
-
let
|
|
822
|
+
let state: NSControl.StateValue? = (control as? NSSwitch)?.state ?? (control as? NSButton)?.state
|
|
823
|
+
let value: Any = (control as? NSPopUpButton)?.selectedItem?.representedObject ?? (state == .on)
|
|
794
824
|
fire("onValueChange", ["value": value, "nativeEvent": ["value": value]])
|
|
795
825
|
}
|
|
796
826
|
func controlTextDidChange(_ notification: Notification) { if let field = notification.object as? NSTextField { input(field.stringValue) } }
|
|
@@ -966,6 +996,8 @@ final class CraftMacRenderer {
|
|
|
966
996
|
return box
|
|
967
997
|
}
|
|
968
998
|
let box = reconcile(document)
|
|
999
|
+
// Boxes were reconfigured and children replaced: nothing measured before now holds.
|
|
1000
|
+
CraftMacBox.measureGeneration &+= 1
|
|
969
1001
|
for (id, box) in boxes where !retained.contains(id) {
|
|
970
1002
|
box.imageTask?.cancel(); box.removeFromSuperview(); boxes.removeValue(forKey: id)
|
|
971
1003
|
if let sheet = sheets.removeValue(forKey: id) { window?.endSheet(sheet); sheet.close() }
|