craft-native 0.0.424 → 0.0.436
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/CraftNativeMutation.swift +18 -3
- 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 +73 -14
- 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 +85 -22
- package/dist/macos/templates/CraftMacRuntime.swift +34 -2
- package/dist/macos/templates/CraftNativeMutation.swift +18 -3
- 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.436";
|
|
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
|
|
@@ -18,10 +18,22 @@ struct CraftNativeMutationFailure: Error, Equatable {
|
|
|
18
18
|
struct CraftNativeMutationResult {
|
|
19
19
|
let batchId: String
|
|
20
20
|
let revision: Int
|
|
21
|
-
let document: [String: Any]?
|
|
22
21
|
let updatedNodeIds: [String]
|
|
23
22
|
let affectedNodeIds: [String]
|
|
24
23
|
let requiresFullRender: Bool
|
|
24
|
+
private let materialize: () -> [String: Any]?
|
|
25
|
+
/// The committed tree, built when asked for: a host that redraws only the
|
|
26
|
+
/// nodes a batch touched never needs the whole tree.
|
|
27
|
+
var document: [String: Any]? { materialize() }
|
|
28
|
+
|
|
29
|
+
init(batchId: String, revision: Int, document: @escaping () -> [String: Any]?, updatedNodeIds: [String], affectedNodeIds: [String], requiresFullRender: Bool) {
|
|
30
|
+
self.batchId = batchId
|
|
31
|
+
self.revision = revision
|
|
32
|
+
self.materialize = document
|
|
33
|
+
self.updatedNodeIds = updatedNodeIds
|
|
34
|
+
self.affectedNodeIds = affectedNodeIds
|
|
35
|
+
self.requiresFullRender = requiresFullRender
|
|
36
|
+
}
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
/// Validates and applies versioned native-tree mutations without touching UIKit.
|
|
@@ -91,6 +103,8 @@ final class CraftNativeMutationDocument {
|
|
|
91
103
|
var updatedNodeIds: [String] = []
|
|
92
104
|
var affectedNodeIds: [String] = []
|
|
93
105
|
var requiresFullRender = false
|
|
106
|
+
// Only an operation that adds, moves or removes nodes can leave one unreachable.
|
|
107
|
+
var reshaped = false
|
|
94
108
|
for (index, value) in operations.enumerated() {
|
|
95
109
|
guard let operation = value as? [String: Any], let name = operation["op"] as? String else {
|
|
96
110
|
throw CraftNativeMutationFailure(
|
|
@@ -99,6 +113,7 @@ final class CraftNativeMutationDocument {
|
|
|
99
113
|
operationIndex: index
|
|
100
114
|
)
|
|
101
115
|
}
|
|
116
|
+
if name != "updateNode" { reshaped = true }
|
|
102
117
|
do {
|
|
103
118
|
switch name {
|
|
104
119
|
case "createNode":
|
|
@@ -147,7 +162,7 @@ final class CraftNativeMutationDocument {
|
|
|
147
162
|
guard let root = candidateNodes[candidateRoot], root.parent == nil else {
|
|
148
163
|
throw CraftNativeMutationFailure("INVALID_TREE", "root node must not have a parent")
|
|
149
164
|
}
|
|
150
|
-
guard descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
|
|
165
|
+
guard !reshaped || descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
|
|
151
166
|
throw CraftNativeMutationFailure("INVALID_TREE", "all nodes must be reachable from the root")
|
|
152
167
|
}
|
|
153
168
|
}
|
|
@@ -158,7 +173,7 @@ final class CraftNativeMutationDocument {
|
|
|
158
173
|
return CraftNativeMutationResult(
|
|
159
174
|
batchId: batchId,
|
|
160
175
|
revision: nextRevision,
|
|
161
|
-
document: candidateRoot.flatMap { materialize($0, nodes: candidateNodes) },
|
|
176
|
+
document: { [candidateRoot, candidateNodes, weak self] in candidateRoot.flatMap { self?.materialize($0, nodes: candidateNodes) } },
|
|
162
177
|
updatedNodeIds: updatedNodeIds,
|
|
163
178
|
affectedNodeIds: affectedNodeIds,
|
|
164
179
|
requiresFullRender: requiresFullRender
|